authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 01:04:14-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 01:04:14-03:00
log6c69534a5800a95e1640b0571580561ef71ec461
tree121319a9cb7a609df623601b1f4e9fb196231975
parent02297e3dbd896ef619d5c916938c751e343e76a8
signature Signed by PGP key B5690EEEBB952194

fix(preview): render website favicons for browser tool activity (#11032)


9 files changed, 320 insertions(+), 78 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts+33-14
......@@ -250,7 +250,10 @@ it.effect.each([
250250 const { accessibilityTree: _tree, ...boundedMetadata } = metadata;
251251 expect(snapshot.isError).toBe(false);
252252 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 });
254257 expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata);
255258 expect(rest).toEqual([
256259 {
......@@ -279,7 +282,12 @@ it.effect.each([
279282 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
280283 Effect.provideService(McpSchema.McpServerClient, client),
281284 );
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 ]);
283291 expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot });
284292 expect(requests).toBe(7);
285293 }),
......@@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () =>
329337 /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/,
330338 );
331339 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;
333341 expect(text?.type === "text" ? text.text : "").toContain(screenshotPath);
334342
335343 const unsaved = yield* callSnapshot({});
......@@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () =>
429437 const snapshot = yield* callSnapshot({ includeImage: false });
430438
431439 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 });
433444 expect(text?.type).toBe("text");
434445 const body = text?.type === "text" ? text.text : "";
435446 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
471482
472483 const snapshot = yield* callSnapshot({ includeImage: false });
473484
474 const [text] = snapshot.content;
485 const [, text] = snapshot.content;
475486 const body = text?.type === "text" ? text.text : "";
476487 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
477488 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
......@@ -482,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are
482493 };
483494 expect(parsed.title.length).toBe(2_049);
484495 expect(parsed.consoleEntries[0]?.text.length).toBe(501);
485 const notice = snapshot.content[1];
496 const notice = snapshot.content[2];
486497 const noticeText = notice?.type === "text" ? notice.text : "";
487498 expect(noticeText).toContain("url or title after 2048 characters");
488499 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", () =>
533544
534545 const snapshot = yield* callSnapshot({ includeImage: false });
535546
536 const [text, notice] = snapshot.content;
547 const [, text, notice] = snapshot.content;
537548 const body = text?.type === "text" ? text.text : "";
538549 expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual(
539550 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
......@@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context
615626 Effect.gen(function* () {
616627 const server = yield* McpServer.McpServer;
617628 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
629 const toolIcon = {
630 _tag: "website" as const,
631 pageUrl: "http://example.test/",
632 };
618633 const routedRequests: Array<{
619634 readonly operation: string;
620635 readonly tabId?: string | undefined;
......@@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context
664679 expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false);
665680 expect(clickTool?.tool.annotations?.destructiveHint).toBe(true);
666681 expect(clickTool?.tool.annotations?.openWorldHint).toBe(true);
667 expect(clickTool?.tool.outputSchema).toEqual({
682 expect(clickTool?.tool.outputSchema).toMatchObject({
668683 type: "object",
669684 additionalProperties: false,
670685 description: "The preview action completed successfully.",
......@@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context
721736 Effect.provideService(McpSchema.McpServerClient, client),
722737 );
723738 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 });
728745
729746 const actionRequests = [
730747 { name: "preview_click", arguments: { x: 10, y: 10 } },
......@@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context
741758 Effect.provideService(McpSchema.McpServerClient, client),
742759 );
743760 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 });
746765 }
747766 }),
748767 ).pipe(Effect.provide(TestLayer)),
apps/server/src/mcp/McpHttpServer.ts+7
......@@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
410410 isError: false,
411411 structuredContent: metadata,
412412 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 },
413420 { type: "text", text: bounded.text },
414421 ...(bounded.omitted.length === 0
415422 ? []
apps/server/src/mcp/PreviewAutomationBroker.test.ts+61-41
......@@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () =
127127 ),
128128);
129129
130it.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" },
130it.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 }
151157 });
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,
155187 });
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: {} });
170189
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 ),
174194);
175195
176196it.effect("tracks the tab returned by a targeted recording stop", () =>
apps/server/src/mcp/PreviewAutomationBroker.ts+6
......@@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput {
4444 readonly input: unknown;
4545 readonly tabId?: PreviewTabId;
4646 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;
4751}
4852
4953export class PreviewAutomationBroker extends Context.Service<
......@@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
541545 });
542546 }
543547 const { connection, requestId, requestContext, requestSequence } = route;
548 input.onTargetTab?.(requestContext.tabId);
544549 const removePending = SynchronizedRef.update(state, (next) => {
545550 if (!next.pending.has(requestId)) return next;
546551 const pending = new Map(next.pending);
......@@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
575580 });
576581 });
577582 const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
583 if (input.updateCurrentTab === false) return result;
578584 const responseTabId = readResultTabId(result);
579585 const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
580586 if (resultTabId === undefined) return result;
apps/server/src/mcp/toolkits/preview/handlers.ts+52-17
......@@ -7,6 +7,7 @@ import {
77 PreviewAutomationRecordingTransferError,
88 PreviewAutomationRecordingDesktopUpdateRequiredError,
99 PreviewAutomationRecordingArtifact,
10 type ToolActivityIcon,
1011 type ThreadId,
1112 type PreviewAutomationOperation,
1213 type PreviewAutomationOpenInput,
......@@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
5556 timeoutMs?: number,
5657 tabId?: PreviewTabId,
5758): Effect.fn.Return<
58 A,
59 { result: A; toolIcon?: ToolActivityIcon },
5960 import("@t3tools/contracts").PreviewAutomationError,
6061 McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
6162> {
6263 const scope = yield* McpInvocationContext.requireMcpCapability("preview");
6364 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 },
6570 scope,
6671 operation,
6772 input,
6873 ...(timeoutMs === undefined ? {} : { timeoutMs }),
6974 ...(tabId === undefined ? {} : { tabId }),
7075 });
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 };
7197});
7298
73const invokeTargeted = <A>(
99const invokeTargeted = <A extends object>(
74100 operation: PreviewAutomationOperation,
75101 input: {
76102 readonly tabId?: PreviewTabId | undefined;
......@@ -79,7 +105,12 @@ const invokeTargeted = <A>(
79105 timeoutMs?: number,
80106) => {
81107 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 );
83114};
84115
85116const UploadedRecordingArtifact = Schema.Struct({
......@@ -170,28 +201,32 @@ const handlers = {
170201 const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {};
171202 return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput);
172203 },
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 })),
181214 ),
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),
184216 preview_recording_start: (input) =>
185217 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
186218 preview_recording_stop: (input) =>
187219 Effect.gen(function* () {
188220 const scope = yield* McpInvocationContext.requireMcpCapability("preview");
189 const response = yield* invokeTargeted<unknown>(
221 const { tabId, ...operationInput } = input;
222 const response = yield* invoke<unknown>(
190223 "recordingStop",
191 { ...input, transferToEnvironment: true },
224 { ...operationInput, transferToEnvironment: true },
192225 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
226 tabId,
193227 );
194 return yield* claimPreviewRecording(scope.threadId, response);
228 const artifact = yield* claimPreviewRecording(scope.threadId, response.result);
229 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
195230 }),
196231} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
197232
apps/server/src/mcp/toolkits/preview/tools.test.ts+1
......@@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => {
6767 for (const name of actionNames) {
6868 expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({
6969 type: "object",
70 properties: { toolIcon: expect.any(Object) },
7071 additionalProperties: false,
7172 description: "The preview action completed successfully.",
7273 });
apps/server/src/mcp/toolkits/preview/tools.ts+12-5
......@@ -1,4 +1,5 @@
11import {
2 ToolActivityIcon,
23 PreviewAutomationClickInput,
34 PreviewAutomationError,
45 PreviewAutomationEvaluateInput,
......@@ -31,7 +32,9 @@ const dependencies = [
3132 PreviewAutomationBroker.PreviewAutomationBroker,
3233];
3334
34const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate({
35const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) };
36
37const PreviewActionResult = Schema.Struct(presentationFields).annotate({
3538 description: "The preview action completed successfully.",
3639});
3740
......@@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool(
8992 description:
9093 "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.",
9194 parameters: PreviewAutomationResizeInput,
92 success: PreviewAutomationResizeResult,
95 success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }),
9396 failure: PreviewAutomationError,
9497 dependencies,
9598 })
......@@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool(
102105 description:
103106 "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.",
104107 parameters: PreviewAutomationSetColorSchemeInput,
105 success: PreviewAutomationSetColorSchemeResult,
108 success: Schema.Struct({
109 ...PreviewAutomationSetColorSchemeResult.fields,
110 ...presentationFields,
111 }),
106112 failure: PreviewAutomationError,
107113 dependencies,
108114 })
......@@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool(
185191 * null valid instead of failing only for non-object expressions.
186192 */
187193export const PreviewEvaluateResult = Schema.Struct({
194 ...presentationFields,
188195 value: Schema.Unknown.annotate({
189196 description: "The JSON-serializable value the expression produced, or null.",
190197 }),
......@@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool(
217224 description:
218225 "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.",
219226 parameters: PreviewAutomationTabTargetInput,
220 success: PreviewAutomationRecordingStatus,
227 success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }),
221228 failure: PreviewAutomationError,
222229 dependencies,
223230 }).annotate(Tool.Title, "Start browser recording"),
......@@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool(
228235 description:
229236 "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.",
230237 parameters: PreviewAutomationTabTargetInput,
231 success: PreviewAutomationRecordingArtifact,
238 success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }),
232239 failure: PreviewAutomationError,
233240 dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],
234241 }).annotate(Tool.Title, "Stop browser recording"),
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+78
......@@ -249,6 +249,84 @@ describe("projectActivityPayload", () => {
249249 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
250250 });
251251
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
252330 it("passes task lifecycle payloads (no data field) through untouched", () => {
253331 const source = activity({
254332 taskId: "task-9",
apps/server/src/orchestration/ActivityPayloadProjection.ts+70-1
......@@ -5,6 +5,7 @@ import type {
55 OrchestrationThreadDetailSnapshot,
66} from "@t3tools/contracts";
77import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
8import { extractJsonObject } from "@t3tools/shared/schemaJson";
89
910function asRecord(value: unknown): Record<string, unknown> | null {
1011 return value !== null && typeof value === "object" && !Array.isArray(value)
......@@ -241,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record<string, unknown> | undefine
241242 return summary ? { content: summary } : undefined;
242243}
243244
245/** Reuse the page URL already returned by preview tools before slimming their output. */
246function 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
244309/**
245310 * MCP tool calls carry full tool results (`data.item.result` on Codex,
246311 * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to
......@@ -367,10 +432,14 @@ export function projectActivityPayload(
367432 }
368433
369434 const itemStatus = asRecord(data.item)?.status;
370 const projectedPayload =
435 const statusPayload =
371436 payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined")
372437 ? { ...payload, status: itemStatus }
373438 : payload;
439 const projectedPayload = {
440 ...projectPreviewToolMetadata(data, statusPayload.status),
441 ...statusPayload,
442 };
374443 const questionInput = projectQuestionToolInput(data, payload.title);
375444
376445 if (payload.itemType === "mcp_tool_call") {