| author | |
| committer | |
| log | 3da315e7b5c4537cbc7280f33dadb3f5f0e3baf0 |
| tree | e9f4378f66af901b3b8558af847ef2ae98f41792 |
| parent | a2ca89aa10f13a2222e08afd98c66285121d5ba2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>3 files changed, 203 insertions(+), 7 deletions(-)
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+54| ... | ... | @@ -44,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => { |
| 44 | 44 | expect(data.somethingClientNeverReads).toBeUndefined(); |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { | |
| 48 | const projected = projectActivityPayload( | |
| 49 | activity({ | |
| 50 | itemType: "mcp_tool_call", | |
| 51 | data: { | |
| 52 | item: { | |
| 53 | type: "mcpToolCall", | |
| 54 | id: "item-1", | |
| 55 | tool: "fetch_pr", | |
| 56 | server: "github", | |
| 57 | status: "completed", | |
| 58 | arguments: { pr: 42 }, | |
| 59 | durationMs: 1200, | |
| 60 | result: { | |
| 61 | content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }], | |
| 62 | structuredContent: { huge: "y".repeat(5000) }, | |
| 63 | }, | |
| 64 | _meta: { internal: true }, | |
| 65 | }, | |
| 66 | }, | |
| 67 | }), | |
| 68 | ); | |
| 69 | const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>; | |
| 70 | const item = data.item as Record<string, unknown>; | |
| 71 | expect(item.tool).toBe("fetch_pr"); | |
| 72 | expect(item.server).toBe("github"); | |
| 73 | expect(item.arguments).toEqual({ pr: 42 }); | |
| 74 | expect(item._meta).toBeUndefined(); | |
| 75 | expect(item.result).toEqual({ content: "PR body line one" }); | |
| 76 | expect(JSON.stringify(projected.payload).length).toBeLessThan(500); | |
| 77 | }); | |
| 78 | ||
| 79 | it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => { | |
| 80 | const projected = projectActivityPayload( | |
| 81 | activity({ | |
| 82 | itemType: "mcp_tool_call", | |
| 83 | data: { | |
| 84 | toolName: "mcp__github__fetch_pr", | |
| 85 | input: { pr: 42 }, | |
| 86 | result: { | |
| 87 | type: "tool_result", | |
| 88 | tool_use_id: "toolu_1", | |
| 89 | content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }], | |
| 90 | }, | |
| 91 | }, | |
| 92 | }), | |
| 93 | ); | |
| 94 | const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>; | |
| 95 | expect(data.toolName).toBe("mcp__github__fetch_pr"); | |
| 96 | expect(data.input).toEqual({ pr: 42 }); | |
| 97 | expect(data.result).toEqual({ content: "first line of output" }); | |
| 98 | expect(JSON.stringify(projected.payload).length).toBeLessThan(500); | |
| 99 | }); | |
| 100 | ||
| 47 | 101 | it("passes task lifecycle payloads (no data field) through untouched", () => { |
| 48 | 102 | const source = activity({ |
| 49 | 103 | taskId: "task-9", |
apps/server/src/orchestration/ActivityPayloadProjection.ts+119-1| ... | ... | @@ -123,6 +123,114 @@ function summarizeToolTextOutput(value: string): string | null { |
| 123 | 123 | return null; |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | /** | |
| 127 | * Fields of an MCP tool-call item both clients render in the expanded | |
| 128 | * work-log row. Everything else — notably `result`, which carries the full | |
| 129 | * tool output and dominates wire size on MCP-heavy threads — is summarized | |
| 130 | * or dropped. Full payloads remain in persistence. | |
| 131 | */ | |
| 132 | const MCP_ITEM_KEPT_FIELDS = [ | |
| 133 | "type", | |
| 134 | "id", | |
| 135 | "tool", | |
| 136 | "server", | |
| 137 | "status", | |
| 138 | "arguments", | |
| 139 | "appContext", | |
| 140 | "error", | |
| 141 | "durationMs", | |
| 142 | ] as const; | |
| 143 | ||
| 144 | /** | |
| 145 | * Pulls renderable text out of an MCP tool result: either a Codex-style | |
| 146 | * `{content: [{type: "text", text}, ...]}` record or a raw Claude | |
| 147 | * `tool_result` block whose `content` is a string or block array. | |
| 148 | */ | |
| 149 | function extractMcpResultText(result: unknown): string | null { | |
| 150 | const record = asRecord(result); | |
| 151 | if (!record) { | |
| 152 | return typeof result === "string" ? result : null; | |
| 153 | } | |
| 154 | if (typeof record.content === "string") { | |
| 155 | return record.content; | |
| 156 | } | |
| 157 | if (Array.isArray(record.content)) { | |
| 158 | const texts: string[] = []; | |
| 159 | for (const entry of record.content) { | |
| 160 | const text = asRecord(entry)?.text; | |
| 161 | if (typeof text === "string" && text.trim().length > 0) { | |
| 162 | texts.push(text); | |
| 163 | } | |
| 164 | } | |
| 165 | if (texts.length > 0) { | |
| 166 | return texts.join("\n"); | |
| 167 | } | |
| 168 | } | |
| 169 | return null; | |
| 170 | } | |
| 171 | ||
| 172 | function summarizeMcpResult(result: unknown): Record<string, unknown> | undefined { | |
| 173 | if (result === undefined || result === null) { | |
| 174 | return undefined; | |
| 175 | } | |
| 176 | const text = extractMcpResultText(result); | |
| 177 | const summary = text ? summarizeToolTextOutput(text) : null; | |
| 178 | return summary ? { content: summary } : undefined; | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * MCP tool calls carry full tool results (`data.item.result` on Codex, | |
| 183 | * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to | |
| 184 | * keep the expanded-row UI working. Keep the fields the UI actually renders | |
| 185 | * and summarize the result like regular tool output. | |
| 186 | */ | |
| 187 | function projectMcpToolCallData(data: Record<string, unknown>): Record<string, unknown> { | |
| 188 | const projectedData: Record<string, unknown> = {}; | |
| 189 | ||
| 190 | const item = asRecord(data.item); | |
| 191 | if (item) { | |
| 192 | const projectedItem: Record<string, unknown> = {}; | |
| 193 | for (const key of MCP_ITEM_KEPT_FIELDS) { | |
| 194 | if (key in item) { | |
| 195 | projectedItem[key] = item[key]; | |
| 196 | } | |
| 197 | } | |
| 198 | const result = summarizeMcpResult(item.result); | |
| 199 | if (result) { | |
| 200 | projectedItem.result = result; | |
| 201 | } | |
| 202 | projectedData.item = projectedItem; | |
| 203 | } | |
| 204 | ||
| 205 | if ("toolName" in data) { | |
| 206 | projectedData.toolName = data.toolName; | |
| 207 | } | |
| 208 | if ("input" in data) { | |
| 209 | projectedData.input = data.input; | |
| 210 | } | |
| 211 | if (!item) { | |
| 212 | const result = summarizeMcpResult(data.result); | |
| 213 | if (result) { | |
| 214 | projectedData.result = result; | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | if ("toolCallId" in data) { | |
| 219 | projectedData.toolCallId = data.toolCallId; | |
| 220 | } | |
| 221 | if ("kind" in data) { | |
| 222 | projectedData.kind = data.kind; | |
| 223 | } | |
| 224 | ||
| 225 | const changedFiles: string[] = []; | |
| 226 | collectChangedFiles(data, changedFiles, new Set<string>(), 0); | |
| 227 | if (changedFiles.length > 0) { | |
| 228 | projectedData.files = changedFiles.map((path) => ({ path })); | |
| 229 | } | |
| 230 | ||
| 231 | return projectedData; | |
| 232 | } | |
| 233 | ||
| 126 | 234 | function projectRawOutput(value: unknown): Record<string, unknown> | undefined { |
| 127 | 235 | const rawOutput = asRecord(value); |
| 128 | 236 | if (!rawOutput) { |
| ... | ... | @@ -160,10 +268,20 @@ export function projectActivityPayload( |
| 160 | 268 | ): OrchestrationThreadActivity { |
| 161 | 269 | const payload = asRecord(activity.payload); |
| 162 | 270 | const data = asRecord(payload?.data); |
| 163 | if (!payload || !data || payload.itemType === "mcp_tool_call") { | |
| 271 | if (!payload || !data) { | |
| 164 | 272 | return activity; |
| 165 | 273 | } |
| 166 | 274 | |
| 275 | if (payload.itemType === "mcp_tool_call") { | |
| 276 | return { | |
| 277 | ...activity, | |
| 278 | payload: { | |
| 279 | ...payload, | |
| 280 | data: projectMcpToolCallData(data), | |
| 281 | }, | |
| 282 | }; | |
| 283 | } | |
| 284 | ||
| 167 | 285 | const projectedData: Record<string, unknown> = {}; |
| 168 | 286 | const item = projectCommandData(data); |
| 169 | 287 | if (item) { |
apps/server/test/ActivityPayloadProjection.test.ts+30-6| ... | ... | @@ -117,9 +117,9 @@ const fixtures = [ |
| 117 | 117 | server: "repository", |
| 118 | 118 | tool: "search", |
| 119 | 119 | arguments: { query: "activity projection" }, |
| 120 | aggregatedOutput: "mcp payload remains available", | |
| 120 | aggregatedOutput: "mcp bulk is dropped", | |
| 121 | 121 | }, |
| 122 | ignored: "MCP data is rendered verbatim", | |
| 122 | ignored: "top-level bulk", | |
| 123 | 123 | }), |
| 124 | 124 | makeActivity("search", "web_search", { |
| 125 | 125 | rawOutput: { |
| ... | ... | @@ -184,13 +184,37 @@ describe("projectActivityPayload", () => { |
| 184 | 184 | }); |
| 185 | 185 | }); |
| 186 | 186 | |
| 187 | it("passes MCP tool data through unchanged", () => { | |
| 188 | expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); | |
| 187 | it("slims MCP tool data to the fields the expanded row renders", () => { | |
| 188 | expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ | |
| 189 | itemType: "mcp_tool_call", | |
| 190 | title: "mcp_tool_call", | |
| 191 | detail: "mcp_tool_call detail", | |
| 192 | status: "completed", | |
| 193 | requestKind: "command", | |
| 194 | data: { | |
| 195 | item: { | |
| 196 | server: "repository", | |
| 197 | tool: "search", | |
| 198 | arguments: { query: "activity projection" }, | |
| 199 | }, | |
| 200 | }, | |
| 201 | }); | |
| 189 | 202 | }); |
| 190 | 203 | |
| 191 | 204 | it("keeps current web and mobile derived output identical for every tool item type", () => { |
| 192 | 205 | for (const activity of fixtures) { |
| 193 | 206 | const projected = projectActivityPayload(activity); |
| 207 | if (activity === fixtures[4]) { | |
| 208 | // MCP is the one deliberate difference: the expanded row's toolData | |
| 209 | // loses result bulk but keeps the rendered identity fields. | |
| 210 | const [entry] = deriveWorkLogEntries([projected]); | |
| 211 | expect(entry?.toolData).toEqual({ | |
| 212 | server: "repository", | |
| 213 | tool: "search", | |
| 214 | arguments: { query: "activity projection" }, | |
| 215 | }); | |
| 216 | continue; | |
| 217 | } | |
| 194 | 218 | expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); |
| 195 | 219 | expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); |
| 196 | 220 | } |
| ... | ... | @@ -328,12 +352,12 @@ describe("context-window snapshot dedup", () => { |
| 328 | 352 | ); |
| 329 | 353 | }); |
| 330 | 354 | |
| 331 | it("leaves snapshots without context-window activities untouched", () => { | |
| 355 | it("applies only payload slimming when there are no context-window activities", () => { | |
| 332 | 356 | const projected = projectThreadDetailSnapshot({ |
| 333 | 357 | snapshotSequence: 7, |
| 334 | 358 | thread: makeThread([fixtures[4]!]), |
| 335 | 359 | }); |
| 336 | expect(projected.thread.activities).toEqual([fixtures[4]]); | |
| 360 | expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]); | |
| 337 | 361 | }); |
| 338 | 362 | |
| 339 | 363 | it("does not filter live activity-appended events", () => { |