From 9cd1e04c8822d745b8c68d6c9dcc27d177887c22 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Sat, 8 Aug 2026 17:59:58 -0500 Subject: [PATCH 1/6] feat(web): show full tool call output in expanded work-log rows Tool results were captured by every provider adapter and persisted, but projectActivityPayload stripped them at the wire boundary, so expanded rows had nothing to show. Retain result/input/rawOutput/state in the projected payload under a 50k-per-field cap with a truncation flag, and render the extracted output text in the expanded timeline row for all tool types. Claude Fable 5 (Claude Code) + GPT-5.6 (Codex CLI) --- .../ActivityPayloadProjection.ts | 363 ++++++++++-------- .../test/ActivityPayloadProjection.test.ts | 133 ++++++- .../components/chat/MessagesTimeline.test.tsx | 84 +++- .../src/components/chat/MessagesTimeline.tsx | 195 +++++++++- apps/web/src/session-logic.test.ts | 76 +++- apps/web/src/session-logic.ts | 12 +- 6 files changed, 672 insertions(+), 191 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9b..6d7ed2391178 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -18,6 +18,175 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +export const MAX_PROJECTED_TOOL_RESULT_CHARS = 50_000; + +const PROJECTED_TOOL_RESULT_TRUNCATION_MARKER = "…[truncated]"; + +type ValuePath = ReadonlyArray; + +function replaceStringAtPath(value: unknown, path: ValuePath, replacement: string): unknown { + if (path.length === 0) { + return replacement; + } + const [head, ...tail] = path; + if (typeof head === "number" && Array.isArray(value)) { + return value.map((entry, index) => + index === head ? replaceStringAtPath(entry, tail, replacement) : entry, + ); + } + const record = asRecord(value); + if (typeof head === "string" && record) { + return { + ...record, + [head]: replaceStringAtPath(record[head], tail, replacement), + }; + } + return value; +} + +function findDominantTextPath( + value: unknown, + path: ValuePath = [], +): { readonly path: ValuePath; readonly text: string } | null { + if (typeof value === "string") { + return { path, text: value }; + } + + let dominant: { readonly path: ValuePath; readonly text: string } | null = null; + const entries: ReadonlyArray = Array.isArray(value) + ? value.map((entry, index) => [index, entry] as const) + : Object.entries(asRecord(value) ?? {}); + for (const [key, entry] of entries) { + const candidate = findDominantTextPath(entry, [...path, key]); + if (candidate && (!dominant || candidate.text.length > dominant.text.length)) { + dominant = candidate; + } + } + return dominant; +} + +function truncateTextInValue( + value: unknown, + path: ValuePath, + text: string, + serializedTotal: string, +): unknown | null { + let keep = Math.max( + 0, + text.length - + (serializedTotal.length - MAX_PROJECTED_TOOL_RESULT_CHARS) - + PROJECTED_TOOL_RESULT_TRUNCATION_MARKER.length, + ); + let candidate = replaceStringAtPath( + value, + path, + `${text.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`, + ); + + let serializedCandidate: string | undefined; + try { + serializedCandidate = JSON.stringify(candidate); + } catch { + return null; + } + if ( + serializedCandidate !== undefined && + serializedCandidate.length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ) { + return candidate; + } + + const remainingOvershoot = + (serializedCandidate?.length ?? MAX_PROJECTED_TOOL_RESULT_CHARS + 1) - + MAX_PROJECTED_TOOL_RESULT_CHARS; + keep = Math.max(0, keep - remainingOvershoot); + candidate = replaceStringAtPath( + value, + path, + `${text.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`, + ); + try { + serializedCandidate = JSON.stringify(candidate); + } catch { + return null; + } + return serializedCandidate !== undefined && + serializedCandidate.length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ? candidate + : null; +} + +function truncateSerializedValue(serialized: string): string { + const serializedTotal = JSON.stringify(serialized); + const keep = Math.max( + 0, + serialized.length - + (serializedTotal.length - MAX_PROJECTED_TOOL_RESULT_CHARS) - + PROJECTED_TOOL_RESULT_TRUNCATION_MARKER.length, + ); + const candidate = `${serialized.slice(0, keep)}${PROJECTED_TOOL_RESULT_TRUNCATION_MARKER}`; + return JSON.stringify(candidate).length <= MAX_PROJECTED_TOOL_RESULT_CHARS + ? candidate + : PROJECTED_TOOL_RESULT_TRUNCATION_MARKER; +} + +function capProjectedToolValue(value: unknown): { + readonly value: unknown; + readonly truncated: boolean; +} { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + return { value, truncated: false }; + } + if (serialized === undefined || serialized.length <= MAX_PROJECTED_TOOL_RESULT_CHARS) { + return { value, truncated: false }; + } + + const dominant = findDominantTextPath(value); + const truncatedValue = dominant + ? truncateTextInValue(value, dominant.path, dominant.text, serialized) + : null; + if (truncatedValue !== null) { + return { value: truncatedValue, truncated: true }; + } + + return { + value: truncateSerializedValue(serialized), + truncated: true, + }; +} + +const OUTPUT_TOOL_FIELD_KEYS = new Set(["result", "rawOutput", "state"]); + +function capToolFields( + source: Record, + target: Record, + options: { + readonly capKeys: ReadonlyArray; + readonly copyKeys: ReadonlyArray; + }, +): boolean { + let outputTruncated = false; + for (const key of options.copyKeys) { + if (key in source) { + target[key] = source[key]; + } + } + for (const key of options.capKeys) { + if (!(key in source)) { + continue; + } + const capped = capProjectedToolValue(source[key]); + target[key] = capped.value; + if (OUTPUT_TOOL_FIELD_KEYS.has(key)) { + outputTruncated ||= capped.truncated; + } + } + return outputTruncated; +} + function pushChangedFile(target: string[], seen: Set, value: unknown): void { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { @@ -80,54 +249,30 @@ function collectChangedFiles( } } -function projectCommandData(data: Record): Record | undefined { +function projectCommandData(data: Record): { + readonly item: Record | undefined; + readonly resultTruncated: boolean; +} { const item = asRecord(data.item); if (!item) { - return undefined; + return { item: undefined, resultTruncated: false }; } const projectedItem: Record = {}; - if ("command" in item) { - projectedItem.command = item.command; - } - - const input = asRecord(item.input); - if (input && "command" in input) { - projectedItem.input = { command: input.command }; - } - - const result = asRecord(item.result); - if (result && "command" in result) { - projectedItem.result = { command: result.command }; - } - - return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; -} - -function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); - if (line.length > 0) { - lines.push(line); - } - } + const resultTruncated = capToolFields(item, projectedItem, { + capKeys: ["toolName", "input", "result"], + copyKeys: ["command"], + }); - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return { + item: Object.keys(projectedItem).length > 0 ? projectedItem : undefined, + resultTruncated, + }; } /** - * 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. + * Fields of an MCP tool-call item clients use for identity and presentation. + * Result content is retained separately under the tool-output cap. */ const MCP_ITEM_KEPT_FIELDS = [ "type", @@ -141,122 +286,37 @@ const MCP_ITEM_KEPT_FIELDS = [ "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 = {}; + let resultTruncated = false; 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; - } + resultTruncated ||= capToolFields(item, projectedItem, { + capKeys: ["result"], + copyKeys: MCP_ITEM_KEPT_FIELDS, + }); 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; - } + resultTruncated ||= capToolFields(data, projectedData, { + capKeys: item + ? ["toolName", "input", "rawOutput", "state"] + : ["toolName", "input", "result", "rawOutput", "state"], + copyKeys: ["tool", "toolCallId", "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) { - return undefined; + if (resultTruncated) { + projectedData.resultTruncated = true; } - if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) { - return { - totalFiles: rawOutput.totalFiles, - ...(rawOutput.truncated === true ? { truncated: true } : {}), - }; - } - - const content = asTrimmedString(rawOutput.content); - if (content) { - const summary = summarizeToolTextOutput(content); - return summary ? { content: summary } : undefined; - } - - const stdout = asTrimmedString(rawOutput.stdout); - if (stdout) { - const summary = summarizeToolTextOutput(stdout); - return summary ? { content: summary } : undefined; - } - - return undefined; + return projectedData; } /** @@ -283,13 +343,20 @@ export function projectActivityPayload( } const projectedData: Record = {}; - const item = projectCommandData(data); + let resultTruncated = false; + const projectedCommandData = projectCommandData(data); + const item = projectedCommandData.item; if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; - } + resultTruncated ||= projectedCommandData.resultTruncated; + const itemResultWasRetained = item !== undefined && "result" in item; + resultTruncated ||= capToolFields(data, projectedData, { + capKeys: itemResultWasRetained + ? ["toolName", "input", "rawOutput", "state"] + : ["toolName", "input", "result", "rawOutput", "state"], + copyKeys: ["command", "tool", "toolCallId", "kind"], + }); const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); @@ -298,16 +365,8 @@ export function projectActivityPayload( projectedData.files = changedFiles.map((path) => ({ path })); } - if ("toolCallId" in data) { - projectedData.toolCallId = data.toolCallId; - } - if ("kind" in data) { - projectedData.kind = data.kind; - } - - const rawOutput = projectRawOutput(data.rawOutput); - if (rawOutput) { - projectedData.rawOutput = rawOutput; + if (resultTruncated) { + projectedData.resultTruncated = true; } return { diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 49f1b532a53a..1d9d47ad293d 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -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, @@ -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: { @@ -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", { @@ -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", @@ -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", + }, }, }); @@ -184,7 +205,7 @@ describe("projectActivityPayload", () => { }); }); - it("slims MCP tool data to the fields the expanded row renders", () => { + it("slims MCP tool data while retaining its full result", () => { expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ itemType: "mcp_tool_call", title: "mcp_tool_call", @@ -196,27 +217,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; + const data = payload.data as Record; + 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; + const data = payload.data as Record; + + 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 = { content: "live output" }; + circular.self = circular; + + const projected = projectActivityPayload( + makeActivity("circular", "command_execution", { result: circular }), + ); + const payload = projected.payload as Record; + const data = payload.data as Record; + + 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) + .data as Record; + const mcpData = (projectActivityPayload(fixtures[4]!).payload as Record) + .data as Record; + + 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; + const data = payload.data as Record; + + 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])); } }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f22130906ebf..330d6c30e97e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,6 +134,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let buildToolCallExpandedBody: typeof import("./MessagesTimeline").buildToolCallExpandedBody; beforeAll(async () => { const classList = { @@ -167,7 +168,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, buildToolCallExpandedBody } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +227,87 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("extracts full provider tool output for expanded work rows", () => { + const makeEntry = (toolData: unknown) => ({ + id: "work-output", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool" as const, + itemType: "command_execution" as const, + toolData, + }); + + expect( + buildToolCallExpandedBody( + makeEntry({ + result: { + type: "tool_result", + content: [ + { type: "text", text: "first Claude line" }, + { type: "text", text: "second Claude line" }, + ], + is_error: true, + }, + resultTruncated: true, + }), + undefined, + ), + ).toContain("Error output\nfirst Claude line\nsecond Claude line\n\nOutput truncated"); + expect( + buildToolCallExpandedBody( + makeEntry({ rawOutput: { stdout: "ACP stdout\nsecond line" } }), + undefined, + ), + ).toContain("Output\nACP stdout\nsecond line"); + expect( + buildToolCallExpandedBody( + makeEntry({ tool: "bash", state: { status: "completed", output: "OpenCode output" } }), + undefined, + ), + ).toContain("Output\nOpenCode output"); + expect( + buildToolCallExpandedBody(makeEntry({ result: { values: [1, 2] } }), undefined), + ).toContain('"values": ['); + }); + + it("uses the first tool result candidate with renderable text", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-priority", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { command: "pnpm test", exitCode: 0 }, + rawOutput: { stdout: "real stdout" }, + state: { status: "error", error: "unrelated state error" }, + }, + }, + undefined, + ); + + expect(body).toContain("Output\nreal stdout"); + expect(body).not.toContain("Error output"); + }); + + it("treats a null state error as successful output", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-null-error", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "dynamic_tool_call", + toolData: { state: { status: "completed", error: null, output: "successful output" } }, + }, + undefined, + ); + + expect(body).toContain("Output\nsuccessful output"); + expect(body).not.toContain("Error output"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c6e28dcef5c5..a8248114355e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2035,13 +2035,154 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } -function buildToolCallExpandedBody( +function asToolDataRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function prettyPrintToolValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} + +function extractKnownToolText(value: unknown): string | null { + if (typeof value === "string") { + return value.trim().length > 0 ? value : null; + } + if (Array.isArray(value)) { + const texts = value + .map((entry) => extractKnownToolText(entry)) + .filter((entry): entry is string => entry !== null); + return texts.length > 0 ? texts.join("\n") : null; + } + const record = asToolDataRecord(value); + if (!record) { + return null; + } + for (const key of [ + "content", + "stdout", + "output", + "aggregatedOutput", + "text", + "diff", + "patch", + "error", + "message", + ]) { + if (!(key in record)) { + continue; + } + const text = extractKnownToolText(record[key]); + if (text) { + return text; + } + } + return null; +} + +function extractToolResultOutput(toolData: unknown): { + readonly text: string; + readonly isError: boolean; + readonly truncated: boolean; +} | null { + const data = asToolDataRecord(toolData); + if (!data) { + return null; + } + const item = asToolDataRecord(data.item); + const rawOutput = data.rawOutput; + const state = data.state; + const result = data.result ?? item?.result; + const content = data.content; + const candidates = [ + { kind: "result", value: result }, + { kind: "rawOutput", value: rawOutput }, + { kind: "state", value: state }, + { kind: "content", value: content }, + ] as const; + for (const candidate of candidates) { + const text = extractKnownToolText(candidate.value); + if (!text) { + continue; + } + const record = asToolDataRecord(candidate.value); + return { + text, + isError: + candidate.kind === "result" + ? record?.is_error === true + : candidate.kind === "state" + ? record?.status === "error" || record?.error != null + : false, + truncated: data.resultTruncated === true, + }; + } + + const fallback = result ?? content; + if (fallback === undefined || fallback === null) { + return null; + } + const text = prettyPrintToolValue(fallback); + if (text.trim().length === 0) { + return null; + } + return { + text, + isError: fallback === result && asToolDataRecord(result)?.is_error === true, + truncated: data.resultTruncated === true, + }; +} + +function toolDataHasExpandedBody(toolData: unknown): boolean { + const data = asToolDataRecord(toolData); + if (!data) { + return false; + } + const item = asToolDataRecord(data.item); + return ( + (item !== null && "result" in item && item.result != null) || + ["result", "rawOutput", "state", "content"].some((key) => key in data && data[key] != null) + ); +} + +function mcpToolCallMetadata(toolData: unknown): unknown { + const data = asToolDataRecord(toolData); + const source = asToolDataRecord(data?.item) ?? data; + if (!source) { + return toolData; + } + const { + result: _result, + rawOutput: _rawOutput, + state: _state, + resultTruncated: _resultTruncated, + ...metadata + } = source; + return metadata; +} + +export function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { const blocks: string[] = []; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { - blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); + blocks.push(`MCP call\n${prettyPrintToolValue(mcpToolCallMetadata(workEntry.toolData))}`); + } + const resultOutput = extractToolResultOutput(workEntry.toolData); + if (resultOutput) { + blocks.push(`${resultOutput.isError ? "Error output" : "Output"}\n${resultOutput.text}`); + if (resultOutput.truncated) { + blocks.push("Output truncated"); + } } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { @@ -2063,6 +2204,41 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +function workEntryHasExpandedBody(workEntry: TimelineWorkEntry): boolean { + return ( + (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || + toolDataHasExpandedBody(workEntry.toolData) || + Boolean(workEntry.command?.trim()) || + Boolean(workEntry.detail?.trim()) || + (workEntry.changedFiles?.length ?? 0) > 0 + ); +} + +const WorkEntryExpandedBody = memo(function WorkEntryExpandedBody(props: { + workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; +}) { + const { workEntry, workspaceRoot } = props; + const expandedBody = useMemo( + () => buildToolCallExpandedBody(workEntry, workspaceRoot), + [workEntry, workspaceRoot], + ); + if (!expandedBody) { + return null; + } + return ( +
+
+        {expandedBody}
+      
+
+ ); +}); + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2242,8 +2418,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? null : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; - const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); - const canExpand = expandedBody !== null; + const canExpand = workEntryHasExpandedBody(workEntry); const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -2367,16 +2542,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { - {expanded && canExpand && expandedBody ? ( -
-
-            {expandedBody}
-          
-
+ {expanded && canExpand ? ( + ) : null} ); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f5effff6602c..22a51ab5c3a7 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -989,7 +989,7 @@ describe("deriveWorkLogEntries", () => { const [entry] = deriveWorkLogEntries(activities); expect(entry?.toolTitle).toBe("t3-code · preview_status"); - expect(entry?.toolData).toEqual(item); + expect(entry?.toolData).toEqual({ item }); }); it("keeps MCP payloads while collapsing lifecycle updates", () => { @@ -1023,7 +1023,79 @@ describe("deriveWorkLogEntries", () => { ]; const [entry] = deriveWorkLogEntries(activities); - expect(entry?.toolData).toEqual(item); + expect(entry?.toolData).toEqual({ item }); + }); + + it("keeps the latest non-MCP tool result while collapsing lifecycle updates", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-progress", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-1", + rawOutput: { stdout: "partial output" }, + }, + }, + }), + makeActivity({ + id: "command-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-1", + rawOutput: { stdout: "full output" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual({ + toolCallId: "command-1", + rawOutput: { stdout: "full output" }, + }); + }); + + it("keeps rich update tool data when the completion payload is sparse", () => { + const updateToolData = { + toolCallId: "command-sparse", + rawOutput: { stdout: "complete output from update" }, + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-sparse-progress", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: updateToolData, + }, + }), + makeActivity({ + id: "command-sparse-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + toolCallId: "command-sparse", + toolName: "Bash", + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual(updateToolData); }); it("unwraps PowerShell command wrappers for displayed command text", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..c940d690a3bc 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -862,10 +862,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } - if (itemType === "mcp_tool_call") { + if (itemType) { const data = asRecord(payload?.data); - if (data?.item !== undefined) { - entry.toolData = data.item; + if (data) { + entry.toolData = data; } } if (itemType) { @@ -1042,7 +1042,11 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; - const toolData = next.toolData ?? previous.toolData; + const nextToolData = asRecord(next.toolData); + const nextToolDataHasContent = + nextToolData !== null && + ["item", "result", "rawOutput", "state"].some((key) => key in nextToolData); + const toolData = nextToolDataHasContent ? next.toolData : previous.toolData; return { ...previous, ...next, From 9602f12a551f7b1cdcb342fb9852bedb757ad1f4 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Sun, 9 Aug 2026 17:37:56 -0500 Subject: [PATCH 2/6] feat(web): polish expanded tool output and add copy actions Expanded tool rows repeated the command as both raw-command and detail blocks under an Output label, the copy button lived inside the scroll container (spawning a phantom scrollbar on short output and scrolling out of reach on long output), and copied commands carried the Bash: detail prefix. Dedupe blocks against the row text, scroll the pre itself with copy buttons anchored outside it, extract the copy button from ChatMarkdown into a shared CopyTextButton used for output and, on command rows, the raw command, and prefer the structured input.command over the prefixed detail string. Claude Fable 5 (Claude Code) + GPT-5.6 (Codex CLI) --- apps/web/src/components/ChatMarkdown.tsx | 79 ++------ .../components/chat/MessagesTimeline.test.tsx | 183 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 183 ++++++++++++------ .../src/components/ui/copy-text-button.tsx | 70 +++++++ apps/web/src/session-logic.test.ts | 31 +++ apps/web/src/session-logic.ts | 12 +- 6 files changed, 427 insertions(+), 131 deletions(-) create mode 100644 apps/web/src/components/ui/copy-text-button.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b5d33facc964..e640060f29dd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -48,6 +48,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"; @@ -120,7 +121,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); } @@ -549,49 +553,8 @@ function MarkdownCodeBlock({ theme: "light" | "dark"; children: ReactNode; }) { - const [copied, setCopied] = useState(false); const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const copiedTimerRef = useRef | 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 (
{wrapLabel} - - - } - > - {copied ? : } - - {copyLabel} - + { + reportMarkdownActionFailure( + { + operation: "copy-code-block", + language, + ...(fenceTitle ? { fenceTitle } : {}), + }, + cause, + ); + }} + />
{children} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 330d6c30e97e..4de5f6386509 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -125,6 +125,14 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../ui/copy-text-button", () => ({ + CopyTextButton: ({ text, label }: { text: string; label: string }) => ( + + ), +})); + function matchMedia() { return { matches: false, @@ -135,6 +143,7 @@ function matchMedia() { let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; let buildToolCallExpandedBody: typeof import("./MessagesTimeline").buildToolCallExpandedBody; +let WorkEntryExpandedBody: typeof import("./MessagesTimeline").WorkEntryExpandedBody; beforeAll(async () => { const classList = { @@ -168,7 +177,8 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline, buildToolCallExpandedBody } = await import("./MessagesTimeline")); + ({ MessagesTimeline, WorkEntryExpandedBody, buildToolCallExpandedBody } = + await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -252,22 +262,49 @@ describe("MessagesTimeline", () => { }), undefined, ), - ).toContain("Error output\nfirst Claude line\nsecond Claude line\n\nOutput truncated"); + ).toEqual({ + blocks: [ + { + kind: "output", + text: "first Claude line\nsecond Claude line", + isError: true, + truncated: true, + }, + ], + }); expect( buildToolCallExpandedBody( makeEntry({ rawOutput: { stdout: "ACP stdout\nsecond line" } }), undefined, ), - ).toContain("Output\nACP stdout\nsecond line"); + ).toEqual({ + blocks: [ + { + kind: "output", + text: "ACP stdout\nsecond line", + isError: false, + truncated: false, + }, + ], + }); expect( buildToolCallExpandedBody( makeEntry({ tool: "bash", state: { status: "completed", output: "OpenCode output" } }), undefined, ), - ).toContain("Output\nOpenCode output"); + ).toEqual({ + blocks: [ + { + kind: "output", + text: "OpenCode output", + isError: false, + truncated: false, + }, + ], + }); expect( buildToolCallExpandedBody(makeEntry({ result: { values: [1, 2] } }), undefined), - ).toContain('"values": ['); + ).toMatchObject({ blocks: [{ kind: "output", text: expect.stringContaining('"values": [') }] }); }); it("uses the first tool result candidate with renderable text", () => { @@ -287,8 +324,9 @@ describe("MessagesTimeline", () => { undefined, ); - expect(body).toContain("Output\nreal stdout"); - expect(body).not.toContain("Error output"); + expect(body).toEqual({ + blocks: [{ kind: "output", text: "real stdout", isError: false, truncated: false }], + }); }); it("treats a null state error as successful output", () => { @@ -304,8 +342,135 @@ describe("MessagesTimeline", () => { undefined, ); - expect(body).toContain("Output\nsuccessful output"); - expect(body).not.toContain("Error output"); + expect(body).toEqual({ + blocks: [{ kind: "output", text: "successful output", isError: false, truncated: false }], + }); + }); + + it("deduplicates expanded blocks already visible in the row", () => { + const command = 'Bash: uname -a && echo "---" && date'; + const body = buildToolCallExpandedBody( + { + id: "work-output-deduplicated", + createdAt: MESSAGE_CREATED_AT, + label: command, + tone: "tool", + itemType: "command_execution", + command, + rawCommand: command, + detail: command, + toolData: { rawOutput: { stdout: "Linux example output" } }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "Linux example output", isError: false, truncated: false }], + copyableCommand: command, + }); + expect( + buildToolCallExpandedBody( + { + id: "work-output-fully-deduplicated", + createdAt: MESSAGE_CREATED_AT, + label: command, + tone: "tool", + itemType: "command_execution", + command, + detail: command, + }, + undefined, + ), + ).toBeNull(); + }); + + it("labels only error output and retains the truncation notice", () => { + const normalBody = buildToolCallExpandedBody( + { + id: "work-output-normal-label", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { rawOutput: { stdout: "normal text" } }, + }, + undefined, + ); + const errorBody = buildToolCallExpandedBody( + { + id: "work-output-error-label", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { content: "failed text", is_error: true }, + resultTruncated: true, + }, + }, + undefined, + ); + expect(normalBody).not.toBeNull(); + expect(errorBody).not.toBeNull(); + + const normalMarkup = renderToStaticMarkup(); + const errorMarkup = renderToStaticMarkup(); + + expect(normalMarkup).toContain("normal text"); + expect(normalMarkup).not.toContain("Output\nnormal text"); + expect(errorMarkup).toContain("Error output\nfailed text\n\nOutput truncated"); + }); + + it("passes only the raw extracted output to the copy button", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-copy", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "short preview", + rawCommand: "a much longer command", + detail: "auxiliary detail", + toolData: { rawOutput: { stdout: "raw output text" } }, + }, + undefined, + ); + expect(body).toEqual({ + blocks: [ + { kind: "output", text: "raw output text", isError: false, truncated: false }, + { kind: "text", text: "a much longer command" }, + { kind: "text", text: "auxiliary detail" }, + ], + copyableCommand: "a much longer command", + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-copy-text="raw output text"'); + expect(markup).toContain("Copy output"); + expect(markup).toContain('data-copy-text="a much longer command"'); + expect(markup).toContain("Copy command"); + expect(markup).not.toContain('data-copy-text="auxiliary detail"'); + }); + + it("offers command copy only for command executions", () => { + const body = buildToolCallExpandedBody( + { + id: "work-output-search", + createdAt: MESSAGE_CREATED_AT, + label: "Web search", + tone: "tool", + itemType: "web_search", + command: "some query", + toolData: { rawOutput: { content: "search results" } }, + }, + undefined, + ); + expect(body?.copyableCommand).toBeUndefined(); + + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("Copy command"); }); it("uses the larger leading inset only when the top fade is enabled", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a8248114355e..613e7a0afd31 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,7 +44,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; -import ChatMarkdown from "../ChatMarkdown"; +import ChatMarkdown, { reportMarkdownActionFailure } from "../ChatMarkdown"; import { BotIcon, CheckIcon, @@ -66,6 +66,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { CopyTextButton } from "../ui/copy-text-button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; @@ -2025,6 +2026,22 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } +function workEntryDisplayText(workEntry: TimelineWorkEntry, workspaceRoot: string | undefined) { + const heading = toolWorkEntryHeading(workEntry); + const rawPreview = workEntryPreview(workEntry, workspaceRoot); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; + return { + heading, + preview, + displayText: preview ? `${heading} - ${preview}` : heading, + }; +} + function workEntryRawCommand( workEntry: Pick, ): string | null { @@ -2141,18 +2158,6 @@ function extractToolResultOutput(toolData: unknown): { }; } -function toolDataHasExpandedBody(toolData: unknown): boolean { - const data = asToolDataRecord(toolData); - if (!data) { - return false; - } - const item = asToolDataRecord(data.item); - return ( - (item !== null && "result" in item && item.result != null) || - ["result", "rawOutput", "state", "content"].some((key) => key in data && data[key] != null) - ); -} - function mcpToolCallMetadata(toolData: unknown): unknown { const data = asToolDataRecord(toolData); const source = asToolDataRecord(data?.item) ?? data; @@ -2169,72 +2174,132 @@ function mcpToolCallMetadata(toolData: unknown): unknown { return metadata; } +type ToolCallExpandedBodyBlock = + | { + readonly kind: "output"; + readonly text: string; + readonly isError: boolean; + readonly truncated: boolean; + } + | { readonly kind: "text"; readonly text: string }; + +export interface ToolCallExpandedBody { + readonly blocks: readonly ToolCallExpandedBodyBlock[]; + readonly copyableCommand?: string; +} + export function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, -): string | null { - const blocks: string[] = []; +): ToolCallExpandedBody | null { + const { heading, preview, displayText } = workEntryDisplayText(workEntry, workspaceRoot); + const seen = new Set( + [heading, preview, displayText] + .filter((value): value is string => value !== null) + .map((value) => value.trim()) + .filter(Boolean), + ); + const blocks: ToolCallExpandedBodyBlock[] = []; + const addTextBlock = (text: string) => { + const trimmed = text.trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + blocks.push({ kind: "text", text: trimmed }); + }; if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { - blocks.push(`MCP call\n${prettyPrintToolValue(mcpToolCallMetadata(workEntry.toolData))}`); + addTextBlock(`MCP call\n${prettyPrintToolValue(mcpToolCallMetadata(workEntry.toolData))}`); } const resultOutput = extractToolResultOutput(workEntry.toolData); if (resultOutput) { - blocks.push(`${resultOutput.isError ? "Error output" : "Output"}\n${resultOutput.text}`); - if (resultOutput.truncated) { - blocks.push("Output truncated"); + const trimmed = resultOutput.text.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + if (resultOutput.truncated) { + seen.add("Output truncated"); + } + blocks.push({ kind: "output", ...resultOutput }); } } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { - blocks.push(raw.trim()); + addTextBlock(raw); } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); + addTextBlock(workEntry.command); } if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + addTextBlock(workEntry.detail); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { - blocks.push( + addTextBlock( changedFiles .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) .join("\n"), ); } - return blocks.length > 0 ? blocks.join("\n\n") : null; -} - -function workEntryHasExpandedBody(workEntry: TimelineWorkEntry): boolean { - return ( - (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || - toolDataHasExpandedBody(workEntry.toolData) || - Boolean(workEntry.command?.trim()) || - Boolean(workEntry.detail?.trim()) || - (workEntry.changedFiles?.length ?? 0) > 0 - ); + if (blocks.length === 0) { + return null; + } + const copyableCommand = + workEntry.itemType === "command_execution" + ? (workEntry.rawCommand ?? workEntry.command)?.trim() || undefined + : undefined; + return copyableCommand ? { blocks, copyableCommand } : { blocks }; } -const WorkEntryExpandedBody = memo(function WorkEntryExpandedBody(props: { - workEntry: TimelineWorkEntry; - workspaceRoot: string | undefined; +export const WorkEntryExpandedBody = memo(function WorkEntryExpandedBody(props: { + body: ToolCallExpandedBody; }) { - const { workEntry, workspaceRoot } = props; - const expandedBody = useMemo( - () => buildToolCallExpandedBody(workEntry, workspaceRoot), - [workEntry, workspaceRoot], - ); - if (!expandedBody) { - return null; - } + const { blocks, copyableCommand } = props.body; return (
-
-        {expandedBody}
-      
+ {blocks.map((block) => + block.kind === "output" ? ( +
+
+              {block.isError ? "Error output\n" : null}
+              {block.text}
+              {block.truncated ? "\n\nOutput truncated" : null}
+            
+ + {copyableCommand ? ( + } + onCopyError={(cause) => { + reportMarkdownActionFailure({ operation: "copy-tool-command" }, cause); + }} + /> + ) : null} + { + reportMarkdownActionFailure({ operation: "copy-tool-output" }, cause); + }} + /> + +
+ ) : ( +
+            {block.text}
+          
+ ), + )}
); }); @@ -2409,16 +2474,12 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; - const canExpand = workEntryHasExpandedBody(workEntry); + const { heading, preview, displayText } = workEntryDisplayText(workEntry, workspaceRoot); + const expandedBody = useMemo( + () => buildToolCallExpandedBody(workEntry, workspaceRoot), + [workEntry, workspaceRoot], + ); + const canExpand = expandedBody !== null; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -2542,9 +2603,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { - {expanded && canExpand ? ( - - ) : null} + {expanded && expandedBody ? : null} ); }); diff --git a/apps/web/src/components/ui/copy-text-button.tsx b/apps/web/src/components/ui/copy-text-button.tsx new file mode 100644 index 000000000000..3ead8c4542e7 --- /dev/null +++ b/apps/web/src/components/ui/copy-text-button.tsx @@ -0,0 +1,70 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { cn } from "~/lib/utils"; +import { Button } from "./button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip"; + +export const CopyTextButton = memo(function CopyTextButton(props: { + text: string; + label: string; + copiedLabel?: string; + className?: string; + icon?: ReactNode; + onCopyError?: (cause: unknown) => void; +}) { + const { text, label, copiedLabel = "Copied", className, icon, onCopyError } = props; + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + const currentLabel = copied ? copiedLabel : label; + + const handleCopy = useCallback(() => { + if (typeof navigator === "undefined" || navigator.clipboard == null) { + return; + } + void navigator.clipboard + .writeText(text) + .then(() => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }) + .catch((cause) => { + onCopyError?.(cause); + }); + }, [onCopyError, text]); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + }, + [], + ); + + return ( + + + } + > + {copied ? : (icon ?? )} + + {currentLabel} + + ); +}); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 22a51ab5c3a7..f1ea28dda0ae 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -928,6 +928,37 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); + it("prefers the structured input command and never the Bash-prefixed detail", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "claude-command-tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "Bash: uname -a && date", + data: { + toolName: "Bash", + input: { command: "uname -a && date" }, + }, + }, + }), + makeActivity({ + id: "detail-only-command-tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: "Bash: ls -la ", + }, + }), + ]; + + const [structured, detailOnly] = deriveWorkLogEntries(activities); + expect(structured?.command).toBe("uname -a && date"); + expect(detailOnly?.command).toBe("ls -la"); + }); + it("extracts failed tool lifecycle status from item payloads", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c940d690a3bc..26346c2815a8 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1287,14 +1287,18 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ item?.command, itemInput?.command, + dataInput?.command, itemResult?.command, data?.command, - itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, + itemType === "command_execution" && detail + ? stripToolNamePrefix(stripTrailingExitCode(detail).output) + : null, ]; for (const candidate of candidates) { @@ -1426,6 +1430,12 @@ function extractToolDetail( return null; } +// Claude's command_execution detail is "Bash: "; the prefix must not +// leak into the extracted command (previews, copy-command). +function stripToolNamePrefix(value: string | null): string | null { + return value ? value.replace(/^bash:\s+/i, "") : value; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; From 55ebdec7e85a2578c67433ba6c07fa2cf80057c8 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 11:17:52 -0500 Subject: [PATCH 3/6] feat(web): refine tool row copy actions and command visibility Copy buttons crowded the output block's corner and revealed inconsistently, long commands were ellipsized with no way to read them in full, and the CSS ellipsis floated detached from the clipped text at most zoom levels. Limit expanded bodies to output only with a "(No output)" placeholder, unfurl the full header command while a row is expanded, reveal both copy actions on row hover with the copy button below the output box, and fade truncated previews with a mask instead of text-overflow ellipsis. Claude Fable 5 (Claude Code) + GPT-5.6 (Codex CLI) --- .../components/chat/MessagesTimeline.test.tsx | 96 +++++++++++++-- .../src/components/chat/MessagesTimeline.tsx | 116 ++++++++++++------ apps/web/src/index.css | 8 ++ 3 files changed, 178 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4de5f6386509..03a563e60e01 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -381,9 +381,93 @@ describe("MessagesTimeline", () => { }, undefined, ), + ).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: command, + }); + }); + + it("keeps command and detail text out of command execution bodies", () => { + const body = buildToolCallExpandedBody( + { + id: "work-raw-command-visible", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "short preview", + rawCommand: "the complete raw command", + detail: "short preview", + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: "the complete raw command", + }); + expect(body?.blocks).not.toContainEqual(expect.objectContaining({ kind: "text" })); + }); + + it("keeps command and detail blocks for non-command entries", () => { + const body = buildToolCallExpandedBody( + { + id: "work-mcp-call", + createdAt: MESSAGE_CREATED_AT, + label: "MCP call", + tone: "tool", + itemType: "mcp_tool_call", + command: "short preview", + rawCommand: "the complete raw command", + detail: "README.md contents", + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [ + { kind: "text", text: "the complete raw command" }, + { kind: "text", text: "README.md contents" }, + ], + }); + expect( + buildToolCallExpandedBody( + { + id: "work-empty-non-command", + createdAt: MESSAGE_CREATED_AT, + label: "Web search", + tone: "tool", + itemType: "web_search", + }, + undefined, + ), ).toBeNull(); }); + it("keeps command executions without output expandable", () => { + const body = buildToolCallExpandedBody( + { + id: "work-command-no-output", + createdAt: MESSAGE_CREATED_AT, + label: "Bash", + tone: "tool", + itemType: "command_execution", + command: "pwd", + }, + undefined, + ); + + expect(body).not.toBeNull(); + expect(body).toEqual({ + blocks: [{ kind: "empty" }], + copyableCommand: "pwd", + }); + + const markup = renderToStaticMarkup(); + expect(markup).toContain("(No output)"); + expect(markup).not.toContain("Copy output"); + }); + it("labels only error output and retains the truncation notice", () => { const normalBody = buildToolCallExpandedBody( { @@ -437,11 +521,7 @@ describe("MessagesTimeline", () => { undefined, ); expect(body).toEqual({ - blocks: [ - { kind: "output", text: "raw output text", isError: false, truncated: false }, - { kind: "text", text: "a much longer command" }, - { kind: "text", text: "auxiliary detail" }, - ], + blocks: [{ kind: "output", text: "raw output text", isError: false, truncated: false }], copyableCommand: "a much longer command", }); @@ -449,8 +529,10 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-copy-text="raw output text"'); expect(markup).toContain("Copy output"); - expect(markup).toContain('data-copy-text="a much longer command"'); - expect(markup).toContain("Copy command"); + expect(markup).not.toContain('data-copy-text="a much longer command"'); + expect(markup).not.toContain("Copy command"); + expect(markup).not.toContain("a much longer command"); + expect(markup).not.toContain("auxiliary detail"); expect(markup).not.toContain('data-copy-text="auxiliary detail"'); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 613e7a0afd31..f22757f90221 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2181,6 +2181,7 @@ type ToolCallExpandedBodyBlock = readonly isError: boolean; readonly truncated: boolean; } + | { readonly kind: "empty" } | { readonly kind: "text"; readonly text: string }; export interface ToolCallExpandedBody { @@ -2200,6 +2201,10 @@ export function buildToolCallExpandedBody( .filter(Boolean), ); const blocks: ToolCallExpandedBodyBlock[] = []; + const copyableCommand = + workEntry.itemType === "command_execution" + ? (workEntry.rawCommand ?? workEntry.command)?.trim() || undefined + : undefined; const addTextBlock = (text: string) => { const trimmed = text.trim(); if (!trimmed || seen.has(trimmed)) { @@ -2211,6 +2216,7 @@ export function buildToolCallExpandedBody( if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { addTextBlock(`MCP call\n${prettyPrintToolValue(mcpToolCallMetadata(workEntry.toolData))}`); } + let hasOutput = false; const resultOutput = extractToolResultOutput(workEntry.toolData); if (resultOutput) { const trimmed = resultOutput.text.trim(); @@ -2220,16 +2226,23 @@ export function buildToolCallExpandedBody( seen.add("Output truncated"); } blocks.push({ kind: "output", ...resultOutput }); + hasOutput = true; } } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - addTextBlock(raw); - } else if (workEntry.command?.trim()) { - addTextBlock(workEntry.command); - } - if (workEntry.detail?.trim()) { - addTextBlock(workEntry.detail); + if (workEntry.itemType === "command_execution") { + if (!hasOutput) { + blocks.push({ kind: "empty" }); + } + } else { + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + addTextBlock(raw); + } else if (workEntry.command?.trim()) { + addTextBlock(workEntry.command); + } + if (workEntry.detail?.trim()) { + addTextBlock(workEntry.detail); + } } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2242,17 +2255,13 @@ export function buildToolCallExpandedBody( if (blocks.length === 0) { return null; } - const copyableCommand = - workEntry.itemType === "command_execution" - ? (workEntry.rawCommand ?? workEntry.command)?.trim() || undefined - : undefined; return copyableCommand ? { blocks, copyableCommand } : { blocks }; } export const WorkEntryExpandedBody = memo(function WorkEntryExpandedBody(props: { body: ToolCallExpandedBody; }) { - const { blocks, copyableCommand } = props.body; + const { blocks } = props.body; return (
{blocks.map((block) => block.kind === "output" ? ( -
-
+          
+
               {block.isError ? "Error output\n" : null}
               {block.text}
               {block.truncated ? "\n\nOutput truncated" : null}
             
- - {copyableCommand ? ( - } - onCopyError={(cause) => { - reportMarkdownActionFailure({ operation: "copy-tool-command" }, cause); - }} - /> - ) : null} +
- +
+ ) : block.kind === "empty" ? ( +

+ (No output) +

) : (
 buildToolCallExpandedBody(workEntry, workspaceRoot),
     [workEntry, workspaceRoot],
   );
   const canExpand = expandedBody !== null;
+  // Expanding the row also unfurls the truncated header command: one gesture
+  // reveals full detail (command + output) without a separate toggle.
+  const commandUnfurled = previewIsCommand && expanded;
   const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
   const showDestructiveRowStyle =
     showFailedIndicator &&
@@ -2522,29 +2528,69 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
   return (
     
-
+
-
+

{heading} {preview && ( - {preview} + + {preview} + )}

+ {expandedBody?.copyableCommand ? ( + { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + > + { + reportMarkdownActionFailure({ operation: "copy-tool-command" }, cause); + }} + /> + + ) : null} Date: Mon, 10 Aug 2026 11:57:34 -0500 Subject: [PATCH 4/6] fix: escape raw control bytes in string separators Composite-key separators were written as literal NUL and unit-separator bytes inside string literals, which made the source files register as binary to grep, ripgrep, and file(1) while still parsing fine. Use the backslash-u0000 and backslash-u001f escapes for identical runtime strings in searchable source. Claude Fable 5 (Claude Code) --- apps/server/src/orchestration/ActivityPayloadProjection.ts | 6 +++--- apps/web/src/session-logic.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 6d7ed2391178..7ea483a546c6 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -453,7 +453,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | if (itemType.length === 0 && label.length === 0 && detail.length === 0) { return null; } - return [itemType, label, detail].join(""); + return [itemType, label, detail].join("\u001f"); } /** @@ -500,7 +500,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -520,7 +520,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 26346c2815a8..325ea47cacc7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1082,7 +1082,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un entry.taskId && (entry.activityKind === "task.progress" || entry.activityKind === "task.completed") ) { - return `task${entry.taskId}`; + return `task\u001f${entry.taskId}`; } if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; From 1fc85758254ee822fb347a23d9e25b0a2a50bcd2 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 12:01:59 -0500 Subject: [PATCH 5/6] fix(web): show Codex provider command output in expanded rows The Codex adapter carries command output in item.aggregatedOutput with exitCode/status alongside, but projectCommandData stripped everything except toolName/input/result/command at the wire boundary and the web extractor never consulted the item, so Codex rows always rendered the (No output) placeholder. Retain aggregatedOutput under the existing 50k cap with the truncation flag plus exitCode/status, and let the web extractor fall back to the item with error state derived from status/exitCode while Claude-shaped result fields keep precedence. Claude Fable 5 (Claude Code) + GPT-5.6 (Codex CLI) --- .../ActivityPayloadProjection.ts | 6 +-- .../test/ActivityPayloadProjection.test.ts | 48 +++++++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 51 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 6 ++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 7ea483a546c6..0c2c6620d885 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -158,7 +158,7 @@ function capProjectedToolValue(value: unknown): { }; } -const OUTPUT_TOOL_FIELD_KEYS = new Set(["result", "rawOutput", "state"]); +const OUTPUT_TOOL_FIELD_KEYS = new Set(["result", "rawOutput", "state", "aggregatedOutput"]); function capToolFields( source: Record, @@ -260,8 +260,8 @@ function projectCommandData(data: Record): { const projectedItem: Record = {}; const resultTruncated = capToolFields(item, projectedItem, { - capKeys: ["toolName", "input", "result"], - copyKeys: ["command"], + capKeys: ["toolName", "input", "result", "aggregatedOutput"], + copyKeys: ["command", "exitCode", "status"], }); return { diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 1d9d47ad293d..103fa16ec376 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -205,6 +205,54 @@ describe("projectActivityPayload", () => { }); }); + 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; + const data = payload.data as Record; + const item = data.item as Record; + + 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", diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 03a563e60e01..b82ef362cade 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -329,6 +329,57 @@ describe("MessagesTimeline", () => { }); }); + it("extracts Codex command output and its error state", () => { + const makeEntry = (exitCode: number) => ({ + id: `work-codex-output-${exitCode}`, + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool" as const, + itemType: "command_execution" as const, + toolData: { + item: { + command: "x", + aggregatedOutput: "hello", + exitCode, + status: "completed", + }, + }, + }); + + expect(buildToolCallExpandedBody(makeEntry(0), undefined)).toEqual({ + blocks: [{ kind: "output", text: "hello", isError: false, truncated: false }], + }); + expect(buildToolCallExpandedBody(makeEntry(1), undefined)).toEqual({ + blocks: [{ kind: "output", text: "hello", isError: true, truncated: false }], + }); + }); + + it("prefers Claude result output over Codex item output", () => { + const body = buildToolCallExpandedBody( + { + id: "work-provider-output-priority", + createdAt: MESSAGE_CREATED_AT, + label: "Tool output", + tone: "tool", + itemType: "command_execution", + toolData: { + result: { content: "Claude output", is_error: false }, + item: { + command: "x", + aggregatedOutput: "Codex output", + exitCode: 1, + status: "failed", + }, + }, + }, + undefined, + ); + + expect(body).toEqual({ + blocks: [{ kind: "output", text: "Claude output", isError: false, truncated: false }], + }); + }); + it("treats a null state error as successful output", () => { const body = buildToolCallExpandedBody( { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f22757f90221..7d398090e38c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2124,6 +2124,7 @@ function extractToolResultOutput(toolData: unknown): { { kind: "rawOutput", value: rawOutput }, { kind: "state", value: state }, { kind: "content", value: content }, + { kind: "item", value: item }, ] as const; for (const candidate of candidates) { const text = extractKnownToolText(candidate.value); @@ -2138,7 +2139,10 @@ function extractToolResultOutput(toolData: unknown): { ? record?.is_error === true : candidate.kind === "state" ? record?.status === "error" || record?.error != null - : false, + : candidate.kind === "item" + ? record?.status === "failed" || + (typeof record?.exitCode === "number" && record.exitCode !== 0) + : false, truncated: data.resultTruncated === true, }; } From 345a4895e84c8ec3be3c016245772c4b8bb6d4e3 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 12:17:39 -0500 Subject: [PATCH 6/6] fix(web): invalidate thread snapshot cache after output projection change The client hydrates threads from an IndexedDB snapshot cache and resumes via afterSequence, so activities cached before the projection retained tool output kept rendering as (No output) through server restarts and page reloads. Bump the stored thread snapshot schema to v4 so pre-fix entries fail to decode and threads re-download once with full payloads. Claude Fable 5 (Claude Code) --- apps/web/src/connection/storage.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 4ae476c1d11f..20315c8c1c82 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -55,8 +55,11 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); // exists for rollback safety: a pre-pagination client would decode a windowed // v2 record, silently drop the unknown `page` field, and treat the partial // thread as complete forever. Older entries fail to decode → cold cache. +// v4 invalidates snapshots cached before the wire projection retained tool +// output fields (result/aggregatedOutput); a warm cache resumes via +// `afterSequence` and would otherwise show stripped payloads forever. const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(3), + schemaVersion: Schema.Literal(4), environmentId: EnvironmentId, threadId: ThreadId, snapshot: OrchestrationThreadDetailSnapshot, @@ -564,7 +567,7 @@ export const connectionStorageLayer = Layer.effectContext( saveThread: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 3, + schemaVersion: 4, environmentId, threadId: snapshot.thread.id, snapshot,