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
369 changes: 214 additions & 155 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts

Large diffs are not rendered by default.

181 changes: 163 additions & 18 deletions apps/server/test/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/t
import { deriveLatestContextWindowSnapshot } from "../../web/src/lib/contextWindow.ts";
import { deriveWorkLogEntries } from "../../web/src/session-logic.ts";
import {
MAX_PROJECTED_TOOL_RESULT_CHARS,
projectActivityEvent,
projectActivityPayload,
projectThreadDetailSnapshot,
Expand Down Expand Up @@ -79,6 +80,13 @@ const fixtures = [
commandActions: [{ type: "unknown", output: "y".repeat(5_000) }],
},
command: "fallback data",
toolName: "Bash",
input: { command: "pnpm test", cwd: "/repo" },
result: {
type: "tool_result",
content: "full command output\nsecond line",
is_error: false,
},
kind: "execute",
toolCallId: "tool-command",
rawOutput: {
Expand Down Expand Up @@ -117,8 +125,15 @@ const fixtures = [
server: "repository",
tool: "search",
arguments: { query: "activity projection" },
result: {
content: [
{ type: "text", text: "first MCP result line" },
{ type: "text", text: "second MCP result line" },
],
},
aggregatedOutput: "mcp bulk is dropped",
},
result: { content: "duplicate top-level MCP result" },
ignored: "top-level bulk",
}),
makeActivity("search", "web_search", {
Expand Down Expand Up @@ -156,7 +171,7 @@ describe("projectActivityPayload", () => {
);
}

it("drops unread bulk while retaining command, file, tool, and summary inputs", () => {
it("drops unread bulk while retaining full command results and tool inputs", () => {
const projected = projectActivityPayload(fixtures[0]!);
expect(projected.payload).toEqual({
itemType: "command_execution",
Expand All @@ -167,13 +182,19 @@ describe("projectActivityPayload", () => {
data: {
item: {
command: ["bash", "-lc", "pnpm test"],
input: { command: "fallback input" },
result: { command: "fallback result" },
input: { command: "fallback input", ignored: "input bulk" },
result: { command: "fallback result", aggregatedOutput: "x".repeat(10_000) },
},
command: "fallback data",
toolName: "Bash",
input: { command: "pnpm test", cwd: "/repo" },
toolCallId: "tool-command",
kind: "execute",
rawOutput: { content: "first useful line" },
rawOutput: {
content: "\n```\nfirst useful line\nsecond line",
stdout: "unused stdout",
ignored: "raw bulk",
},
},
});

Expand All @@ -184,7 +205,55 @@ describe("projectActivityPayload", () => {
});
});

it("slims MCP tool data to the fields the expanded row renders", () => {
it("retains Codex command output and completion metadata", () => {
const projected = projectActivityPayload(
makeActivity("codex-command", "command_execution", {
item: {
type: "commandExecution",
command: "pnpm test",
aggregatedOutput: "output text",
exitCode: 0,
status: "completed",
},
}),
);

expect(projected.payload).toMatchObject({
data: {
item: {
command: "pnpm test",
aggregatedOutput: "output text",
exitCode: 0,
status: "completed",
},
},
});
});

it("caps oversized Codex command output and marks it truncated", () => {
const projected = projectActivityPayload(
makeActivity("codex-command-oversized", "command_execution", {
item: {
type: "commandExecution",
command: "pnpm test",
aggregatedOutput: "x".repeat(MAX_PROJECTED_TOOL_RESULT_CHARS + 1_000),
exitCode: 0,
status: "completed",
},
}),
);
const payload = projected.payload as Record<string, unknown>;
const data = payload.data as Record<string, unknown>;
const item = data.item as Record<string, unknown>;

expect(item.aggregatedOutput).toEqual(expect.stringMatching(/…\[truncated\]$/));
expect(JSON.stringify(item.aggregatedOutput).length).toBeLessThanOrEqual(
MAX_PROJECTED_TOOL_RESULT_CHARS,
);
expect(data.resultTruncated).toBe(true);
});

it("slims MCP tool data while retaining its full result", () => {
expect(projectActivityPayload(fixtures[4]!).payload).toEqual({
itemType: "mcp_tool_call",
title: "mcp_tool_call",
Expand All @@ -196,27 +265,103 @@ describe("projectActivityPayload", () => {
server: "repository",
tool: "search",
arguments: { query: "activity projection" },
result: {
content: [
{ type: "text", text: "first MCP result line" },
{ type: "text", text: "second MCP result line" },
],
},
},
},
});
});

it("keeps current web and mobile derived output identical for every tool item type", () => {
it("truncates oversized result text and marks the projected data", () => {
const escapedOutput = '"\n\\'.repeat(MAX_PROJECTED_TOOL_RESULT_CHARS);
const projected = projectActivityPayload(
makeActivity("oversized", "command_execution", {
toolName: "Bash",
result: {
type: "tool_result",
content: escapedOutput,
},
}),
);
const payload = projected.payload as Record<string, unknown>;
const data = payload.data as Record<string, unknown>;
const result = data.result as { readonly content: string };

expect(result.content.endsWith("…[truncated]")).toBe(true);
expect(JSON.stringify(result).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS);
expect(data.resultTruncated).toBe(true);
});

it("caps oversized values without text under the serialized limit", () => {
const projected = projectActivityPayload(
makeActivity("oversized-no-text", "command_execution", {
result: Array.from({ length: MAX_PROJECTED_TOOL_RESULT_CHARS }, () => 0),
}),
);
const payload = projected.payload as Record<string, unknown>;
const data = payload.data as Record<string, unknown>;

expect(JSON.stringify(data.result).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS);
expect(data.resultTruncated).toBe(true);
});

it("leaves unserializable live values unchanged without throwing", () => {
const circular: Record<string, unknown> = { content: "live output" };
circular.self = circular;

const projected = projectActivityPayload(
makeActivity("circular", "command_execution", { result: circular }),
);
const payload = projected.payload as Record<string, unknown>;
const data = payload.data as Record<string, unknown>;

expect(data.result).toBe(circular);
expect(data).not.toHaveProperty("resultTruncated");
});

it("does not duplicate item results at the top level", () => {
const commandData = (projectActivityPayload(fixtures[0]!).payload as Record<string, unknown>)
.data as Record<string, unknown>;
const mcpData = (projectActivityPayload(fixtures[4]!).payload as Record<string, unknown>)
.data as Record<string, unknown>;

expect(commandData.item).toHaveProperty("result");
expect(commandData).not.toHaveProperty("result");
expect(mcpData.item).toHaveProperty("result");
expect(mcpData).not.toHaveProperty("result");
});

it("does not mark input-only truncation as truncated output", () => {
const projected = projectActivityPayload(
makeActivity("oversized-input", "command_execution", {
input: "x".repeat(MAX_PROJECTED_TOOL_RESULT_CHARS + 1_000),
}),
);
const payload = projected.payload as Record<string, unknown>;
const data = payload.data as Record<string, unknown>;

expect(JSON.stringify(data.input).length).toBeLessThanOrEqual(MAX_PROJECTED_TOOL_RESULT_CHARS);
expect(data).not.toHaveProperty("resultTruncated");
});

it("keeps current web presentation and mobile derived output equivalent", () => {
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;
const [before] = deriveWorkLogEntries([activity]);
const [after] = deriveWorkLogEntries([projected]);
const { toolData: _beforeToolData, ...beforePresentation } = before ?? {};
const { toolData: _afterToolData, ...afterPresentation } = after ?? {};
expect(afterPresentation).toEqual(beforePresentation);
expect(after?.toolData).toEqual((projected.payload as { readonly data?: unknown }).data);
// Mobile serializes every MCP item field in its debug expansion, so
// dropping unrelated item bulk is intentionally not byte-equivalent.
if (activity !== fixtures[4]) {
expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity]));
}
expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity]));
expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity]));
}
});

