| author | |
| committer | |
| log | 5c715c6941184da09bf11f6651d303d284bd54da |
| tree | 21fb2f0ba5567450de8f93676188f8dd19ff80f3 |
| parent | 8b81a8880f56daf475136e9d89794bb2a33526d0 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 47 insertions(+), 31 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts+2-2| ... | ... | @@ -234,14 +234,14 @@ it.effect.each([ |
| 234 | 234 | ).pipe(Effect.provide(layerTest)), |
| 235 | 235 | ); |
| 236 | 236 | |
| 237 | it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () => | |
| 237 | it.effect("tells the agent how to recover when no preview host can run the snapshot", () => | |
| 238 | 238 | Effect.gen(function* () { |
| 239 | 239 | const snapshot = yield* callSnapshot({}); |
| 240 | 240 | |
| 241 | 241 | expect(snapshot.isError).toBe(true); |
| 242 | 242 | const [text] = snapshot.content; |
| 243 | 243 | expect(text?.type === "text" ? text.text : "").toContain( |
| 244 | "use a headless browser from the shell", | |
| 244 | "Retry preview_status, then call preview_open", | |
| 245 | 245 | ); |
| 246 | 246 | expect(snapshot.structuredContent).toMatchObject({ |
| 247 | 247 | error: { _tag: "PreviewAutomationNoAvailableHostError" }, |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+34-24| ... | ... | @@ -507,7 +507,6 @@ it.effect.each([ |
| 507 | 507 | threadId: scope.thread.threadId, |
| 508 | 508 | }); |
| 509 | 509 | expect(error.cause).toBe(remoteError); |
| 510 | expect(error.message).toContain("remains on the desktop"); | |
| 511 | 510 | expect(error.message).not.toContain("remote recording details"); |
| 512 | 511 | }), |
| 513 | 512 | ), |
| ... | ... | @@ -1352,7 +1351,7 @@ it.effect("discards buffered actions before completing an evicted host stream", |
| 1352 | 1351 | }) |
| 1353 | 1352 | .pipe(Effect.flip, Effect.forkScoped); |
| 1354 | 1353 | yield* Deferred.await(actionRouted); |
| 1355 | yield* TestClock.adjust(1_000); | |
| 1354 | yield* TestClock.adjust(2_000); | |
| 1356 | 1355 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1357 | 1356 | expect(yield* Fiber.join(buffered)).toMatchObject({ |
| 1358 | 1357 | _tag: "PreviewAutomationClientDisconnectedError", |
| ... | ... | @@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver |
| 1414 | 1413 | Effect.forkScoped, |
| 1415 | 1414 | ); |
| 1416 | 1415 | yield* Deferred.await(actionRouted); |
| 1417 | yield* TestClock.adjust(1_000); | |
| 1416 | yield* TestClock.adjust(2_000); | |
| 1418 | 1417 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1419 | 1418 | expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); |
| 1420 | 1419 | |
| ... | ... | @@ -1428,30 +1427,41 @@ it.effect("rejects a routed action when its generation is evicted before deliver |
| 1428 | 1427 | ), |
| 1429 | 1428 | ); |
| 1430 | 1429 | |
| 1431 | it.effect("keeps a host that responds with an operation timeout", () => | |
| 1430 | it.effect("keeps a host whose operation timeout arrives just after the budget", () => | |
| 1432 | 1431 | Effect.scoped( |
| 1433 | 1432 | Effect.gen(function* () { |
| 1434 | 1433 | const broker = yield* makeBroker; |
| 1435 | const connected = yield* Deferred.make<void>(); | |
| 1436 | const events = yield* broker.connect(makeHost()); | |
| 1437 | yield* Stream.runForEach(events, (event) => { | |
| 1438 | if (event.type === "connected") return Deferred.succeed(connected, undefined); | |
| 1439 | return broker.respond({ | |
| 1440 | clientId: "client-1", | |
| 1441 | connectionId: event.connectionId, | |
| 1442 | requestId: event.request.requestId, | |
| 1443 | ...(event.request.operation === "waitFor" | |
| 1444 | ? { | |
| 1445 | ok: false, | |
| 1446 | error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, | |
| 1447 | } | |
| 1448 | : { ok: true, result: "responsive" }), | |
| 1449 | }); | |
| 1450 | }).pipe(Effect.forkScoped); | |
| 1451 | yield* Deferred.await(connected); | |
| 1452 | expect( | |
| 1453 | yield* broker.invoke<void>({ scope, operation: "waitFor", input: {} }).pipe(Effect.flip), | |
| 1454 | ).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); | |
| 1434 | const received = yield* Deferred.make<void>(); | |
| 1435 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 1436 | yield* Stream.runForEach(requests, (request) => | |
| 1437 | Effect.gen(function* () { | |
| 1438 | if (request.operation === "waitFor") { | |
| 1439 | yield* Deferred.succeed(received, undefined); | |
| 1440 | // The browser starts its own timer after delivery, so its timeout lands late. | |
| 1441 | yield* Effect.sleep(request.timeoutMs + 50); | |
| 1442 | } | |
| 1443 | yield* broker.respond({ | |
| 1444 | clientId: "client-1", | |
| 1445 | connectionId: request.connectionId, | |
| 1446 | requestId: request.requestId, | |
| 1447 | ...(request.operation === "waitFor" | |
| 1448 | ? { | |
| 1449 | ok: false, | |
| 1450 | error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, | |
| 1451 | } | |
| 1452 | : { ok: true, result: "responsive" }), | |
| 1453 | }); | |
| 1454 | }), | |
| 1455 | ).pipe(Effect.forkScoped); | |
| 1456 | const timedOut = yield* broker | |
| 1457 | .invoke<void>({ scope, operation: "waitFor", input: {}, timeoutMs: 1_000 }) | |
| 1458 | .pipe(Effect.flip, Effect.forkScoped); | |
| 1459 | yield* Deferred.await(received); | |
| 1460 | yield* TestClock.adjust(1_050); | |
| 1461 | expect(yield* Fiber.join(timedOut)).toMatchObject({ | |
| 1462 | _tag: "PreviewAutomationTimeoutError", | |
| 1463 | remoteTag: "PreviewAutomationTimeoutError", | |
| 1464 | }); | |
| 1455 | 1465 | expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive"); |
| 1456 | 1466 | }), |
| 1457 | 1467 | ), |
apps/server/src/mcp/PreviewAutomationBroker.ts+7-1| ... | ... | @@ -41,6 +41,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 41 | 41 | |
| 42 | 42 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 43 | 43 | |
| 44 | const HOST_RESPONSE_GRACE_MS = 1_000; | |
| 45 | ||
| 44 | 46 | export interface PreviewAutomationInvokeInput { |
| 45 | 47 | /** Preview tabs belong to a thread, so only thread callers reach the broker. */ |
| 46 | 48 | readonly scope: McpInvocationContext.McpThreadInvocationScope; |
| ... | ... | @@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 651 | 653 | } |
| 652 | 654 | return yield* new PreviewAutomationRequestQueueClosedError(requestContext); |
| 653 | 655 | } |
| 654 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | |
| 656 | // The browser starts its operation timer after delivery. Allow its timeout | |
| 657 | // response to arrive before treating the entire host as unresponsive. | |
| 658 | const responseTimeoutMs = | |
| 659 | input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS; | |
| 660 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs)); | |
| 655 | 661 | return yield* Option.match(result, { |
| 656 | 662 | onNone: () => |
| 657 | 663 | Effect.gen(function* () { |
packages/contracts/src/previewAutomation.ts+4-4| ... | ... | @@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr |
| 925 | 925 | }, |
| 926 | 926 | ) { |
| 927 | 927 | override get message(): string { |
| 928 | 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.`; | |
| 928 | return `No preview automation host is available for ${this.operation} in environment ${this.environmentId}. The server-owned browser may be starting or reconnecting. Retry preview_status, then call preview_open if no tab is available. If it remains unavailable, report the browser connection failure.`; | |
| 929 | 929 | } |
| 930 | 930 | } |
| 931 | 931 | |
| ... | ... | @@ -1129,7 +1129,7 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedError< |
| 1129 | 1129 | }, |
| 1130 | 1130 | ) { |
| 1131 | 1131 | override get message(): string { |
| 1132 | return "Preview recording could not be saved to the agent environment. The saved copy remains on the desktop."; | |
| 1132 | return "Preview recording could not be saved to the agent environment."; | |
| 1133 | 1133 | } |
| 1134 | 1134 | } |
| 1135 | 1135 | |
| ... | ... | @@ -1147,7 +1147,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError< |
| 1147 | 1147 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, |
| 1148 | 1148 | ) { |
| 1149 | 1149 | override get message(): string { |
| 1150 | return "The recording exceeds 50 MiB. The saved copy remains on the desktop."; | |
| 1150 | return "The recording exceeds 50 MiB. Make a shorter recording."; | |
| 1151 | 1151 | } |
| 1152 | 1152 | } |
| 1153 | 1153 | |
| ... | ... | @@ -1156,7 +1156,7 @@ export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.Tagge |
| 1156 | 1156 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, |
| 1157 | 1157 | ) { |
| 1158 | 1158 | override get message(): string { |
| 1159 | return "The recording transfer deadline expired. The saved copy remains on the desktop."; | |
| 1159 | return "The recording transfer deadline expired."; | |
| 1160 | 1160 | } |
| 1161 | 1161 | } |
| 1162 | 1162 |