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
43 changes: 43 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,49 @@ it.effect("classifies a remote non-editable target without collapsing it to exec
);
});

it.effect.each([
"PreviewAutomationRecordingTransferError",
"PreviewAutomationRecordingDesktopUpdateRequiredError",
"PreviewAutomationRecordingTooLargeError",
"PreviewAutomationRecordingDeadlineExpiredError",
] as const)("preserves recording failure %s", (tag) =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const remoteError = {
_tag: tag,
message: "remote recording details",
detail: { reason: "untrusted-reason", threadId: "untrusted-thread" },
};
const requests = requestsFrom(yield* broker.connect(makeHost()));
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId: "client-1",
connectionId: request.connectionId,
requestId: request.requestId,
ok: false,
error: remoteError,
}),
).pipe(Effect.forkScoped);
yield* Effect.yieldNow;
const error = yield* broker
.invoke<void>({
scope,
operation: "recordingStop",
input: {},
})
.pipe(Effect.flip);
expect(error).toMatchObject({
_tag: tag,
threadId: scope.threadId,
});
expect(error.cause).toBe(remoteError);
expect(error.message).toContain("remains on the desktop");
expect(error.message).not.toContain("remote recording details");
}),
),
);

it.effect("distinguishes malformed remote failures", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
PreviewAutomationMalformedResponseError,
PreviewAutomationNoAvailableHostError,
PreviewAutomationRemoteUnavailableError,
PreviewAutomationRecordingTransferError,
PreviewAutomationRecordingDesktopUpdateRequiredError,
PreviewAutomationRecordingTooLargeError,
PreviewAutomationRecordingDeadlineExpiredError,
PreviewAutomationRequestQueueClosedError,
PreviewAutomationResultTooLargeError,
PreviewAutomationTabNotFoundError,
Expand Down Expand Up @@ -194,6 +198,26 @@ const classifyResponseError = (
cause: error,
};
switch (error._tag) {
case "PreviewAutomationRecordingDesktopUpdateRequiredError":
return new PreviewAutomationRecordingDesktopUpdateRequiredError({
threadId: context.threadId,
cause: error,
});
case "PreviewAutomationRecordingTooLargeError":
return new PreviewAutomationRecordingTooLargeError({
threadId: context.threadId,
cause: error,
});
case "PreviewAutomationRecordingDeadlineExpiredError":
return new PreviewAutomationRecordingDeadlineExpiredError({
threadId: context.threadId,
cause: error,
});
case "PreviewAutomationRecordingTransferError":
return new PreviewAutomationRecordingTransferError({
threadId: context.threadId,
cause: error,
});
case "PreviewAutomationNoAvailableHostError":
return new PreviewAutomationNoAvailableHostError({
...context,
Expand Down
127 changes: 125 additions & 2 deletions apps/server/src/mcp/toolkits/preview/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";

import { normalizePreviewOpenInput } from "./handlers.ts";
import {
createPendingAttachmentId,
parseThreadSegmentFromAttachmentId,
} from "../../../attachmentStore.ts";
import * as ServerConfig from "../../../config.ts";
import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts";

describe("normalizePreviewOpenInput", () => {
it("leaves an unstated visibility for the client preference to decide", () => {
Expand Down Expand Up @@ -30,3 +41,115 @@ describe("normalizePreviewOpenInput", () => {
});
});
});

describe("claimPreviewRecording", () => {
it.effect("overlapping and repeated claims return the same retained recording", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const uploadedAttachmentId = createPendingAttachmentId(".webm");
const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(pendingPath, "video!");
const response = {
id: "desktop-recording",
tabId: "tab-1",
path: "/desktop/recording.webm",
mimeType: "video/webm",
sizeBytes: 6,
createdAt: "2026-09-07T00:00:00.000Z",
uploadedAttachmentId,
};
const claim = claimPreviewRecording(ThreadId.make("thread-1"), response);
const [first, second] = yield* Effect.all([claim, claim], { concurrency: "unbounded" });
expect(first).toEqual(second);
expect(yield* claim).toEqual(first);
expect(yield* fileSystem.readFileString(first.path)).toBe("video!");
expect(yield* fileSystem.exists(pendingPath)).toBe(false);
const wrongThread = yield* claimPreviewRecording(ThreadId.make("thread-2"), response).pipe(
Effect.result,
);
expect(wrongThread._tag).toBe("Failure");
const wrongPath = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
...response,
uploadedAttachmentId: `../${uploadedAttachmentId}`,
}).pipe(Effect.result);
expect(wrongPath._tag).toBe("Failure");
}).pipe(
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
),
);

it.effect.each([6, 5])(
"claims only a complete uploaded recording (reported bytes: %s)",
(sizeBytes) =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const uploadedAttachmentId = createPendingAttachmentId(".webm");
const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(pendingPath, "video!");
const response = {
id: "desktop-recording",
tabId: "tab-1",
path: "/desktop/recording.webm",
mimeType: "video/webm",
sizeBytes,
createdAt: "2026-09-07T00:00:00.000Z",
uploadedAttachmentId,
};
const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), response).pipe(
Effect.result,
);
if (sizeBytes === 6) {
expect(result._tag).toBe("Success");
if (result._tag !== "Success") return;
expect(result.success.path).not.toBe(response.path);
expect(parseThreadSegmentFromAttachmentId(result.success.id)).toBe("thread-1");
expect(yield* fileSystem.readFileString(result.success.path)).toBe("video!");
expect(yield* fileSystem.exists(pendingPath)).toBe(false);
} else {
expect(result._tag).toBe("Failure");
if (result._tag !== "Failure") return;
expect(result.failure._tag).toBe("PreviewAutomationRecordingTransferError");
expect(yield* fileSystem.exists(pendingPath)).toBe(true);
}
}).pipe(
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
),
);