Expand Down
79 changes: 20 additions & 59 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { Button } from "./ui/button";
import { CopyTextButton } from "./ui/copy-text-button";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";
import { ScrollArea } from "./ui/scroll-area";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
Expand Down Expand Up @@ -127,7 +128,10 @@ interface MarkdownActionFailureContext {
readonly copyTarget?: string;
}

function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void {
export function reportMarkdownActionFailure(
context: MarkdownActionFailureContext,
cause: unknown,
): void {
console.error("[chat-markdown] action failed", context, cause);
}

Expand Down Expand Up @@ -596,49 +600,8 @@ function MarkdownCodeBlock({
theme: "light" | "dark";
children: ReactNode;
}) {
const [copied, setCopied] = useState(false);
const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
const copyLabel = copied ? "Copied" : "Copy code";

const handleCopy = useCallback(() => {
if (typeof navigator === "undefined" || navigator.clipboard == null) {
return;
}
void navigator.clipboard
.writeText(code)
.then(() => {
if (copiedTimerRef.current != null) {
clearTimeout(copiedTimerRef.current);
}
setCopied(true);
copiedTimerRef.current = setTimeout(() => {
setCopied(false);
copiedTimerRef.current = null;
}, 1200);
})
.catch((cause) => {
reportMarkdownActionFailure(
{
operation: "copy-code-block",
language,
...(fenceTitle ? { fenceTitle } : {}),
},
cause,
);
});
}, [code, fenceTitle, language]);

useEffect(
() => () => {
if (copiedTimerRef.current != null) {
clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = null;
}
},
[],
);

return (
<div
Expand Down Expand Up @@ -673,23 +636,21 @@ function MarkdownCodeBlock({
</TooltipTrigger>
<TooltipPopup side="top">{wrapLabel}</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="chat-markdown-chrome-action"
onClick={handleCopy}
aria-label={copyLabel}
/>
}
>
{copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{copyLabel}</TooltipPopup>
</Tooltip>
<CopyTextButton
text={code}
label="Copy code"
className="chat-markdown-chrome-action"
onCopyError={(cause) => {
reportMarkdownActionFailure(
{
operation: "copy-code-block",
language,
...(fenceTitle ? { fenceTitle } : {}),
},
cause,
);
}}
/>
</span>
</div>
{children}
Expand Down
Loading
Loading