| 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,14 +234,14 @@ it.effect.each([ |
| 234 | ).pipe(Effect.provide(layerTest)), | 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 | Effect.gen(function* () { | 238 | Effect.gen(function* () { |
| 239 | const snapshot = yield* callSnapshot({}); | 239 | const snapshot = yield* callSnapshot({}); |
| 240 | 240 | ||
| 241 | expect(snapshot.isError).toBe(true); | 241 | expect(snapshot.isError).toBe(true); |
| 242 | const [text] = snapshot.content; | 242 | const [text] = snapshot.content; |
| 243 | expect(text?.type === "text" ? text.text : "").toContain( | 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 | expect(snapshot.structuredContent).toMatchObject({ | 246 | expect(snapshot.structuredContent).toMatchObject({ |
| 247 | error: { _tag: "PreviewAutomationNoAvailableHostError" }, | 247 | error: { _tag: "PreviewAutomationNoAvailableHostError" }, |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+34-24| ... | @@ -507,7 +507,6 @@ it.effect.each([ | ... | @@ -507,7 +507,6 @@ it.effect.each([ |
| 507 | threadId: scope.thread.threadId, | 507 | threadId: scope.thread.threadId, |
| 508 | }); | 508 | }); |
| 509 | expect(error.cause).toBe(remoteError); | 509 | expect(error.cause).toBe(remoteError); |
| 510 | expect(error.message).toContain("remains on the desktop"); | ||
| 511 | expect(error.message).not.toContain("remote recording details"); | 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,7 +1351,7 @@ it.effect("discards buffered actions before completing an evicted host stream", |
| 1352 | }) | 1351 | }) |
| 1353 | .pipe(Effect.flip, Effect.forkScoped); | 1352 | .pipe(Effect.flip, Effect.forkScoped); |
| 1354 | yield* Deferred.await(actionRouted); | 1353 | yield* Deferred.await(actionRouted); |
| 1355 | yield* TestClock.adjust(1_000); | 1354 | yield* TestClock.adjust(2_000); |
| 1356 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); | 1355 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1357 | expect(yield* Fiber.join(buffered)).toMatchObject({ | 1356 | expect(yield* Fiber.join(buffered)).toMatchObject({ |
| 1358 | _tag: "PreviewAutomationClientDisconnectedError", | 1357 | _tag: "PreviewAutomationClientDisconnectedError", |
| ... | @@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver | ... | @@ -1414,7 +1413,7 @@ it.effect("rejects a routed action when its generation is evicted before deliver |
| 1414 | Effect.forkScoped, | 1413 | Effect.forkScoped, |
| 1415 | ); | 1414 | ); |
| 1416 | yield* Deferred.await(actionRouted); | 1415 | yield* Deferred.await(actionRouted); |
| 1417 | yield* TestClock.adjust(1_000); | 1416 | yield* TestClock.adjust(2_000); |
| 1418 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); | 1417 | expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); |
| 1419 | expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); | 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,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 | Effect.scoped( | 1431 | Effect.scoped( |
| 1433 | Effect.gen(function* () { | 1432 | Effect.gen(function* () { |
| 1434 | const broker = yield* makeBroker; | 1433 | const broker = yield* makeBroker; |
| 1435 | const connected = yield* Deferred.make<void>(); | 1434 | const received = yield* Deferred.make<void>(); |
| 1436 | const events = yield* broker.connect(makeHost()); | 1435 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 1437 | yield* Stream.runForEach(events, (event) => { | 1436 | yield* Stream.runForEach(requests, (request) => |
| 1438 | if (event.type === "connected") return Deferred.succeed(connected, undefined); | 1437 | Effect.gen(function* () { |
| 1439 | return broker.respond({ | 1438 | if (request.operation === "waitFor") { |
| 1440 | clientId: "client-1", | 1439 | yield* Deferred.succeed(received, undefined); |
| 1441 | connectionId: event.connectionId, | 1440 | // The browser starts its own timer after delivery, so its timeout lands late. |
| 1442 | requestId: event.request.requestId, | 1441 | yield* Effect.sleep(request.timeoutMs + 50); |
| 1443 | ...(event.request.operation === "waitFor" | 1442 | } |
| 1444 | ? { | 1443 | yield* broker.respond({ |
| 1445 | ok: false, | 1444 | clientId: "client-1", |
| 1446 | error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, | 1445 | connectionId: request.connectionId, |
| 1447 | } | 1446 | requestId: request.requestId, |
| 1448 | : { ok: true, result: "responsive" }), | 1447 | ...(request.operation === "waitFor" |
| 1449 | }); | 1448 | ? { |
| 1450 | }).pipe(Effect.forkScoped); | 1449 | ok: false, |
| 1451 | yield* Deferred.await(connected); | 1450 | error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, |
| 1452 | expect( | 1451 | } |
| 1453 | yield* broker.invoke<void>({ scope, operation: "waitFor", input: {} }).pipe(Effect.flip), | 1452 | : { ok: true, result: "responsive" }), |
| 1454 | ).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); | 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 | expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive"); | 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,6 +41,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 41 | 41 | ||
| 42 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 42 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 43 | 43 | ||
| 44 | const HOST_RESPONSE_GRACE_MS = 1_000; | ||
| 45 | |||
| 44 | export interface PreviewAutomationInvokeInput { | 46 | export interface PreviewAutomationInvokeInput { |
| 45 | /** Preview tabs belong to a thread, so only thread callers reach the broker. */ | 47 | /** Preview tabs belong to a thread, so only thread callers reach the broker. */ |
| 46 | readonly scope: McpInvocationContext.McpThreadInvocationScope; | 48 | readonly scope: McpInvocationContext.McpThreadInvocationScope; |
| ... | @@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -651,7 +653,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 651 | } | 653 | } |
| 652 | return yield* new PreviewAutomationRequestQueueClosedError(requestContext); | 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 | return yield* Option.match(result, { | 661 | return yield* Option.match(result, { |
| 656 | onNone: () => | 662 | onNone: () => |
| 657 | Effect.gen(function* () { | 663 | Effect.gen(function* () { |
packages/contracts/src/previewAutomation.ts+4-4| ... | @@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr | ... | @@ -925,7 +925,7 @@ export class PreviewAutomationNoAvailableHostError extends Schema.TaggedError<Pr |
| 925 | }, | 925 | }, |
| 926 | ) { | 926 | ) { |
| 927 | override get message(): string { | 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,7 +1129,7 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedError< |
| 1129 | }, | 1129 | }, |
| 1130 | ) { | 1130 | ) { |
| 1131 | override get message(): string { | 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,7 +1147,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError< |
| 1147 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, | 1147 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, |
| 1148 | ) { | 1148 | ) { |
| 1149 | override get message(): string { | 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,7 +1156,7 @@ export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.Tagge |
| 1156 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, | 1156 | { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, |
| 1157 | ) { | 1157 | ) { |
| 1158 | override get message(): string { | 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 |