| 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,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => { |
| 44 | expect(data.somethingClientNeverReads).toBeUndefined(); | 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 | it("passes task lifecycle payloads (no data field) through untouched", () => { | 101 | it("passes task lifecycle payloads (no data field) through untouched", () => { |
| 48 | const source = activity({ | 102 | const source = activity({ |
| 49 | taskId: "task-9", | 103 | taskId: "task-9", |
apps/server/src/orchestration/ActivityPayloadProjection.ts+119-1| ... | @@ -123,6 +123,114 @@ function summarizeToolTextOutput(value: string): string | null { | ... | @@ -123,6 +123,114 @@ function summarizeToolTextOutput(value: string): string | null { |
| 123 | return null; | 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 | function projectRawOutput(value: unknown): Record<string, unknown> | undefined { | 234 | function projectRawOutput(value: unknown): Record<string, unknown> | undefined { |
| 127 | const rawOutput = asRecord(value); | 235 | const rawOutput = asRecord(value); |
| 128 | if (!rawOutput) { | 236 | if (!rawOutput) { |
| ... | @@ -160,10 +268,20 @@ export function projectActivityPayload( | ... | @@ -160,10 +268,20 @@ export function projectActivityPayload( |
| 160 | ): OrchestrationThreadActivity { | 268 | ): OrchestrationThreadActivity { |
| 161 | const payload = asRecord(activity.payload); | 269 | const payload = asRecord(activity.payload); |
| 162 | const data = asRecord(payload?.data); | 270 | const data = asRecord(payload?.data); |
| 163 | if (!payload || !data || payload.itemType === "mcp_tool_call") { | 271 | if (!payload || !data) { |
| 164 | return activity; | 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 | const projectedData: Record<string, unknown> = {}; | 285 | const projectedData: Record<string, unknown> = {}; |
| 168 | const item = projectCommandData(data); | 286 | const item = projectCommandData(data); |
| 169 | if (item) { | 287 | if (item) { |
apps/server/test/ActivityPayloadProjection.test.ts+30-6| ... | @@ -117,9 +117,9 @@ const fixtures = [ | ... | @@ -117,9 +117,9 @@ const fixtures = [ |
| 117 | server: "repository", | 117 | server: "repository", |
| 118 | tool: "search", | 118 | tool: "search", |
| 119 | arguments: { query: "activity projection" }, | 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 | makeActivity("search", "web_search", { | 124 | makeActivity("search", "web_search", { |
| 125 | rawOutput: { | 125 | rawOutput: { |
| ... | @@ -184,13 +184,37 @@ describe("projectActivityPayload", () => { | ... | @@ -184,13 +184,37 @@ describe("projectActivityPayload", () => { |
| 184 | }); | 184 | }); |
| 185 | }); | 185 | }); |
| 186 | 186 | ||
| 187 | it("passes MCP tool data through unchanged", () => { | 187 | it("slims MCP tool data to the fields the expanded row renders", () => { |
| 188 | expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); | 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 | it("keeps current web and mobile derived output identical for every tool item type", () => { | 204 | it("keeps current web and mobile derived output identical for every tool item type", () => { |
| 192 | for (const activity of fixtures) { | 205 | for (const activity of fixtures) { |
| 193 | const projected = projectActivityPayload(activity); | 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 | expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); | 218 | expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); |
| 195 | expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); | 219 | expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity])); |
| 196 | } | 220 | } |
| ... | @@ -328,12 +352,12 @@ describe("context-window snapshot dedup", () => { | ... | @@ -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 | const projected = projectThreadDetailSnapshot({ | 356 | const projected = projectThreadDetailSnapshot({ |
| 333 | snapshotSequence: 7, | 357 | snapshotSequence: 7, |
| 334 | thread: makeThread([fixtures[4]!]), | 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 | it("does not filter live activity-appended events", () => { | 363 | it("does not filter live activity-appended events", () => { |