it.effect("reports an older desktop without returning its inaccessible path", () =>
Effect.gen(function* () {
const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
id: "desktop-recording",
tabId: "tab-1",
path: "/desktop/recording.webm",
mimeType: "video/webm",
sizeBytes: 6,
createdAt: "2026-09-07T00:00:00.000Z",
}).pipe(Effect.result);
expect(result._tag).toBe("Failure");
if (result._tag !== "Failure") return;
expect(result.failure._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError");
expect(result.failure.message).toContain("Update the desktop app");
}).pipe(
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
),
);
});
116 changes: 106 additions & 10 deletions apps/server/src/mcp/toolkits/preview/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import * as Effect from "effect/Effect";
import type {
PreviewAutomationOperation,
PreviewAutomationOpenInput,
import * as FileSystem from "effect/FileSystem";
import * as Schema from "effect/Schema";
import {
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
PREVIEW_RECORDING_STOP_TIMEOUT_MS,
PreviewAutomationRecordingTransferError,
PreviewAutomationRecordingDesktopUpdateRequiredError,
PreviewAutomationRecordingArtifact,
PreviewAutomationRecordingStatus,
PreviewAutomationResizeResult,
PreviewAutomationSetColorSchemeResult,
PreviewAutomationSnapshot,
PreviewAutomationStatus,
PreviewTabId,
type ThreadId,
type PreviewAutomationOperation,
type PreviewAutomationOpenInput,
type PreviewAutomationRecordingStatus,
type PreviewAutomationResizeResult,
type PreviewAutomationSetColorSchemeResult,
type PreviewAutomationSnapshot,
type PreviewAutomationStatus,
type PreviewTabId,
} from "@t3tools/contracts";

import {
parseAttachmentUuid,
parseAttachmentFileExtension,
PENDING_ATTACHMENT_THREAD_SEGMENT,
toSafeThreadAttachmentSegment,
} from "../../../attachmentStore.ts";
import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
import * as ServerConfig from "../../../config.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
Expand Down Expand Up @@ -67,6 +82,79 @@ const invokeTargeted = <A>(
return invoke<A>(operation, operationInput, timeoutMs, tabId);
};

const UploadedRecordingArtifact = Schema.Struct({
...PreviewAutomationRecordingArtifact.fields,
uploadedAttachmentId: Schema.optional(Schema.String),
});
const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact);

