| author | |
| committer | |
| log | 6c69534a5800a95e1640b0571580561ef71ec461 |
| tree | 121319a9cb7a609df623601b1f4e9fb196231975 |
| parent | 02297e3dbd896ef619d5c916938c751e343e76a8 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 320 insertions(+), 78 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts+33-14| ... | ... | @@ -250,7 +250,10 @@ it.effect.each([ |
| 250 | 250 | const { accessibilityTree: _tree, ...boundedMetadata } = metadata; |
| 251 | 251 | expect(snapshot.isError).toBe(false); |
| 252 | 252 | expect(snapshot.structuredContent).toEqual(metadata); |
| 253 | const [text, ...rest] = snapshot.content; | |
| 253 | const [identity, text, ...rest] = snapshot.content; | |
| 254 | expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ | |
| 255 | url: page.url, | |
| 256 | }); | |
| 254 | 257 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); |
| 255 | 258 | expect(rest).toEqual([ |
| 256 | 259 | { |
| ... | ... | @@ -279,7 +282,12 @@ it.effect.each([ |
| 279 | 282 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 280 | 283 | Effect.provideService(McpSchema.McpServerClient, client), |
| 281 | 284 | ); |
| 282 | expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]); | |
| 285 | expect(nextDefault.content.map((content) => content.type)).toEqual([ | |
| 286 | "text", | |
| 287 | "text", | |
| 288 | "text", | |
| 289 | "image", | |
| 290 | ]); | |
| 283 | 291 | expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); |
| 284 | 292 | expect(requests).toBe(7); |
| 285 | 293 | }), |
| ... | ... | @@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => |
| 329 | 337 | /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, |
| 330 | 338 | ); |
| 331 | 339 | expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); |
| 332 | const text = snapshot.content.find((content) => content.type === "text"); | |
| 340 | const [, text] = snapshot.content; | |
| 333 | 341 | expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); |
| 334 | 342 | |
| 335 | 343 | const unsaved = yield* callSnapshot({}); |
| ... | ... | @@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => |
| 429 | 437 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 430 | 438 | |
| 431 | 439 | expect(snapshot.isError).toBe(false); |
| 432 | const [text, notice] = snapshot.content; | |
| 440 | const [identity, text, notice] = snapshot.content; | |
| 441 | expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ | |
| 442 | url: oversized.url, | |
| 443 | }); | |
| 433 | 444 | expect(text?.type).toBe("text"); |
| 434 | 445 | const body = text?.type === "text" ? text.text : ""; |
| 435 | 446 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| ... | ... | @@ -471,7 +482,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are |
| 471 | 482 | |
| 472 | 483 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 473 | 484 | |
| 474 | const [text] = snapshot.content; | |
| 485 | const [, text] = snapshot.content; | |
| 475 | 486 | const body = text?.type === "text" ? text.text : ""; |
| 476 | 487 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 477 | 488 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| ... | ... | @@ -482,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are |
| 482 | 493 | }; |
| 483 | 494 | expect(parsed.title.length).toBe(2_049); |
| 484 | 495 | expect(parsed.consoleEntries[0]?.text.length).toBe(501); |
| 485 | const notice = snapshot.content[1]; | |
| 496 | const notice = snapshot.content[2]; | |
| 486 | 497 | const noticeText = notice?.type === "text" ? notice.text : ""; |
| 487 | 498 | expect(noticeText).toContain("url or title after 2048 characters"); |
| 488 | 499 | expect(noticeText).toContain("console entries text after 500 characters"); |
| ... | ... | @@ -533,7 +544,7 @@ it.effect("sheds log entries before locators when every list is full", () => |
| 533 | 544 | |
| 534 | 545 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 535 | 546 | |
| 536 | const [text, notice] = snapshot.content; | |
| 547 | const [, text, notice] = snapshot.content; | |
| 537 | 548 | const body = text?.type === "text" ? text.text : ""; |
| 538 | 549 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 539 | 550 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| ... | ... | @@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 615 | 626 | Effect.gen(function* () { |
| 616 | 627 | const server = yield* McpServer.McpServer; |
| 617 | 628 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 629 | const toolIcon = { | |
| 630 | _tag: "website" as const, | |
| 631 | pageUrl: "http://example.test/", | |
| 632 | }; | |
| 618 | 633 | const routedRequests: Array<{ |
| 619 | 634 | readonly operation: string; |
| 620 | 635 | readonly tabId?: string | undefined; |
| ... | ... | @@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 664 | 679 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); |
| 665 | 680 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); |
| 666 | 681 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); |
| 667 | expect(clickTool?.tool.outputSchema).toEqual({ | |
| 682 | expect(clickTool?.tool.outputSchema).toMatchObject({ | |
| 668 | 683 | type: "object", |
| 669 | 684 | additionalProperties: false, |
| 670 | 685 | description: "The preview action completed successfully.", |
| ... | ... | @@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 721 | 736 | Effect.provideService(McpSchema.McpServerClient, client), |
| 722 | 737 | ); |
| 723 | 738 | expect(evaluated.isError).toBe(false); |
| 724 | expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] }); | |
| 725 | expect(evaluated.content).toEqual([ | |
| 726 | { type: "text", text: '{"value":["Connect","Continue"]}' }, | |
| 727 | ]); | |
| 739 | expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); | |
| 740 | const evaluatedText = evaluated.content[0]; | |
| 741 | expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ | |
| 742 | toolIcon, | |
| 743 | value: ["Connect", "Continue"], | |
| 744 | }); | |
| 728 | 745 | |
| 729 | 746 | const actionRequests = [ |
| 730 | 747 | { name: "preview_click", arguments: { x: 10, y: 10 } }, |
| ... | ... | @@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 741 | 758 | Effect.provideService(McpSchema.McpServerClient, client), |
| 742 | 759 | ); |
| 743 | 760 | expect(result.isError).toBe(false); |
| 744 | expect(result.structuredContent).toEqual({}); | |
| 745 | expect(result.content).toEqual([{ type: "text", text: "{}" }]); | |
| 761 | expect(result.structuredContent).toEqual({ toolIcon }); | |
| 762 | expect(routedRequests.at(-1)?.operation).toBe("status"); | |
| 763 | const text = result.content[0]; | |
| 764 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon }); | |
| 746 | 765 | } |
| 747 | 766 | }), |
| 748 | 767 | ).pipe(Effect.provide(TestLayer)), |
apps/server/src/mcp/McpHttpServer.ts+7| ... | ... | @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot |
| 410 | 410 | isError: false, |
| 411 | 411 | structuredContent: metadata, |
| 412 | 412 | content: [ |
| 413 | // Keep the page identity readable even if a provider truncates the snapshot. | |
| 414 | { | |
| 415 | type: "text", | |
| 416 | text: encodeJsonText({ | |
| 417 | url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), | |
| 418 | }), | |
| 419 | }, | |
| 413 | 420 | { type: "text", text: bounded.text }, |
| 414 | 421 | ...(bounded.omitted.length === 0 |
| 415 | 422 | ? [] |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+61-41| ... | ... | @@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () = |
| 127 | 127 | ), |
| 128 | 128 | ); |
| 129 | 129 | |
| 130 | it.effect("does not let an older response replace a newer explicit tab target", () => | |
| 131 | Effect.scoped( | |
| 132 | Effect.gen(function* () { | |
| 133 | const broker = yield* makeBroker; | |
| 134 | const olderTabId = PreviewTabId.make("tab-older-request"); | |
| 135 | const newerTabId = PreviewTabId.make("tab-newer-request"); | |
| 136 | const releaseOlderResponse = yield* Deferred.make<void>(); | |
| 137 | const routedRequests: RoutedRequest[] = []; | |
| 138 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 139 | yield* Stream.runForEach(requests, (request) => { | |
| 140 | routedRequests.push(request); | |
| 141 | const response = Effect.gen(function* () { | |
| 142 | if (request.tabId === olderTabId) { | |
| 143 | yield* Deferred.await(releaseOlderResponse); | |
| 144 | } | |
| 145 | yield* broker.respond({ | |
| 146 | clientId: "client-1", | |
| 147 | connectionId: request.connectionId, | |
| 148 | requestId: request.requestId, | |
| 149 | ok: true, | |
| 150 | result: { url: "http://localhost:3200" }, | |
| 130 | it.effect.each([true, false])( | |
| 131 | "keeps an older target stable while a newer explicit tab responds (implicit: %s)", | |
| 132 | (implicit) => | |
| 133 | Effect.scoped( | |
| 134 | Effect.gen(function* () { | |
| 135 | const broker = yield* makeBroker; | |
| 136 | const olderTabId = PreviewTabId.make("tab-older-request"); | |
| 137 | const newerTabId = PreviewTabId.make("tab-newer-request"); | |
| 138 | const releaseOlderResponse = yield* Deferred.make<void>(); | |
| 139 | const routedRequests: RoutedRequest[] = []; | |
| 140 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 141 | yield* Stream.runForEach(requests, (request) => { | |
| 142 | routedRequests.push(request); | |
| 143 | const response = Effect.gen(function* () { | |
| 144 | if (request.tabId === olderTabId && request.operation === "snapshot") { | |
| 145 | yield* Deferred.await(releaseOlderResponse); | |
| 146 | } | |
| 147 | yield* broker.respond({ | |
| 148 | clientId: "client-1", | |
| 149 | connectionId: request.connectionId, | |
| 150 | requestId: request.requestId, | |
| 151 | ok: true, | |
| 152 | result: { url: "http://localhost:3200" }, | |
| 153 | }); | |
| 154 | if (request.tabId === newerTabId) { | |
| 155 | yield* Deferred.succeed(releaseOlderResponse, undefined); | |
| 156 | } | |
| 151 | 157 | }); |
| 152 | if (request.tabId === newerTabId) { | |
| 153 | yield* Deferred.succeed(releaseOlderResponse, undefined); | |
| 154 | } | |
| 158 | return response.pipe(Effect.forkScoped, Effect.asVoid); | |
| 159 | }).pipe(Effect.forkScoped); | |
| 160 | yield* Effect.yieldNow; | |
| 161 | ||
| 162 | yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); | |
| 163 | let capturedTabId: PreviewTabId | undefined; | |
| 164 | const older = yield* broker | |
| 165 | .invoke({ | |
| 166 | scope, | |
| 167 | operation: "snapshot", | |
| 168 | input: {}, | |
| 169 | ...(implicit ? {} : { tabId: olderTabId }), | |
| 170 | onTargetTab: (tabId) => { | |
| 171 | capturedTabId = tabId; | |
| 172 | }, | |
| 173 | }) | |
| 174 | .pipe(Effect.forkScoped); | |
| 175 | yield* Effect.yieldNow; | |
| 176 | const newer = yield* broker | |
| 177 | .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) | |
| 178 | .pipe(Effect.forkScoped); | |
| 179 | yield* Fiber.join(newer); | |
| 180 | yield* Fiber.join(older); | |
| 181 | yield* broker.invoke({ | |
| 182 | scope, | |
| 183 | operation: "status", | |
| 184 | input: {}, | |
| 185 | tabId: olderTabId, | |
| 186 | updateCurrentTab: false, | |
| 155 | 187 | }); |
| 156 | return response.pipe(Effect.forkScoped, Effect.asVoid); | |
| 157 | }).pipe(Effect.forkScoped); | |
| 158 | yield* Effect.yieldNow; | |
| 159 | ||
| 160 | const older = yield* broker | |
| 161 | .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) | |
| 162 | .pipe(Effect.forkScoped); | |
| 163 | yield* Effect.yieldNow; | |
| 164 | const newer = yield* broker | |
| 165 | .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) | |
| 166 | .pipe(Effect.forkScoped); | |
| 167 | yield* Fiber.join(newer); | |
| 168 | yield* Fiber.join(older); | |
| 169 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 188 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | |
| 170 | 189 | |
| 171 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); | |
| 172 | }), | |
| 173 | ), | |
| 190 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); | |
| 191 | expect(capturedTabId).toBe(olderTabId); | |
| 192 | }), | |
| 193 | ), | |
| 174 | 194 | ); |
| 175 | 195 | |
| 176 | 196 | it.effect("tracks the tab returned by a targeted recording stop", () => |
apps/server/src/mcp/PreviewAutomationBroker.ts+6| ... | ... | @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { |
| 44 | 44 | readonly input: unknown; |
| 45 | 45 | readonly tabId?: PreviewTabId; |
| 46 | 46 | readonly timeoutMs?: number; |
| 47 | /** Background metadata reads must not change the agent's current tab. */ | |
| 48 | readonly updateCurrentTab?: boolean; | |
| 49 | /** Capture the routed tab before another request changes the current assignment. */ | |
| 50 | readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | export class PreviewAutomationBroker extends Context.Service< |
| ... | ... | @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 541 | 545 | }); |
| 542 | 546 | } |
| 543 | 547 | const { connection, requestId, requestContext, requestSequence } = route; |
| 548 | input.onTargetTab?.(requestContext.tabId); | |
| 544 | 549 | const removePending = SynchronizedRef.update(state, (next) => { |
| 545 | 550 | if (!next.pending.has(requestId)) return next; |
| 546 | 551 | const pending = new Map(next.pending); |
| ... | ... | @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 575 | 580 | }); |
| 576 | 581 | }); |
| 577 | 582 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); |
| 583 | if (input.updateCurrentTab === false) return result; | |
| 578 | 584 | const responseTabId = readResultTabId(result); |
| 579 | 585 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; |
| 580 | 586 | if (resultTabId === undefined) return result; |
apps/server/src/mcp/toolkits/preview/handlers.ts+52-17| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | PreviewAutomationRecordingTransferError, |
| 8 | 8 | PreviewAutomationRecordingDesktopUpdateRequiredError, |
| 9 | 9 | PreviewAutomationRecordingArtifact, |
| 10 | type ToolActivityIcon, | |
| 10 | 11 | type ThreadId, |
| 11 | 12 | type PreviewAutomationOperation, |
| 12 | 13 | type PreviewAutomationOpenInput, |
| ... | ... | @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 55 | 56 | timeoutMs?: number, |
| 56 | 57 | tabId?: PreviewTabId, |
| 57 | 58 | ): Effect.fn.Return< |
| 58 | A, | |
| 59 | { result: A; toolIcon?: ToolActivityIcon }, | |
| 59 | 60 | import("@t3tools/contracts").PreviewAutomationError, |
| 60 | 61 | McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker |
| 61 | 62 | > { |
| 62 | 63 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); |
| 63 | 64 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 64 | return yield* broker.invoke<A>({ | |
| 65 | let targetTabId = tabId; | |
| 66 | const result = yield* broker.invoke<A>({ | |
| 67 | onTargetTab: (resolvedTabId) => { | |
| 68 | targetTabId = resolvedTabId; | |
| 69 | }, | |
| 65 | 70 | scope, |
| 66 | 71 | operation, |
| 67 | 72 | input, |
| 68 | 73 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 69 | 74 | ...(tabId === undefined ? {} : { tabId }), |
| 70 | 75 | }); |
| 76 | if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result }; | |
| 77 | const statusTabId = | |
| 78 | (operation !== "evaluate" && typeof result === "object" && result !== null | |
| 79 | ? (result as { tabId?: PreviewTabId }).tabId | |
| 80 | : undefined) ?? targetTabId; | |
| 81 | const page = yield* broker | |
| 82 | .invoke<PreviewAutomationStatus>({ | |
| 83 | scope, | |
| 84 | operation: "status", | |
| 85 | input: {}, | |
| 86 | timeoutMs: 500, | |
| 87 | updateCurrentTab: false, | |
| 88 | ...(statusTabId === undefined ? {} : { tabId: statusTabId }), | |
| 89 | }) | |
| 90 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 91 | return { | |
| 92 | result, | |
| 93 | ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096 | |
| 94 | ? { toolIcon: { _tag: "website" as const, pageUrl: page.url } } | |
| 95 | : {}), | |
| 96 | }; | |
| 71 | 97 | }); |
| 72 | 98 | |
| 73 | const invokeTargeted = <A>( | |
| 99 | const invokeTargeted = <A extends object>( | |
| 74 | 100 | operation: PreviewAutomationOperation, |
| 75 | 101 | input: { |
| 76 | 102 | readonly tabId?: PreviewTabId | undefined; |
| ... | ... | @@ -79,7 +105,12 @@ const invokeTargeted = <A>( |
| 79 | 105 | timeoutMs?: number, |
| 80 | 106 | ) => { |
| 81 | 107 | const { tabId, ...operationInput } = input; |
| 82 | return invoke<A>(operation, operationInput, timeoutMs, tabId); | |
| 108 | return invoke<A>(operation, operationInput, timeoutMs, tabId).pipe( | |
| 109 | Effect.map(({ result, toolIcon }) => ({ | |
| 110 | ...result, | |
| 111 | ...(toolIcon ? { toolIcon } : {}), | |
| 112 | })), | |
| 113 | ); | |
| 83 | 114 | }; |
| 84 | 115 | |
| 85 | 116 | const UploadedRecordingArtifact = Schema.Struct({ |
| ... | ... | @@ -170,28 +201,32 @@ const handlers = { |
| 170 | 201 | const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; |
| 171 | 202 | return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput); |
| 172 | 203 | }, |
| 173 | preview_click: (input) => | |
| 174 | invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as({})), | |
| 175 | preview_type: (input) => invokeTargeted<void>("type", input, input.timeoutMs).pipe(Effect.as({})), | |
| 176 | preview_press: (input) => invokeTargeted<void>("press", input).pipe(Effect.as({})), | |
| 177 | preview_scroll: (input) => invokeTargeted<void>("scroll", input).pipe(Effect.as({})), | |
| 178 | preview_evaluate: (input) => | |
| 179 | invokeTargeted<unknown>("evaluate", input).pipe( | |
| 180 | Effect.map((result) => ({ value: result ?? null })), | |
| 204 | preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs), | |
| 205 | preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs), | |
| 206 | preview_press: (input) => invokeTargeted<object>("press", input), | |
| 207 | preview_scroll: (input) => invokeTargeted<object>("scroll", input), | |
| 208 | preview_evaluate: ({ tabId, ...input }) => | |
| 209 | invoke<unknown>("evaluate", input, undefined, tabId).pipe( | |
| 210 | Effect.map(({ result, toolIcon }) => ({ | |
| 211 | value: result ?? null, | |
| 212 | ...(toolIcon ? { toolIcon } : {}), | |
| 213 | })), | |
| 181 | 214 | ), |
| 182 | preview_wait_for: (input) => | |
| 183 | invokeTargeted<void>("waitFor", input, input.timeoutMs).pipe(Effect.as({})), | |
| 215 | preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs), | |
| 184 | 216 | preview_recording_start: (input) => |
| 185 | 217 | invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}), |
| 186 | 218 | preview_recording_stop: (input) => |
| 187 | 219 | Effect.gen(function* () { |
| 188 | 220 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); |
| 189 | const response = yield* invokeTargeted<unknown>( | |
| 221 | const { tabId, ...operationInput } = input; | |
| 222 | const response = yield* invoke<unknown>( | |
| 190 | 223 | "recordingStop", |
| 191 | { ...input, transferToEnvironment: true }, | |
| 224 | { ...operationInput, transferToEnvironment: true }, | |
| 192 | 225 | PREVIEW_RECORDING_STOP_TIMEOUT_MS, |
| 226 | tabId, | |
| 193 | 227 | ); |
| 194 | return yield* claimPreviewRecording(scope.threadId, response); | |
| 228 | const artifact = yield* claimPreviewRecording(scope.threadId, response.result); | |
| 229 | return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; | |
| 195 | 230 | }), |
| 196 | 231 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; |
| 197 | 232 |
apps/server/src/mcp/toolkits/preview/tools.test.ts+1| ... | ... | @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { |
| 67 | 67 | for (const name of actionNames) { |
| 68 | 68 | expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ |
| 69 | 69 | type: "object", |
| 70 | properties: { toolIcon: expect.any(Object) }, | |
| 70 | 71 | additionalProperties: false, |
| 71 | 72 | description: "The preview action completed successfully.", |
| 72 | 73 | }); |
apps/server/src/mcp/toolkits/preview/tools.ts+12-5| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | ToolActivityIcon, | |
| 2 | 3 | PreviewAutomationClickInput, |
| 3 | 4 | PreviewAutomationError, |
| 4 | 5 | PreviewAutomationEvaluateInput, |
| ... | ... | @@ -31,7 +32,9 @@ const dependencies = [ |
| 31 | 32 | PreviewAutomationBroker.PreviewAutomationBroker, |
| 32 | 33 | ]; |
| 33 | 34 | |
| 34 | const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate({ | |
| 35 | const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; | |
| 36 | ||
| 37 | const PreviewActionResult = Schema.Struct(presentationFields).annotate({ | |
| 35 | 38 | description: "The preview action completed successfully.", |
| 36 | 39 | }); |
| 37 | 40 | |
| ... | ... | @@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool( |
| 89 | 92 | description: |
| 90 | 93 | "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", |
| 91 | 94 | parameters: PreviewAutomationResizeInput, |
| 92 | success: PreviewAutomationResizeResult, | |
| 95 | success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), | |
| 93 | 96 | failure: PreviewAutomationError, |
| 94 | 97 | dependencies, |
| 95 | 98 | }) |
| ... | ... | @@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool( |
| 102 | 105 | description: |
| 103 | 106 | "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", |
| 104 | 107 | parameters: PreviewAutomationSetColorSchemeInput, |
| 105 | success: PreviewAutomationSetColorSchemeResult, | |
| 108 | success: Schema.Struct({ | |
| 109 | ...PreviewAutomationSetColorSchemeResult.fields, | |
| 110 | ...presentationFields, | |
| 111 | }), | |
| 106 | 112 | failure: PreviewAutomationError, |
| 107 | 113 | dependencies, |
| 108 | 114 | }) |
| ... | ... | @@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool( |
| 185 | 191 | * null valid instead of failing only for non-object expressions. |
| 186 | 192 | */ |
| 187 | 193 | export const PreviewEvaluateResult = Schema.Struct({ |
| 194 | ...presentationFields, | |
| 188 | 195 | value: Schema.Unknown.annotate({ |
| 189 | 196 | description: "The JSON-serializable value the expression produced, or null.", |
| 190 | 197 | }), |
| ... | ... | @@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool( |
| 217 | 224 | description: |
| 218 | 225 | "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", |
| 219 | 226 | parameters: PreviewAutomationTabTargetInput, |
| 220 | success: PreviewAutomationRecordingStatus, | |
| 227 | success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), | |
| 221 | 228 | failure: PreviewAutomationError, |
| 222 | 229 | dependencies, |
| 223 | 230 | }).annotate(Tool.Title, "Start browser recording"), |
| ... | ... | @@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool( |
| 228 | 235 | description: |
| 229 | 236 | "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", |
| 230 | 237 | parameters: PreviewAutomationTabTargetInput, |
| 231 | success: PreviewAutomationRecordingArtifact, | |
| 238 | success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), | |
| 232 | 239 | failure: PreviewAutomationError, |
| 233 | 240 | dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], |
| 234 | 241 | }).annotate(Tool.Title, "Stop browser recording"), |
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+78| ... | ... | @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { |
| 249 | 249 | expect(JSON.stringify(projected.payload).length).toBeLessThan(500); |
| 250 | 250 | }); |
| 251 | 251 | |
| 252 | it.each([ | |
| 253 | { | |
| 254 | item: { | |
| 255 | server: "t3-code", | |
| 256 | tool: "preview_open", | |
| 257 | result: { structuredContent: { url: "https://example.com/" } }, | |
| 258 | }, | |
| 259 | }, | |
| 260 | { | |
| 261 | toolName: "mcp__t3-code__preview_navigate", | |
| 262 | result: { content: '{"url":"https://example.com/"}' }, | |
| 263 | }, | |
| 264 | { tool: "t3-code_preview_status", state: { output: '{"url":"https://example.com/"}' } }, | |
| 265 | { | |
| 266 | toolName: "mcp__t3_code__preview_snapshot", | |
| 267 | result: { | |
| 268 | content: [ | |
| 269 | { type: "text", text: '{"url":"https://example.com/"}' }, | |
| 270 | { type: "text", text: "Snapshot text was bounded. Omitted: accessibilityTree." }, | |
| 271 | ], | |
| 272 | }, | |
| 273 | }, | |
| 274 | { | |
| 275 | toolName: "mcp__t3-code__preview_click", | |
| 276 | result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, | |
| 277 | }, | |
| 278 | { | |
| 279 | toolName: "mcp__t3_code__preview_snapshot", | |
| 280 | result: { content: '{"url":"https://example.com/"}\n{"accessibilityTree":"truncated' }, | |
| 281 | }, | |
| 282 | ...[false, true].map((truncated) => ({ | |
| 283 | toolName: "mcp__t3_code__preview_snapshot", | |
| 284 | result: { | |
| 285 | content: JSON.stringify({ | |
| 286 | content: [{ type: "text", text: '{"url":"https://example.com/"}' }], | |
| 287 | structuredContent: { url: "https://example.com/", visibleText: "page" }, | |
| 288 | }).slice(0, truncated ? -5 : undefined), | |
| 289 | }, | |
| 290 | })), | |
| 291 | ...[ | |
| 292 | "type", | |
| 293 | "press", | |
| 294 | "scroll", | |
| 295 | "resize", | |
| 296 | "set_appearance", | |
| 297 | "evaluate", | |
| 298 | "wait_for", | |
| 299 | "recording_start", | |
| 300 | "recording_stop", | |
| 301 | ].map((action) => ({ | |
| 302 | toolName: `mcp__t3_code__preview_${action}`, | |
| 303 | result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, | |
| 304 | })), | |
| 305 | ])("preserves the preview page favicon through result slimming", (data) => { | |
| 306 | const projected = projectActivityPayload(activity({ itemType: "mcp_tool_call", data })); | |
| 307 | const icon = { _tag: "website", pageUrl: "https://example.com/" }; | |
| 308 | expect(projected.payload).toMatchObject({ toolIcon: icon }); | |
| 309 | expect(projectActivityPayload(projected).payload).toMatchObject({ toolIcon: icon }); | |
| 310 | }); | |
| 311 | ||
| 312 | it.each([ | |
| 313 | { toolName: "mcp__other__preview_open", result: { content: '{"url":"https://example.com/"}' } }, | |
| 314 | { | |
| 315 | toolName: "mcp__t3-code__preview_evaluate", | |
| 316 | result: { content: '{"url":"https://example.com/"}' }, | |
| 317 | }, | |
| 318 | { | |
| 319 | toolName: "mcp__t3-code__preview_open", | |
| 320 | result: { isError: true, content: '{"url":"https://example.com/"}' }, | |
| 321 | }, | |
| 322 | { toolName: "mcp__t3-code__preview_open", result: { content: "malformed JSON" } }, | |
| 323 | { toolName: "mcp__t3-code__preview_open", result: { content: '{"url":"about:blank"}' } }, | |
| 324 | ])("keeps the fallback for unrelated tools, failed navigation, and missing page URLs", (data) => { | |
| 325 | expect( | |
| 326 | projectActivityPayload(activity({ itemType: "mcp_tool_call", data })).payload, | |
| 327 | ).not.toHaveProperty("toolIcon"); | |
| 328 | }); | |
| 329 | ||
| 252 | 330 | it("passes task lifecycle payloads (no data field) through untouched", () => { |
| 253 | 331 | const source = activity({ |
| 254 | 332 | taskId: "task-9", |
apps/server/src/orchestration/ActivityPayloadProjection.ts+70-1| ... | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | 5 | OrchestrationThreadDetailSnapshot, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 8 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | |
| 8 | 9 | |
| 9 | 10 | function asRecord(value: unknown): Record<string, unknown> | null { |
| 10 | 11 | return value !== null && typeof value === "object" && !Array.isArray(value) |
| ... | ... | @@ -241,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record<string, unknown> | undefine |
| 241 | 242 | return summary ? { content: summary } : undefined; |
| 242 | 243 | } |
| 243 | 244 | |
| 245 | /** Reuse the page URL already returned by preview tools before slimming their output. */ | |
| 246 | function projectPreviewToolMetadata(data: Record<string, unknown>, status: unknown) { | |
| 247 | const item = asRecord(data.item); | |
| 248 | const name = item ? `mcp__${item.server}__${item.tool}` : (data.toolName ?? data.tool); | |
| 249 | if ( | |
| 250 | typeof name !== "string" || | |
| 251 | !/^(?:mcp__)?(?:t3-code|t3_code|t3code)_{1,2}preview_(?:open|navigate|status|snapshot|click|type|press|scroll|resize|set_appearance|evaluate|wait_for|recording_start|recording_stop)$/.test( | |
| 252 | name, | |
| 253 | ) | |
| 254 | ) | |
| 255 | return {}; | |
| 256 | const state = asRecord(data.state); | |
| 257 | const result = item?.result ?? data.result ?? state?.output; | |
| 258 | const record = asRecord(result); | |
| 259 | if ( | |
| 260 | status === "failed" || | |
| 261 | status === "declined" || | |
| 262 | state?.status === "error" || | |
| 263 | item?.error != null || | |
| 264 | record?.isError === true || | |
| 265 | record?.is_error === true | |
| 266 | ) | |
| 267 | return {}; | |
| 268 | ||
| 269 | let page = record; | |
| 270 | let output: unknown = result; | |
| 271 | for (let depth = 0; depth < 3; depth += 1) { | |
| 272 | if (page?.isError === true || page?.is_error === true) return {}; | |
| 273 | const structured = asRecord(page?.structuredContent); | |
| 274 | if (structured) { | |
| 275 | page = structured; | |
| 276 | break; | |
| 277 | } | |
| 278 | const text = extractMcpResultText(output)?.slice(0, 2 * 1024 * 1024); | |
| 279 | if (!text) break; | |
| 280 | try { | |
| 281 | page = asRecord(JSON.parse(extractJsonObject(text))); | |
| 282 | } catch { | |
| 283 | // A truncated MCP envelope can still contain a complete first text block. | |
| 284 | const firstBlock = /^\s*\{\s*"content"\s*:\s*\[\s*/.exec(text); | |
| 285 | if (!firstBlock) return {}; | |
| 286 | try { | |
| 287 | const block = asRecord(JSON.parse(extractJsonObject(text.slice(firstBlock[0].length)))); | |
| 288 | page = block?.type === "text" ? { content: [block] } : null; | |
| 289 | } catch { | |
| 290 | return {}; | |
| 291 | } | |
| 292 | } | |
| 293 | output = page; | |
| 294 | } | |
| 295 | const rawUrl = asTrimmedString( | |
| 296 | asRecord(page?.toolIcon)?.pageUrl ?? | |
| 297 | (/preview_(?:open|navigate|status|snapshot)$/.test(name) ? page?.url : undefined), | |
| 298 | ); | |
| 299 | if (!rawUrl || rawUrl.length > 4096) return {}; | |
| 300 | try { | |
| 301 | const url = new URL(rawUrl); | |
| 302 | if (url.protocol !== "http:" && url.protocol !== "https:") return {}; | |
| 303 | return { toolIcon: { _tag: "website", pageUrl: url.href } }; | |
| 304 | } catch { | |
| 305 | return {}; | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 244 | 309 | /** |
| 245 | 310 | * MCP tool calls carry full tool results (`data.item.result` on Codex, |
| 246 | 311 | * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to |
| ... | ... | @@ -367,10 +432,14 @@ export function projectActivityPayload( |
| 367 | 432 | } |
| 368 | 433 | |
| 369 | 434 | const itemStatus = asRecord(data.item)?.status; |
| 370 | const projectedPayload = | |
| 435 | const statusPayload = | |
| 371 | 436 | payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") |
| 372 | 437 | ? { ...payload, status: itemStatus } |
| 373 | 438 | : payload; |
| 439 | const projectedPayload = { | |
| 440 | ...projectPreviewToolMetadata(data, statusPayload.status), | |
| 441 | ...statusPayload, | |
| 442 | }; | |
| 374 | 443 | const questionInput = projectQuestionToolInput(data, payload.title); |
| 375 | 444 | |
| 376 | 445 | if (payload.itemType === "mcp_tool_call") { |