1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Schema from "effect/Schema";
4import {
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
7 PreviewAutomationRecordingTransferError,
8 PreviewAutomationRecordingDesktopUpdateRequiredError,
9 PreviewAutomationRecordingArtifact,
10 type ToolActivityIcon,
11 type ThreadId,
12 type PreviewAutomationOperation,
13 type PreviewAutomationOpenInput,
14 type PreviewAutomationRecordingStatus,
15 type PreviewAutomationResizeResult,
16 type PreviewAutomationSelectResult,
17 type PreviewAutomationSetColorSchemeResult,
18 type PreviewAutomationSnapshot,
19 type PreviewAutomationStatus,
20 type PreviewTabId,
21} from "@t3tools/contracts";
22
23import {
24 parseAttachmentUuid,
25 parseAttachmentFileExtension,
26 PENDING_ATTACHMENT_THREAD_SEGMENT,
27 toSafeThreadAttachmentSegment,
28} from "../../../attachmentStore.ts";
29import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
30import * as ServerConfig from "../../../config.ts";
31import * as McpInvocationContext from "../../McpInvocationContext.ts";
32import * as McpToolAccess from "../../McpToolAccess.ts";
33import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
34import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
35
36/**
37 * Collapses the `show` alias onto `open` and defaults tab reuse.
38 *
39 * Deliberately leaves an unstated `open` unstated. Whether a preview the agent
40 * said nothing about surfaces is the user's `browserAutoShowFloatingPreview`
41 * preference, which is desktop-local and unreadable from here — filling in
42 * `true` would silently override it for every `preview_open`.
43 */
44export function normalizePreviewOpenInput(
45 input: PreviewAutomationOpenInput,
46): PreviewAutomationOpenInput {
47 const open = input.open ?? input.show;
48 return {
49 ...input,
50 ...(open === undefined ? {} : { open, show: open }),
51 reuseExistingTab: input.reuseExistingTab ?? true,
52 };
53}
54
55const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
56 operation: PreviewAutomationOperation,
57 input: unknown,
58 timeoutMs?: number,
59 tabId?: PreviewTabId,
60): Effect.fn.Return<
61 { result: A; toolIcon?: ToolActivityIcon },
62 import("@t3tools/contracts").PreviewAutomationError,
63 McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
64> {
65 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
66 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
67 let targetTabId = tabId;
68 const result = yield* broker.invoke<A>({
69 onTargetTab: (resolvedTabId) => {
70 targetTabId = resolvedTabId;
71 },
72 scope,
73 operation,
74 input,
75 ...(timeoutMs === undefined ? {} : { timeoutMs }),
76 ...(tabId === undefined ? {} : { tabId }),
77 });
78 if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result };
79 const statusTabId =
80 (operation !== "evaluate" && typeof result === "object" && result !== null
81 ? (result as { tabId?: PreviewTabId }).tabId
82 : undefined) ?? targetTabId;
83 const page = yield* broker
84 .invoke<PreviewAutomationStatus>({
85 scope,
86 operation: "status",
87 input: {},
88 timeoutMs: 500,
89 updateCurrentTab: false,
90 ...(statusTabId === undefined ? {} : { tabId: statusTabId }),
91 })
92 .pipe(Effect.orElseSucceed(() => null));
93 return {
94 result,
95 ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096
96 ? { toolIcon: { _tag: "website" as const, pageUrl: page.url } }
97 : {}),
98 };
99});
100
101const invokeTargeted = <A extends object>(
102 operation: PreviewAutomationOperation,
103 input: {
104 readonly tabId?: PreviewTabId | undefined;
105 readonly [key: string]: unknown;
106 },
107 timeoutMs?: number,
108) => {
109 const { tabId, ...operationInput } = input;
110 return invoke<A>(operation, operationInput, timeoutMs, tabId).pipe(
111 Effect.map(({ result, toolIcon }) => ({
112 ...result,
113 ...(toolIcon ? { toolIcon } : {}),
114 })),
115 );
116};
117
118const UploadedRecordingArtifact = Schema.Struct({
119 ...PreviewAutomationRecordingArtifact.fields,
120 uploadedAttachmentId: Schema.optional(Schema.String),
121});
122const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact);
123
124export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* (
125 threadId: ThreadId,
126 response: unknown,
127) {
128 const artifact = yield* decodeUploadedRecordingArtifact(response).pipe(
129 Effect.mapError(
130 (cause) =>
131 new PreviewAutomationRecordingTransferError({
132 threadId,
133 cause,
134 }),
135 ),
136 );
137 if (!artifact.uploadedAttachmentId) {
138 return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId });
139 }
140 const config = yield* ServerConfig.ServerConfig;
141 const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId);
142 const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId);
143 const threadSegment = toSafeThreadAttachmentSegment(threadId);
144 const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`;
145 if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) {
146 return yield* new PreviewAutomationRecordingTransferError({
147 threadId,
148 });
149 }
150 // The same completed upload can be returned to overlapping stop requests.
151 const finalId = `${threadSegment}-${uuid}-${extension}`;
152 const currentPath = resolveAttachmentRelativePath({
153 attachmentsDir: config.attachmentsDir,
154 relativePath: `${pendingId}.${extension}`,
155 });
156 const finalPath = resolveAttachmentRelativePath({
157 attachmentsDir: config.attachmentsDir,
158 relativePath: `${finalId}.${extension}`,
159 });
160 if (!currentPath || !finalPath) {
161 return yield* new PreviewAutomationRecordingTransferError({ threadId });
162 }
163 const fileSystem = yield* FileSystem.FileSystem;
164 const validateFile = (filePath: string) =>
165 fileSystem.stat(filePath).pipe(
166 Effect.filterOrFail(
167 (stat) =>
168 stat.type === "File" &&
169 Number(stat.size) === artifact.sizeBytes &&
170 artifact.sizeBytes > 0 &&
171 artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES,
172 () => new PreviewAutomationRecordingTransferError({ threadId }),
173 ),
174 );
175 yield* Effect.gen(function* () {
176 yield* validateFile(currentPath);
177 yield* fileSystem.rename(currentPath, finalPath);
178 }).pipe(
179 // Another stop may already have claimed this exact upload for this thread.
180 Effect.catchIf(
181 (cause) =>
182 cause._tag !== "PreviewAutomationRecordingTransferError" &&
183 cause.reason._tag === "NotFound",
184 () => validateFile(finalPath),
185 ),
186 Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })),
187 );
188 const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact;
189 return { ...recording, id: finalId, path: finalPath };
190});
191
192const handlers = {
193 preview_dialog: McpToolAccess.actsAsCaller((input) =>
194 invokeTargeted<PreviewAutomationStatus>("dialog", input),
195 ),
196 preview_status: McpToolAccess.readsAsCaller((input) =>
197 invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
198 ),
199 preview_open: McpToolAccess.actsAsCaller((input) =>
200 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
201 ),
202 preview_navigate: McpToolAccess.actsAsCaller((input) =>
203 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
204 ),
205 preview_resize: McpToolAccess.actsAsCaller((input) =>
206 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
207 ),
208 preview_set_appearance: McpToolAccess.actsAsCaller((input) =>
209 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
210 ),
211 preview_snapshot: McpToolAccess.readsAsCaller((input) => {
212 // Output selection and saving are MCP-only; the browser still produces a complete snapshot.
213 const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
214 return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
215 }),
216 preview_click: McpToolAccess.actsAsCaller((input) =>
217 invokeTargeted<object>("click", input, input.timeoutMs),
218 ),
219 preview_type: McpToolAccess.actsAsCaller((input) =>
220 invokeTargeted<object>("type", input, input.timeoutMs),
221 ),
222 preview_hover: McpToolAccess.actsAsCaller((input) =>
223 invokeTargeted<object>("hover", input, input.timeoutMs),
224 ),
225 preview_select: McpToolAccess.actsAsCaller((input) =>
226 invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),
227 ),
228 preview_drag: McpToolAccess.actsAsCaller((input) =>
229 invokeTargeted<object>("drag", input, input.timeoutMs),
230 ),
231 preview_upload: McpToolAccess.actsAsCaller((input) =>
232 invokeTargeted<object>("upload", input, input.timeoutMs),
233 ),
234 preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("press", input)),
235 preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("scroll", input)),
236 preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) =>
237 invoke<unknown>("evaluate", input, undefined, tabId).pipe(
238 Effect.map(({ result, toolIcon }) => ({
239 value: result ?? null,
240 ...(toolIcon ? { toolIcon } : {}),
241 })),
242 ),
243 ),
244 preview_wait_for: McpToolAccess.readsAsCaller((input) =>
245 invokeTargeted<object>("waitFor", input, input.timeoutMs),
246 ),
247 preview_recording_start: McpToolAccess.actsAsCaller((input) =>
248 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
249 ),
250 preview_recording_stop: McpToolAccess.actsAsCaller((input) =>
251 Effect.gen(function* () {
252 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
253 const { tabId, ...operationInput } = input;
254 const response = yield* invoke<unknown>(
255 "recordingStop",
256 { ...operationInput, transferToEnvironment: true },
257 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
258 tabId,
259 );
260 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);
261 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
262 }),
263 ),
264} satisfies McpToolAccess.Handlers<typeof PreviewToolkit.tools>;
265
266const { preview_snapshot, ...standardHandlers } = handlers;
267
268export const layerStandard = McpToolAccess.toLayer(PreviewStandardToolkit, standardHandlers);
269
270export const layerSnapshot = McpToolAccess.toLayer(PreviewSnapshotToolkit, {
271 preview_snapshot,
272});