export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* (
threadId: ThreadId,
response: unknown,
) {
const artifact = yield* decodeUploadedRecordingArtifact(response).pipe(
Effect.mapError(
(cause) =>
new PreviewAutomationRecordingTransferError({
threadId,
cause,
}),
),
);
if (!artifact.uploadedAttachmentId) {
return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId });
}
const config = yield* ServerConfig.ServerConfig;
const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId);
const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId);
const threadSegment = toSafeThreadAttachmentSegment(threadId);
const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`;
if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) {
return yield* new PreviewAutomationRecordingTransferError({
threadId,
});
}
// The same completed upload can be returned to overlapping stop requests.
const finalId = `${threadSegment}-${uuid}-${extension}`;
const currentPath = resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
relativePath: `${pendingId}.${extension}`,
});
const finalPath = resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
relativePath: `${finalId}.${extension}`,
});
if (!currentPath || !finalPath) {
return yield* new PreviewAutomationRecordingTransferError({ threadId });
}
const fileSystem = yield* FileSystem.FileSystem;
const validateFile = (filePath: string) =>
fileSystem.stat(filePath).pipe(
Effect.filterOrFail(
(stat) =>
stat.type === "File" &&
Number(stat.size) === artifact.sizeBytes &&
artifact.sizeBytes > 0 &&
artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES,
() => new PreviewAutomationRecordingTransferError({ threadId }),
),
);
yield* Effect.gen(function* () {
yield* validateFile(currentPath);
yield* fileSystem.rename(currentPath, finalPath);
}).pipe(
// Another stop may already have claimed this exact upload for this thread.
Effect.catch((cause) =>
cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound"
? validateFile(finalPath)
: Effect.fail(cause),
),
Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })),
);
const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact;
return { ...recording, id: finalId, path: finalPath };
});

const handlers = {
preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
preview_open: (input) =>
Expand Down Expand Up @@ -94,7 +182,15 @@ const handlers = {
preview_recording_start: (input) =>
invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
preview_recording_stop: (input) =>
invokeTargeted<PreviewAutomationRecordingArtifact>("recordingStop", input ?? {}),
Effect.gen(function* () {
const scope = yield* McpInvocationContext.requireMcpCapability("preview");
const response = yield* invokeTargeted<unknown>(
Comment thread
maria-rcks marked this conversation as resolved.
"recordingStop",
{ ...input, transferToEnvironment: true },
PREVIEW_RECORDING_STOP_TIMEOUT_MS,
);
return yield* claimPreviewRecording(scope.threadId, response);
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];

const { preview_snapshot, ...standardHandlers } = handlers;
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/mcp/toolkits/preview/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import {
PreviewAutomationWaitForInput,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import * as FileSystem from "effect/FileSystem";
import { Tool, Toolkit } from "effect/unstable/ai";

import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
import * as ServerConfig from "../../../config.ts";

const dependencies = [
McpInvocationContext.McpInvocationContext,
Expand Down Expand Up @@ -207,11 +209,11 @@ export const PreviewRecordingStartTool = safeBrowserTool(
export const PreviewRecordingStopTool = safeBrowserTool(
Tool.make("preview_recording_stop", {
description:
"Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.",
"Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.",
parameters: PreviewAutomationTabTargetInput,
success: PreviewAutomationRecordingArtifact,
failure: PreviewAutomationError,
dependencies,
dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],
}).annotate(Tool.Title, "Stop browser recording"),
);

Expand Down
Loading
Loading