| author | |
| committer | |
| log | 1e192b25583dd682324acd52f396be3d2ec9ee23 |
| tree | c895e16ac23b8c541f25d6cf91f7d5fad4ed9c3a |
| parent | 99641fd09a509feb644b3c8ef718c12c60cf436c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 70 insertions(+), 11 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts+53-1| ... | ... | @@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])( |
| 161 | 161 | Effect.provideService(McpSchema.McpServerClient, client), |
| 162 | 162 | ); |
| 163 | 163 | |
| 164 | const message = "Preview automation snapshot failed on client mcp-failure-client."; | |
| 164 | 165 | expect(snapshot.isError).toBe(true); |
| 165 | 166 | expect(snapshot.content).toEqual([ |
| 166 | { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, | |
| 167 | { type: "text", text: `Preview snapshot failed: ${message}` }, | |
| 167 | 168 | ]); |
| 168 | 169 | expect(snapshot.structuredContent).toEqual({ |
| 169 | 170 | error: { |
| 170 | 171 | _tag: "PreviewAutomationExecutionError", |
| 171 | 172 | operation: "snapshot", |
| 172 | 173 | failureCount: 1, |
| 174 | message, | |
| 173 | 175 | }, |
| 174 | 176 | }); |
| 175 | 177 | }), |
| 176 | 178 | ).pipe(Effect.provide(TestLayer)), |
| 177 | 179 | ); |
| 178 | 180 | |
| 181 | it.effect.each([ | |
| 182 | { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, | |
| 183 | { | |
| 184 | args: { tabId: alternateTabId }, | |
| 185 | advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, | |
| 186 | }, | |
| 187 | ])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => | |
| 188 | Effect.scoped( | |
| 189 | Effect.gen(function* () { | |
| 190 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 191 | const connected = yield* Deferred.make<void>(); | |
| 192 | const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); | |
| 193 | yield* Stream.runForEach(events, (event) => | |
| 194 | event.type === "connected" | |
| 195 | ? Deferred.succeed(connected, undefined) | |
| 196 | : broker.respond({ | |
| 197 | clientId: "mcp-no-tab-client", | |
| 198 | connectionId: event.connectionId, | |
| 199 | requestId: event.request.requestId, | |
| 200 | ok: false, | |
| 201 | error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, | |
| 202 | }), | |
| 203 | ).pipe(Effect.forkScoped); | |
| 204 | yield* Deferred.await(connected); | |
| 205 | ||
| 206 | const snapshot = yield* callSnapshot(args); | |
| 207 | ||
| 208 | expect(snapshot.isError).toBe(true); | |
| 209 | expect(snapshot.content).toEqual([ | |
| 210 | { type: "text", text: `Preview snapshot failed: ${advice}` }, | |
| 211 | ]); | |
| 212 | }), | |
| 213 | ).pipe(Effect.provide(TestLayer)), | |
| 214 | ); | |
| 215 | ||
| 216 | it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const snapshot = yield* callSnapshot({}); | |
| 219 | ||
| 220 | expect(snapshot.isError).toBe(true); | |
| 221 | const [text] = snapshot.content; | |
| 222 | expect(text?.type === "text" ? text.text : "").toContain( | |
| 223 | "use a headless browser from the shell", | |
| 224 | ); | |
| 225 | expect(snapshot.structuredContent).toMatchObject({ | |
| 226 | error: { _tag: "PreviewAutomationNoAvailableHostError" }, | |
| 227 | }); | |
| 228 | }).pipe(Effect.provide(TestLayer)), | |
| 229 | ); | |
| 230 | ||
| 179 | 231 | it.effect.each([ |
| 180 | 232 | { mode: "default", input: {}, images: true }, |
| 181 | 233 | { mode: "explicit image", input: { includeImage: true }, images: true }, |
apps/server/src/mcp/McpHttpServer.ts+9-2| ... | ... | @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; |
| 13 | 13 | import type * as Types from "effect/Types"; |
| 14 | 14 | import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; |
| 15 | 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 16 | import { PreviewAutomationError } from "@t3tools/contracts"; | |
| 16 | 17 | |
| 17 | 18 | import packageJson from "../../package.json" with { type: "json" }; |
| 18 | 19 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( |
| 328 | 329 | return screenshotPath; |
| 329 | 330 | }); |
| 330 | 331 | |
| 332 | const isPreviewAutomationError = Schema.is(PreviewAutomationError); | |
| 333 | ||
| 331 | 334 | const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 332 | 335 | if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { |
| 333 | 336 | return Effect.failCause(cause).pipe(Effect.orDie); |
| ... | ... | @@ -341,6 +344,9 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 341 | 344 | typeof firstFailure._tag === "string" |
| 342 | 345 | ? firstFailure._tag |
| 343 | 346 | : "PreviewSnapshotError"; |
| 347 | // Preview errors build their message on the server, never from page output, | |
| 348 | // and it tells the agent what to do next, such as falling back to a shell browser. | |
| 349 | const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; | |
| 344 | 350 | const result = new McpSchema.CallToolResult({ |
| 345 | 351 | isError: true, |
| 346 | 352 | structuredContent: { |
| ... | ... | @@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 348 | 354 | _tag: errorTag, |
| 349 | 355 | operation: "snapshot", |
| 350 | 356 | failureCount: failures.length, |
| 357 | ...(message === undefined ? {} : { message }), | |
| 351 | 358 | }, |
| 352 | 359 | }, |
| 353 | // Agents usually see only the text content, so name the tag there too. | |
| 354 | content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], | |
| 360 | // Some clients show only the text content and others only structuredContent, so both carry it. | |
| 361 | content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }], | |
| 355 | 362 | }); |
| 356 | 363 | return Effect.logWarning("preview snapshot failed", { |
| 357 | 364 | operation: "snapshot", |
apps/server/src/mcp/McpInvocationContext.test.ts+2-1| ... | ... | @@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav |
| 34 | 34 | providerSessionId: invocation.providerSessionId, |
| 35 | 35 | providerInstanceId: invocation.providerInstanceId, |
| 36 | 36 | }); |
| 37 | expect(error.message).toBe("MCP credential does not grant the preview capability."); | |
| 37 | expect(error.message).toContain("MCP credential does not grant the preview capability"); | |
| 38 | expect(error.message).toContain("use a headless browser from the shell"); | |
| 38 | 39 | }); |
| 39 | 40 | }); |
| 40 | 41 |
packages/contracts/src/previewAutomation.ts+6-7| ... | ... | @@ -649,6 +649,7 @@ const McpCapabilityErrorFields = { |
| 649 | 649 | providerInstanceId: ProviderInstanceId, |
| 650 | 650 | }; |
| 651 | 651 | |
| 652 | /** Agents read this message, so it names the next step and not only the failure. */ | |
| 652 | 653 | export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()( |
| 653 | 654 | "PreviewAutomationUnavailableError", |
| 654 | 655 | { |
| ... | ... | @@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe |
| 657 | 658 | }, |
| 658 | 659 | ) { |
| 659 | 660 | override get message(): string { |
| 660 | return `MCP credential does not grant the ${this.capability} capability.`; | |
| 661 | return `MCP credential does not grant the ${this.capability} capability: browser preview tools are off for this thread. Do not retry them. To check a page, use a headless browser from the shell, such as Playwright, or curl. The user can turn on "Agent browser access" in Settings; it applies when the agent session next starts.`; | |
| 661 | 662 | } |
| 662 | 663 | } |
| 663 | 664 | |
| ... | ... | @@ -722,8 +723,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr |
| 722 | 723 | }, |
| 723 | 724 | ) { |
| 724 | 725 | override get message(): string { |
| 725 | const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`; | |
| 726 | return summary; | |
| 726 | return `No preview automation host is available for ${this.operation} in environment ${this.environmentId}. Preview tools run in a T3 Code desktop app that is open and connected to this environment; a headless server has no browser of its own. Do not retry. To check a page, use a headless browser from the shell, such as Playwright, or curl, or ask the user to open this thread in the T3 Code desktop app.`; | |
| 727 | 727 | } |
| 728 | 728 | } |
| 729 | 729 | |
| ... | ... | @@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe |
| 747 | 747 | }, |
| 748 | 748 | ) { |
| 749 | 749 | override get message(): string { |
| 750 | const summary = this.tabId | |
| 751 | ? `Preview tab ${this.tabId} was not found for ${this.operation}.` | |
| 752 | : `No active preview tab was found for ${this.operation}.`; | |
| 753 | return summary; | |
| 750 | return this.tabId | |
| 751 | ? `Preview tab ${this.tabId} was not found for ${this.operation}. Omit tabId to use the current tab, or call preview_open.` | |
| 752 | : `No active preview tab was found for ${this.operation}. Call preview_open first.`; | |
| 754 | 753 | } |
| 755 | 754 | } |
| 756 | 755 |