| 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,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])( |
| 161 | Effect.provideService(McpSchema.McpServerClient, client), | 161 | Effect.provideService(McpSchema.McpServerClient, client), |
| 162 | ); | 162 | ); |
| 163 | 163 | ||
| 164 | const message = "Preview automation snapshot failed on client mcp-failure-client."; | ||
| 164 | expect(snapshot.isError).toBe(true); | 165 | expect(snapshot.isError).toBe(true); |
| 165 | expect(snapshot.content).toEqual([ | 166 | expect(snapshot.content).toEqual([ |
| 166 | { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, | 167 | { type: "text", text: `Preview snapshot failed: ${message}` }, |
| 167 | ]); | 168 | ]); |
| 168 | expect(snapshot.structuredContent).toEqual({ | 169 | expect(snapshot.structuredContent).toEqual({ |
| 169 | error: { | 170 | error: { |
| 170 | _tag: "PreviewAutomationExecutionError", | 171 | _tag: "PreviewAutomationExecutionError", |
| 171 | operation: "snapshot", | 172 | operation: "snapshot", |
| 172 | failureCount: 1, | 173 | failureCount: 1, |
| 174 | message, | ||
| 173 | }, | 175 | }, |
| 174 | }); | 176 | }); |
| 175 | }), | 177 | }), |
| 176 | ).pipe(Effect.provide(TestLayer)), | 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 | it.effect.each([ | 231 | it.effect.each([ |
| 180 | { mode: "default", input: {}, images: true }, | 232 | { mode: "default", input: {}, images: true }, |
| 181 | { mode: "explicit image", input: { includeImage: true }, images: true }, | 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,6 +13,7 @@ import * as Stream from "effect/Stream"; |
| 13 | import type * as Types from "effect/Types"; | 13 | import type * as Types from "effect/Types"; |
| 14 | import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; | 14 | import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; |
| 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 16 | import { PreviewAutomationError } from "@t3tools/contracts"; | ||
| 16 | 17 | ||
| 17 | import packageJson from "../../package.json" with { type: "json" }; | 18 | import packageJson from "../../package.json" with { type: "json" }; |
| 18 | import * as ServerConfig from "../config.ts"; | 19 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( | ... | @@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( |
| 328 | return screenshotPath; | 329 | return screenshotPath; |
| 329 | }); | 330 | }); |
| 330 | 331 | ||
| 332 | const isPreviewAutomationError = Schema.is(PreviewAutomationError); | ||
| 333 | |||
| 331 | const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { | 334 | const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 332 | if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { | 335 | if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { |
| 333 | return Effect.failCause(cause).pipe(Effect.orDie); | 336 | return Effect.failCause(cause).pipe(Effect.orDie); |
| ... | @@ -341,6 +344,9 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { | ... | @@ -341,6 +344,9 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 341 | typeof firstFailure._tag === "string" | 344 | typeof firstFailure._tag === "string" |
| 342 | ? firstFailure._tag | 345 | ? firstFailure._tag |
| 343 | : "PreviewSnapshotError"; | 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 | const result = new McpSchema.CallToolResult({ | 350 | const result = new McpSchema.CallToolResult({ |
| 345 | isError: true, | 351 | isError: true, |
| 346 | structuredContent: { | 352 | structuredContent: { |
| ... | @@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { | ... | @@ -348,10 +354,11 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 348 | _tag: errorTag, | 354 | _tag: errorTag, |
| 349 | operation: "snapshot", | 355 | operation: "snapshot", |
| 350 | failureCount: failures.length, | 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. | 360 | // Some clients show only the text content and others only structuredContent, so both carry it. |
| 354 | content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], | 361 | content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }], |
| 355 | }); | 362 | }); |
| 356 | return Effect.logWarning("preview snapshot failed", { | 363 | return Effect.logWarning("preview snapshot failed", { |
| 357 | operation: "snapshot", | 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,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav |
| 34 | providerSessionId: invocation.providerSessionId, | 34 | providerSessionId: invocation.providerSessionId, |
| 35 | providerInstanceId: invocation.providerInstanceId, | 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,6 +649,7 @@ const McpCapabilityErrorFields = { |
| 649 | providerInstanceId: ProviderInstanceId, | 649 | providerInstanceId: ProviderInstanceId, |
| 650 | }; | 650 | }; |
| 651 | 651 | ||
| 652 | /** Agents read this message, so it names the next step and not only the failure. */ | ||
| 652 | export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()( | 653 | export class PreviewAutomationUnavailableError extends Schema.TaggedError<PreviewAutomationUnavailableError>()( |
| 653 | "PreviewAutomationUnavailableError", | 654 | "PreviewAutomationUnavailableError", |
| 654 | { | 655 | { |
| ... | @@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe | ... | @@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError<Previe |
| 657 | }, | 658 | }, |
| 658 | ) { | 659 | ) { |
| 659 | override get message(): string { | 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,8 +723,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr |
| 722 | }, | 723 | }, |
| 723 | ) { | 724 | ) { |
| 724 | override get message(): string { | 725 | override get message(): string { |
| 725 | const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`; | 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.`; |
| 726 | return summary; | ||
| 727 | } | 727 | } |
| 728 | } | 728 | } |
| 729 | 729 | ||
| ... | @@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe | ... | @@ -747,10 +747,9 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError<Previe |
| 747 | }, | 747 | }, |
| 748 | ) { | 748 | ) { |
| 749 | override get message(): string { | 749 | override get message(): string { |
| 750 | const summary = this.tabId | 750 | return this.tabId |
| 751 | ? `Preview tab ${this.tabId} was not found for ${this.operation}.` | 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}.`; | 752 | : `No active preview tab was found for ${this.operation}. Call preview_open first.`; |
| 753 | return summary; | ||
| 754 | } | 753 | } |
| 755 | } | 754 | } |
| 756 | 755 |