From 95ab949d52277c4eddb793c6d8af5e0b53c2703d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 02:16:32 -0700 Subject: [PATCH 1/2] perf(server): stop shipping full MCP tool results in thread payloads mcp_tool_call activities bypassed payload slimming entirely, so full tool results (up to 1 MB per call) shipped in every snapshot and live event. Keep the fields the expanded-row UI renders and summarize the result like regular tool output: 12.2 MB -> 546 KB across the seeded real-data db. Co-Authored-By: Claude Fable 5 --- .../ActivityPayloadProjection.test.ts | 54 ++++++++ .../ActivityPayloadProjection.ts | 120 +++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 7ea1e3ea0ed..fc9ea4b6226 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -44,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + item: { + type: "mcpToolCall", + id: "item-1", + tool: "fetch_pr", + server: "github", + status: "completed", + arguments: { pr: 42 }, + durationMs: 1200, + result: { + content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }], + structuredContent: { huge: "y".repeat(5000) }, + }, + _meta: { internal: true }, + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + const item = data.item as Record; + expect(item.tool).toBe("fetch_pr"); + expect(item.server).toBe("github"); + expect(item.arguments).toEqual({ pr: 42 }); + expect(item._meta).toBeUndefined(); + expect(item.result).toEqual({ content: "PR body line one" }); + expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + }); + + it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => { + const projected = projectActivityPayload( + activity({ + itemType: "mcp_tool_call", + data: { + toolName: "mcp__github__fetch_pr", + input: { pr: 42 }, + result: { + type: "tool_result", + tool_use_id: "toolu_1", + content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }], + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.toolName).toBe("mcp__github__fetch_pr"); + expect(data.input).toEqual({ pr: 42 }); + expect(data.result).toEqual({ content: "first line of output" }); + expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + }); + it("passes task lifecycle payloads (no data field) through untouched", () => { const source = activity({ taskId: "task-9", diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 67896961b38..854e45dbfbd 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -123,6 +123,114 @@ function summarizeToolTextOutput(value: string): string | null { return null; } +/** + * Fields of an MCP tool-call item both clients render in the expanded + * work-log row. Everything else — notably `result`, which carries the full + * tool output and dominates wire size on MCP-heavy threads — is summarized + * or dropped. Full payloads remain in persistence. + */ +const MCP_ITEM_KEPT_FIELDS = [ + "type", + "id", + "tool", + "server", + "status", + "arguments", + "appContext", + "error", + "durationMs", +] as const; + +/** + * Pulls renderable text out of an MCP tool result: either a Codex-style + * `{content: [{type: "text", text}, ...]}` record or a raw Claude + * `tool_result` block whose `content` is a string or block array. + */ +function extractMcpResultText(result: unknown): string | null { + const record = asRecord(result); + if (!record) { + return typeof result === "string" ? result : null; + } + if (typeof record.content === "string") { + return record.content; + } + if (Array.isArray(record.content)) { + const texts: string[] = []; + for (const entry of record.content) { + const text = asRecord(entry)?.text; + if (typeof text === "string" && text.trim().length > 0) { + texts.push(text); + } + } + if (texts.length > 0) { + return texts.join("\n"); + } + } + return null; +} + +function summarizeMcpResult(result: unknown): Record | undefined { + if (result === undefined || result === null) { + return undefined; + } + const text = extractMcpResultText(result); + const summary = text ? summarizeToolTextOutput(text) : null; + return summary ? { content: summary } : undefined; +} + +/** + * MCP tool calls carry full tool results (`data.item.result` on Codex, + * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to + * keep the expanded-row UI working. Keep the fields the UI actually renders + * and summarize the result like regular tool output. + */ +function projectMcpToolCallData(data: Record): Record { + const projectedData: Record = {}; + + const item = asRecord(data.item); + if (item) { + const projectedItem: Record = {}; + for (const key of MCP_ITEM_KEPT_FIELDS) { + if (key in item) { + projectedItem[key] = item[key]; + } + } + const result = summarizeMcpResult(item.result); + if (result) { + projectedItem.result = result; + } + projectedData.item = projectedItem; + } + + if ("toolName" in data) { + projectedData.toolName = data.toolName; + } + if ("input" in data) { + projectedData.input = data.input; + } + if (!item) { + const result = summarizeMcpResult(data.result); + if (result) { + projectedData.result = result; + } + } + + if ("toolCallId" in data) { + projectedData.toolCallId = data.toolCallId; + } + if ("kind" in data) { + projectedData.kind = data.kind; + } + + const changedFiles: string[] = []; + collectChangedFiles(data, changedFiles, new Set(), 0); + if (changedFiles.length > 0) { + projectedData.files = changedFiles.map((path) => ({ path })); + } + + return projectedData; +} + function projectRawOutput(value: unknown): Record | undefined { const rawOutput = asRecord(value); if (!rawOutput) { @@ -160,10 +268,20 @@ export function projectActivityPayload( ): OrchestrationThreadActivity { const payload = asRecord(activity.payload); const data = asRecord(payload?.data); - if (!payload || !data || payload.itemType === "mcp_tool_call") { + if (!payload || !data) { return activity; } + if (payload.itemType === "mcp_tool_call") { + return { + ...activity, + payload: { + ...payload, + data: projectMcpToolCallData(data), + }, + }; + } + const projectedData: Record = {}; const item = projectCommandData(data); if (item) { From 22f7a583f1ef616f0273ede3cf85f6335c472725 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 02:27:17 -0700 Subject: [PATCH 2/2] test: update cross-client projection test for MCP slimming The integration test asserted MCP payloads pass through verbatim; it now asserts the slimmed shape and that the expanded row's rendered fields survive on both clients. Co-Authored-By: Claude Fable 5 --- .../test/ActivityPayloadProjection.test.ts | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index d6098937e7f..2d11801393a 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -117,9 +117,9 @@ const fixtures = [ server: "repository", tool: "search", arguments: { query: "activity projection" }, - aggregatedOutput: "mcp payload remains available", + aggregatedOutput: "mcp bulk is dropped", }, - ignored: "MCP data is rendered verbatim", + ignored: "top-level bulk", }), makeActivity("search", "web_search", { rawOutput: { @@ -184,13 +184,37 @@ describe("projectActivityPayload", () => { }); }); - it("passes MCP tool data through unchanged", () => { - expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); + it("slims MCP tool data to the fields the expanded row renders", () => { + expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ + itemType: "mcp_tool_call", + title: "mcp_tool_call", + detail: "mcp_tool_call detail", + status: "completed", + requestKind: "command", + data: { + item: { + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + }, + }, + }); }); it("keeps current web and mobile derived output identical for every tool item type", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); + if (activity === fixtures[4]) { + // MCP is the one deliberate difference: the expanded row's toolData + // loses result bulk but keeps the rendered identity fields. + const [entry] = deriveWorkLogEntries([projected]); + expect(entry?.toolData).toEqual({ + server: "repository", + tool: "search", + arguments: { query: "activity projection" }, + }); + continue; + } expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); } @@ -328,12 +352,12 @@ describe("context-window snapshot dedup", () => { ); }); - it("leaves snapshots without context-window activities untouched", () => { + it("applies only payload slimming when there are no context-window activities", () => { const projected = projectThreadDetailSnapshot({ snapshotSequence: 7, thread: makeThread([fixtures[4]!]), }); - expect(projected.thread.activities).toEqual([fixtures[4]]); + expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); }); it("does not filter live activity-appended events", () => {