Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).data as Record<string, unknown>;
const item = data.item as Record<string, unknown>;
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<string, unknown>).data as Record<string, unknown>;
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",
Expand Down
120 changes: 119 additions & 1 deletion apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>): Record<string, unknown> {
const projectedData: Record<string, unknown> = {};

const item = asRecord(data.item);
if (item) {
const projectedItem: Record<string, unknown> = {};
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<string>(), 0);
if (changedFiles.length > 0) {
projectedData.files = changedFiles.map((path) => ({ path }));
}

return projectedData;
}

function projectRawOutput(value: unknown): Record<string, unknown> | undefined {
const rawOutput = asRecord(value);
if (!rawOutput) {
Expand Down Expand Up @@ -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),
},
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

const projectedData: Record<string, unknown> = {};
const item = projectCommandData(data);
if (item) {
Expand Down
36 changes: 30 additions & 6 deletions apps/server/test/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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]));
}
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading