diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1415e2e8dcb87f0ecffee94b1907e110fc16553c..5619f19b25497b76a5f3ac25b027717d94ce7ac5 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])( Effect.provideService(McpSchema.McpServerClient, client), ); + const message = "Preview automation snapshot failed on client mcp-failure-client."; expect(snapshot.isError).toBe(true); expect(snapshot.content).toEqual([ - { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, + { type: "text", text: `Preview snapshot failed: ${message}` }, ]); expect(snapshot.structuredContent).toEqual({ error: { _tag: "PreviewAutomationExecutionError", operation: "snapshot", failureCount: 1, + message, }, }); }), ).pipe(Effect.provide(TestLayer)), ); +it.effect.each([ + { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, + { + args: { tabId: alternateTabId }, + advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, + }, +])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Deferred.succeed(connected, undefined) + : broker.respond({ + clientId: "mcp-no-tab-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: false, + error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + + const snapshot = yield* callSnapshot(args); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([ + { type: "text", text: `Preview snapshot failed: ${advice}` }, + ]); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () => + Effect.gen(function* () { + const snapshot = yield* callSnapshot({}); + + expect(snapshot.isError).toBe(true); + const [text] = snapshot.content; + expect(text?.type === "text" ? text.text : "").toContain( + "use a headless browser from the shell", + ); + expect(snapshot.structuredContent).toMatchObject({ + error: { _tag: "PreviewAutomationNoAvailableHostError" }, + }); + }).pipe(Effect.provide(TestLayer)), +); + it.effect.each([ { mode: "default", input: {}, images: true }, { mode: "explicit image", input: { includeImage: true }, images: true }, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f93c7643dafb064a3a3e6b5990ea099b819b0ce7..5a807fc328af01b577bed46e98128f58883ffb7b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { PreviewAutomationError } from "@t3tools/contracts"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; @@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); +const isPreviewAutomationError = Schema.is(PreviewAutomationError); + const previewSnapshotFailure = (cause: Cause.Cause) => { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); @@ -341,6 +344,9 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { typeof firstFailure._tag === "string" ? firstFailure._tag : "PreviewSnapshotError"; + // Preview errors build their message on the server, never from page output, + // and it tells the agent what to do next, such as falling back to a shell browser. + const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, structuredContent: { @@ -348,10 +354,11 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { _tag: errorTag, operation: "snapshot", failureCount: failures.length, + ...(message === undefined ? {} : { message }), }, }, - // Agents usually see only the text content, so name the tag there too. - content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], + // Some clients show only the text content and others only structuredContent, so both carry it. + content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }], }); return Effect.logWarning("preview snapshot failed", { operation: "snapshot", diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 123944206e37d94e9e7d1466547a4bcbc33b3d5f..4314c82e6ddc369b53b6d8d25b83d993ebcf86ed 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav providerSessionId: invocation.providerSessionId, providerInstanceId: invocation.providerInstanceId, }); - expect(error.message).toBe("MCP credential does not grant the preview capability."); + expect(error.message).toContain("MCP credential does not grant the preview capability"); + expect(error.message).toContain("use a headless browser from the shell"); }); }); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 431e83189d2246bdcce5afe26b2b633ce5b86ff5..f87739670f75d86e8aba4008c6d72af184982b8d 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -649,6 +649,7 @@ const McpCapabilityErrorFields = { providerInstanceId: ProviderInstanceId, }; +/** Agents read this message, so it names the next step and not only the failure. */ export class PreviewAutomationUnavailableError extends Schema.TaggedError()( "PreviewAutomationUnavailableError", { @@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError