Unified · Split
| @@ -250,7 +250,10 @@ it.effect.each([ · 249 unmodified lines | |||
| 250 | const { accessibilityTree: _tree, ...boundedMetadata } = metadata; | 250 | const { accessibilityTree: _tree, ...boundedMetadata } = metadata; |
| 251 | expect(snapshot.isError).toBe(false); | 251 | expect(snapshot.isError).toBe(false); |
| 252 | expect(snapshot.structuredContent).toEqual(metadata); | 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 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); | 257 | expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); |
| 255 | expect(rest).toEqual([ | 258 | expect(rest).toEqual([ |
| 256 | { | 259 | { |
| @@ -279,7 +282,12 @@ it.effect.each([ · 22 unmodified lines | |||
| 279 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | 282 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 280 | Effect.provideService(McpSchema.McpServerClient, client), | 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 | expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); | 291 | expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); |
| 284 | expect(requests).toBe(7); | 292 | expect(requests).toBe(7); |
| 285 | }), | 293 | }), |
| @@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => · 43 unmodified lines | |||
| 329 | /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, | 337 | /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, |
| 330 | ); | 338 | ); |
| 331 | expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); | 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 | expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); | 341 | expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); |
| 334 | 342 | ||
| 335 | const unsaved = yield* callSnapshot({}); | 343 | const unsaved = yield* callSnapshot({}); |
| @@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => · 93 unmodified lines | |||
| 429 | const snapshot = yield* callSnapshot({ includeImage: false }); | 437 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 430 | 438 | ||
| 431 | expect(snapshot.isError).toBe(false); | 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 | expect(text?.type).toBe("text"); | 444 | expect(text?.type).toBe("text"); |
| 434 | const body = text?.type === "text" ? text.text : ""; | 445 | const body = text?.type === "text" ? text.text : ""; |
| 435 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( | 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 · 35 unmodified lines | |||
| 471 | 482 | ||
| 472 | const snapshot = yield* callSnapshot({ includeImage: false }); | 483 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 473 | 484 | ||
| 474 | const [text] = snapshot.content; | 485 | const [, text] = snapshot.content; |
| 475 | const body = text?.type === "text" ? text.text : ""; | 486 | const body = text?.type === "text" ? text.text : ""; |
| 476 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( | 487 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 477 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, | 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 · 4 unmodified lines | |||
| 482 | }; | 493 | }; |
| 483 | expect(parsed.title.length).toBe(2_049); | 494 | expect(parsed.title.length).toBe(2_049); |
| 484 | expect(parsed.consoleEntries[0]?.text.length).toBe(501); | 495 | expect(parsed.consoleEntries[0]?.text.length).toBe(501); |
| 485 | const notice = snapshot.content[1]; | 496 | const notice = snapshot.content[2]; |
| 486 | const noticeText = notice?.type === "text" ? notice.text : ""; | 497 | const noticeText = notice?.type === "text" ? notice.text : ""; |
| 487 | expect(noticeText).toContain("url or title after 2048 characters"); | 498 | expect(noticeText).toContain("url or title after 2048 characters"); |
| 488 | expect(noticeText).toContain("console entries text after 500 characters"); | 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", () => · 44 unmodified lines | |||
| 533 | 544 | ||
| 534 | const snapshot = yield* callSnapshot({ includeImage: false }); | 545 | const snapshot = yield* callSnapshot({ includeImage: false }); |
| 535 | 546 | ||
| 536 | const [text, notice] = snapshot.content; | 547 | const [, text, notice] = snapshot.content; |
| 537 | const body = text?.type === "text" ? text.text : ""; | 548 | const body = text?.type === "text" ? text.text : ""; |
| 538 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( | 549 | expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( |
| 539 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, | 550 | McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, |
| @@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context · 75 unmodified lines | |||
| 615 | Effect.gen(function* () { | 626 | Effect.gen(function* () { |
| 616 | const server = yield* McpServer.McpServer; | 627 | const server = yield* McpServer.McpServer; |
| 617 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 628 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 629 | const toolIcon = { | ||
| 630 | _tag: "website" as const, | ||
| 631 | pageUrl: "http://example.test/", | ||
| 632 | }; | ||
| 618 | const routedRequests: Array<{ | 633 | const routedRequests: Array<{ |
| 619 | readonly operation: string; | 634 | readonly operation: string; |
| 620 | readonly tabId?: string | undefined; | 635 | readonly tabId?: string | undefined; |
| @@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context · 43 unmodified lines | |||
| 664 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); | 679 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); |
| 665 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); | 680 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); |
| 666 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); | 681 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); |
| 667 | expect(clickTool?.tool.outputSchema).toEqual({ | 682 | expect(clickTool?.tool.outputSchema).toMatchObject({ |
| 668 | type: "object", | 683 | type: "object", |
| 669 | additionalProperties: false, | 684 | additionalProperties: false, |
| 670 | description: "The preview action completed successfully.", | 685 | description: "The preview action completed successfully.", |
| @@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context · 50 unmodified lines | |||
| 721 | Effect.provideService(McpSchema.McpServerClient, client), | 736 | Effect.provideService(McpSchema.McpServerClient, client), |
| 722 | ); | 737 | ); |
| 723 | expect(evaluated.isError).toBe(false); | 738 | expect(evaluated.isError).toBe(false); |
| 724 | expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] }); | 739 | expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); |
| 725 | expect(evaluated.content).toEqual([ | 740 | const evaluatedText = evaluated.content[0]; |
| 726 | { type: "text", text: '{"value":["Connect","Continue"]}' }, | 741 | expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ |
| 727 | ]); | 742 | toolIcon, |
| 743 | value: ["Connect", "Continue"], | ||
| 744 | }); | ||
| 728 | 745 | ||
| 729 | const actionRequests = [ | 746 | const actionRequests = [ |
| 730 | { name: "preview_click", arguments: { x: 10, y: 10 } }, | 747 | { name: "preview_click", arguments: { x: 10, y: 10 } }, |
| @@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context · 10 unmodified lines | |||
| 741 | Effect.provideService(McpSchema.McpServerClient, client), | 758 | Effect.provideService(McpSchema.McpServerClient, client), |
| 742 | ); | 759 | ); |
| 743 | expect(result.isError).toBe(false); | 760 | expect(result.isError).toBe(false); |
| 744 | expect(result.structuredContent).toEqual({}); | 761 | expect(result.structuredContent).toEqual({ toolIcon }); |
| 745 | expect(result.content).toEqual([{ type: "text", text: "{}" }]); | 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 | ).pipe(Effect.provide(TestLayer)), | 767 | ).pipe(Effect.provide(TestLayer)), |
| @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot · 409 unmodified lines | |||
| 410 | isError: false, | 410 | isError: false, |
| 411 | structuredContent: metadata, | 411 | structuredContent: metadata, |
| 412 | content: [ | 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 | { type: "text", text: bounded.text }, | 420 | { type: "text", text: bounded.text }, |
| 414 | ...(bounded.omitted.length === 0 | 421 | ...(bounded.omitted.length === 0 |
| 415 | ? [] | 422 | ? [] |
| @@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () = · 126 unmodified lines | |||
| 127 | ), | 127 | ), |
| 128 | ); | 128 | ); |
| 129 | 129 | ||
| 130 | it.effect("does not let an older response replace a newer explicit tab target", () => | 130 | it.effect.each([true, false])( |
| 131 | Effect.scoped( | 131 | "keeps an older target stable while a newer explicit tab responds (implicit: %s)", |
| 132 | Effect.gen(function* () { | 132 | (implicit) => |
| 133 | const broker = yield* makeBroker; | 133 | Effect.scoped( |
| 134 | const olderTabId = PreviewTabId.make("tab-older-request"); | 134 | Effect.gen(function* () { |
| 135 | const newerTabId = PreviewTabId.make("tab-newer-request"); | 135 | const broker = yield* makeBroker; |
| 136 | const releaseOlderResponse = yield* Deferred.make<void>(); | 136 | const olderTabId = PreviewTabId.make("tab-older-request"); |
| 137 | const routedRequests: RoutedRequest[] = []; | 137 | const newerTabId = PreviewTabId.make("tab-newer-request"); |
| 138 | const requests = requestsFrom(yield* broker.connect(makeHost())); | 138 | const releaseOlderResponse = yield* Deferred.make<void>(); |
| 139 | yield* Stream.runForEach(requests, (request) => { | 139 | const routedRequests: RoutedRequest[] = []; |
| 140 | routedRequests.push(request); | 140 | const requests = requestsFrom(yield* broker.connect(makeHost())); |
| 141 | const response = Effect.gen(function* () { | 141 | yield* Stream.runForEach(requests, (request) => { |
| 142 | if (request.tabId === olderTabId) { | 142 | routedRequests.push(request); |
| 143 | yield* Deferred.await(releaseOlderResponse); | 143 | const response = Effect.gen(function* () { |
| 144 | } | 144 | if (request.tabId === olderTabId && request.operation === "snapshot") { |
| 145 | yield* broker.respond({ | 145 | yield* Deferred.await(releaseOlderResponse); |
| 146 | clientId: "client-1", | 146 | } |
| 147 | connectionId: request.connectionId, | 147 | yield* broker.respond({ |
| 148 | requestId: request.requestId, | 148 | clientId: "client-1", |
| 149 | ok: true, | 149 | connectionId: request.connectionId, |
| 150 | result: { url: "http://localhost:3200" }, | 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) { | 158 | return response.pipe(Effect.forkScoped, Effect.asVoid); |
| 153 | yield* Deferred.succeed(releaseOlderResponse, undefined); | 159 | }).pipe(Effect.forkScoped); |
| 154 | } | 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); | 188 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 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: {} }); | ||
| 170 | 189 | ||
| 171 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); | 190 | expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); |
| 172 | }), | 191 | expect(capturedTabId).toBe(olderTabId); |
| 173 | ), | 192 | }), |
| 193 | ), | ||
| 174 | ); | 194 | ); |
| 175 | 195 | ||
| 176 | it.effect("tracks the tab returned by a targeted recording stop", () => | 196 | it.effect("tracks the tab returned by a targeted recording stop", () => |
| @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { · 43 unmodified lines | |||
| 44 | readonly input: unknown; | 44 | readonly input: unknown; |
| 45 | readonly tabId?: PreviewTabId; | 45 | readonly tabId?: PreviewTabId; |
| 46 | readonly timeoutMs?: number; | 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 | export class PreviewAutomationBroker extends Context.Service< | 53 | export class PreviewAutomationBroker extends Context.Service< |
| @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { · 491 unmodified lines | |||
| 541 | }); | 545 | }); |
| 542 | } | 546 | } |
| 543 | const { connection, requestId, requestContext, requestSequence } = route; | 547 | const { connection, requestId, requestContext, requestSequence } = route; |
| 548 | input.onTargetTab?.(requestContext.tabId); | ||
| 544 | const removePending = SynchronizedRef.update(state, (next) => { | 549 | const removePending = SynchronizedRef.update(state, (next) => { |
| 545 | if (!next.pending.has(requestId)) return next; | 550 | if (!next.pending.has(requestId)) return next; |
| 546 | const pending = new Map(next.pending); | 551 | const pending = new Map(next.pending); |
| @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { · 28 unmodified lines | |||
| 575 | }); | 580 | }); |
| 576 | }); | 581 | }); |
| 577 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); | 582 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); |
| 583 | if (input.updateCurrentTab === false) return result; | ||
| 578 | const responseTabId = readResultTabId(result); | 584 | const responseTabId = readResultTabId(result); |
| 579 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; | 585 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; |
| 580 | if (resultTabId === undefined) return result; | 586 | if (resultTabId === undefined) return result; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | PreviewAutomationRecordingTransferError, | 7 | PreviewAutomationRecordingTransferError, |
| 8 | PreviewAutomationRecordingDesktopUpdateRequiredError, | 8 | PreviewAutomationRecordingDesktopUpdateRequiredError, |
| 9 | PreviewAutomationRecordingArtifact, | 9 | PreviewAutomationRecordingArtifact, |
| 10 | type ToolActivityIcon, | ||
| 10 | type ThreadId, | 11 | type ThreadId, |
| 11 | type PreviewAutomationOperation, | 12 | type PreviewAutomationOperation, |
| 12 | type PreviewAutomationOpenInput, | 13 | type PreviewAutomationOpenInput, |
| @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( · 42 unmodified lines | |||
| 55 | timeoutMs?: number, | 56 | timeoutMs?: number, |
| 56 | tabId?: PreviewTabId, | 57 | tabId?: PreviewTabId, |
| 57 | ): Effect.fn.Return< | 58 | ): Effect.fn.Return< |
| 58 | A, | 59 | { result: A; toolIcon?: ToolActivityIcon }, |
| 59 | import("@t3tools/contracts").PreviewAutomationError, | 60 | import("@t3tools/contracts").PreviewAutomationError, |
| 60 | McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker | 61 | McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker |
| 61 | > { | 62 | > { |
| 62 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); | 63 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); |
| 63 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | 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 | scope, | 70 | scope, |
| 66 | operation, | 71 | operation, |
| 67 | input, | 72 | input, |
| 68 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | 73 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 69 | ...(tabId === undefined ? {} : { tabId }), | 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 | operation: PreviewAutomationOperation, | 100 | operation: PreviewAutomationOperation, |
| 75 | input: { | 101 | input: { |
| 76 | readonly tabId?: PreviewTabId | undefined; | 102 | readonly tabId?: PreviewTabId | undefined; |
| @@ -79,7 +105,12 @@ const invokeTargeted = <A>( · 2 unmodified lines | |||
| 79 | timeoutMs?: number, | 105 | timeoutMs?: number, |
| 80 | ) => { | 106 | ) => { |
| 81 | const { tabId, ...operationInput } = input; | 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 | const UploadedRecordingArtifact = Schema.Struct({ | 116 | const UploadedRecordingArtifact = Schema.Struct({ |
| @@ -170,28 +201,32 @@ const handlers = { · 84 unmodified lines | |||
| 170 | const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; | 201 | const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; |
| 171 | return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput); | 202 | return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput); |
| 172 | }, | 203 | }, |
| 173 | preview_click: (input) => | 204 | preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs), |
| 174 | invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as({})), | 205 | preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs), |
| 175 | preview_type: (input) => invokeTargeted<void>("type", input, input.timeoutMs).pipe(Effect.as({})), | 206 | preview_press: (input) => invokeTargeted<object>("press", input), |
| 176 | preview_press: (input) => invokeTargeted<void>("press", input).pipe(Effect.as({})), | 207 | preview_scroll: (input) => invokeTargeted<object>("scroll", input), |
| 177 | preview_scroll: (input) => invokeTargeted<void>("scroll", input).pipe(Effect.as({})), | 208 | preview_evaluate: ({ tabId, ...input }) => |
| 178 | preview_evaluate: (input) => | 209 | invoke<unknown>("evaluate", input, undefined, tabId).pipe( |
| 179 | invokeTargeted<unknown>("evaluate", input).pipe( | 210 | Effect.map(({ result, toolIcon }) => ({ |
| 180 | Effect.map((result) => ({ value: result ?? null })), | 211 | value: result ?? null, |
| 212 | ...(toolIcon ? { toolIcon } : {}), | ||
| 213 | })), | ||
| 181 | ), | 214 | ), |
| 182 | preview_wait_for: (input) => | 215 | preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs), |
| 183 | invokeTargeted<void>("waitFor", input, input.timeoutMs).pipe(Effect.as({})), | ||
| 184 | preview_recording_start: (input) => | 216 | preview_recording_start: (input) => |
| 185 | invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}), | 217 | invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}), |
| 186 | preview_recording_stop: (input) => | 218 | preview_recording_stop: (input) => |
| 187 | Effect.gen(function* () { | 219 | Effect.gen(function* () { |
| 188 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); | 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 | "recordingStop", | 223 | "recordingStop", |
| 191 | { ...input, transferToEnvironment: true }, | 224 | { ...operationInput, transferToEnvironment: true }, |
| 192 | PREVIEW_RECORDING_STOP_TIMEOUT_MS, | 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 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; | 231 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; |
| 197 | 232 | ||
| @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { · 66 unmodified lines | |||
| 67 | for (const name of actionNames) { | 67 | for (const name of actionNames) { |
| 68 | expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ | 68 | expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ |
| 69 | type: "object", | 69 | type: "object", |
| 70 | properties: { toolIcon: expect.any(Object) }, | ||
| 70 | additionalProperties: false, | 71 | additionalProperties: false, |
| 71 | description: "The preview action completed successfully.", | 72 | description: "The preview action completed successfully.", |
| 72 | }); | 73 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ToolActivityIcon, | ||
| 2 | PreviewAutomationClickInput, | 3 | PreviewAutomationClickInput, |
| 3 | PreviewAutomationError, | 4 | PreviewAutomationError, |
| 4 | PreviewAutomationEvaluateInput, | 5 | PreviewAutomationEvaluateInput, |
| @@ -31,7 +32,9 @@ const dependencies = [ · 26 unmodified lines | |||
| 31 | PreviewAutomationBroker.PreviewAutomationBroker, | 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 | description: "The preview action completed successfully.", | 38 | description: "The preview action completed successfully.", |
| 36 | }); | 39 | }); |
| 37 | 40 | ||
| @@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool( · 51 unmodified lines | |||
| 89 | description: | 92 | description: |
| 90 | "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.", | 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 | parameters: PreviewAutomationResizeInput, | 94 | parameters: PreviewAutomationResizeInput, |
| 92 | success: PreviewAutomationResizeResult, | 95 | success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), |
| 93 | failure: PreviewAutomationError, | 96 | failure: PreviewAutomationError, |
| 94 | dependencies, | 97 | dependencies, |
| 95 | }) | 98 | }) |
| @@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool( · 6 unmodified lines | |||
| 102 | description: | 105 | description: |
| 103 | "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.", | 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 | parameters: PreviewAutomationSetColorSchemeInput, | 107 | parameters: PreviewAutomationSetColorSchemeInput, |
| 105 | success: PreviewAutomationSetColorSchemeResult, | 108 | success: Schema.Struct({ |
| 109 | ...PreviewAutomationSetColorSchemeResult.fields, | ||
| 110 | ...presentationFields, | ||
| 111 | }), | ||
| 106 | failure: PreviewAutomationError, | 112 | failure: PreviewAutomationError, |
| 107 | dependencies, | 113 | dependencies, |
| 108 | }) | 114 | }) |
| @@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool( · 76 unmodified lines | |||
| 185 | * null valid instead of failing only for non-object expressions. | 191 | * null valid instead of failing only for non-object expressions. |
| 186 | */ | 192 | */ |
| 187 | export const PreviewEvaluateResult = Schema.Struct({ | 193 | export const PreviewEvaluateResult = Schema.Struct({ |
| 194 | ...presentationFields, | ||
| 188 | value: Schema.Unknown.annotate({ | 195 | value: Schema.Unknown.annotate({ |
| 189 | description: "The JSON-serializable value the expression produced, or null.", | 196 | description: "The JSON-serializable value the expression produced, or null.", |
| 190 | }), | 197 | }), |
| @@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool( · 26 unmodified lines | |||
| 217 | description: | 224 | description: |
| 218 | "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", | 225 | "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", |
| 219 | parameters: PreviewAutomationTabTargetInput, | 226 | parameters: PreviewAutomationTabTargetInput, |
| 220 | success: PreviewAutomationRecordingStatus, | 227 | success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), |
| 221 | failure: PreviewAutomationError, | 228 | failure: PreviewAutomationError, |
| 222 | dependencies, | 229 | dependencies, |
| 223 | }).annotate(Tool.Title, "Start browser recording"), | 230 | }).annotate(Tool.Title, "Start browser recording"), |
| @@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool( · 4 unmodified lines | |||
| 228 | description: | 235 | description: |
| 229 | "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.", | 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 | parameters: PreviewAutomationTabTargetInput, | 237 | parameters: PreviewAutomationTabTargetInput, |
| 231 | success: PreviewAutomationRecordingArtifact, | 238 | success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), |
| 232 | failure: PreviewAutomationError, | 239 | failure: PreviewAutomationError, |
| 233 | dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], | 240 | dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], |
| 234 | }).annotate(Tool.Title, "Stop browser recording"), | 241 | }).annotate(Tool.Title, "Stop browser recording"), |
| @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { · 248 unmodified lines | |||
| 249 | expect(JSON.stringify(projected.payload).length).toBeLessThan(500); | 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 | it("passes task lifecycle payloads (no data field) through untouched", () => { | 330 | it("passes task lifecycle payloads (no data field) through untouched", () => { |
| 253 | const source = activity({ | 331 | const source = activity({ |
| 254 | taskId: "task-9", | 332 | taskId: "task-9", |
| @@ -5,6 +5,7 @@ import type { · 4 unmodified lines | |||
| 5 | OrchestrationThreadDetailSnapshot, | 5 | OrchestrationThreadDetailSnapshot, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 8 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 8 | 9 | ||
| 9 | function asRecord(value: unknown): Record<string, unknown> | null { | 10 | function asRecord(value: unknown): Record<string, unknown> | null { |
| 10 | return value !== null && typeof value === "object" && !Array.isArray(value) | 11 | return value !== null && typeof value === "object" && !Array.isArray(value) |
| @@ -241,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record<string, unknown> | undefine · 230 unmodified lines | |||
| 241 | return summary ? { content: summary } : undefined; | 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 | * MCP tool calls carry full tool results (`data.item.result` on Codex, | 310 | * MCP tool calls carry full tool results (`data.item.result` on Codex, |
| 246 | * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to | 311 | * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to |
| @@ -367,10 +432,14 @@ export function projectActivityPayload( · 120 unmodified lines | |||
| 367 | } | 432 | } |
| 368 | 433 | ||
| 369 | const itemStatus = asRecord(data.item)?.status; | 434 | const itemStatus = asRecord(data.item)?.status; |
| 370 | const projectedPayload = | 435 | const statusPayload = |
| 371 | payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") | 436 | payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") |
| 372 | ? { ...payload, status: itemStatus } | 437 | ? { ...payload, status: itemStatus } |
| 373 | : payload; | 438 | : payload; |
| 439 | const projectedPayload = { | ||
| 440 | ...projectPreviewToolMetadata(data, statusPayload.status), | ||
| 441 | ...statusPayload, | ||
| 442 | }; | ||
| 374 | const questionInput = projectQuestionToolInput(data, payload.title); | 443 | const questionInput = projectQuestionToolInput(data, payload.title); |
| 375 | 444 | ||
| 376 | if (payload.itemType === "mcp_tool_call") { | 445 | if (payload.itemType === "mcp_tool_call") { |