| 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,7 +250,10 @@ it.effect.each([ |
| 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([ | ... | @@ -279,7 +282,12 @@ it.effect.each([ |
| 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", () => | ... | @@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => |
| 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", () => | ... | @@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => |
| 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 | ... | @@ -471,7 +482,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are |
| 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 | ... | @@ -482,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are |
| 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", () => | ... | @@ -533,7 +544,7 @@ it.effect("sheds log entries before locators when every list is full", () => |
| 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 | ... | @@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 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 | ... | @@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 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 | ... | @@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 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 | ... | @@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 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)), |
apps/server/src/mcp/McpHttpServer.ts+7| ... | @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot | ... | @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot |
| 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 | ? [] |
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,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", () => | 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", () => |
apps/server/src/mcp/PreviewAutomationBroker.ts+6| ... | @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { | ... | @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { |
| 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() { | ... | @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 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() { | ... | @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 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; |
apps/server/src/mcp/toolkits/preview/handlers.ts+52-17| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 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>( | ... | @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 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>( | ... | @@ -79,7 +105,12 @@ const invokeTargeted = <A>( |
| 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 = { | ... | @@ -170,28 +201,32 @@ const handlers = { |
| 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 |
apps/server/src/mcp/toolkits/preview/tools.test.ts+1| ... | @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { | ... | @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { |
| 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 | }); |
apps/server/src/mcp/toolkits/preview/tools.ts+12-5| ... | @@ -1,4 +1,5 @@ | ... | @@ -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 = [ | ... | @@ -31,7 +32,9 @@ const dependencies = [ |
| 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( | ... | @@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool( |
| 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( | ... | @@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool( |
| 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( | ... | @@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool( |
| 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( | ... | @@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool( |
| 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( | ... | @@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool( |
| 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"), |
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+78| ... | @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { | ... | @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { |
| 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", |
apps/server/src/orchestration/ActivityPayloadProjection.ts+70-1| ... | @@ -5,6 +5,7 @@ import type { | ... | @@ -5,6 +5,7 @@ import type { |
| 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 | ... | @@ -241,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record<string, unknown> | undefine |
| 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( | ... | @@ -367,10 +432,14 @@ export function projectActivityPayload( |
| 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") { |