From 19979bb4492ae75005baa7b886c820f182ec28e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 18 Jun 2026 12:15:09 +0100 Subject: [PATCH 01/16] Port file and command activity boxes --- .../chat/MessagesTimeline.logic.test.ts | 25 ++ .../components/chat/MessagesTimeline.logic.ts | 20 + .../components/chat/MessagesTimeline.test.tsx | 128 ++++++ .../src/components/chat/MessagesTimeline.tsx | 392 +++++++++++++++++- apps/web/src/session-logic.test.ts | 291 +++++++++++++ apps/web/src/session-logic.ts | 321 +++++++++++++- 6 files changed, 1164 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 032f863569..11f1a14c64 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3,6 +3,8 @@ import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, + getRenderableCommandOutputLines, + hasRenderableCommandOutput, normalizeCompactToolLabel, resolveAssistantMessageCopyState, } from "./MessagesTimeline.logic"; @@ -204,6 +206,29 @@ describe("resolveAssistantMessageCopyState", () => { }); }); +describe("hasRenderableCommandOutput", () => { + it("hides nullish and empty command output streams", () => { + expect(hasRenderableCommandOutput(undefined)).toBe(false); + expect(hasRenderableCommandOutput(null)).toBe(false); + expect(hasRenderableCommandOutput("")).toBe(false); + }); + + it("renders command output streams when the provider emitted content", () => { + expect(hasRenderableCommandOutput("stdout\n")).toBe(true); + expect(hasRenderableCommandOutput(" ")).toBe(false); + expect(hasRenderableCommandOutput("\n\t\n")).toBe(false); + }); + + it("preserves intentional blank command output lines", () => { + expect(getRenderableCommandOutputLines("\nstdout\n \n\t\nstderr\n")).toEqual([ + "stdout", + " ", + "\t", + "stderr", + ]); + }); +}); + describe("deriveMessagesTimelineRows", () => { it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 416b37e4f5..2a9cf66f56 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -113,6 +113,26 @@ export function resolveAssistantMessageCopyState({ }; } +export function hasRenderableCommandOutput(value: string | null | undefined): value is string { + return getRenderableCommandOutputLines(value).length > 0; +} + +export function getRenderableCommandOutputLines(value: string | null | undefined): string[] { + if (typeof value !== "string" || value.length === 0) { + return []; + } + const lines = value.split(/\r?\n/u); + let startIndex = 0; + let endIndex = lines.length; + while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) { + startIndex += 1; + } + while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) { + endIndex -= 1; + } + return lines.slice(startIndex, endIndex); +} + function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { const lastAssistantMessageIdByResponseKey = new Map(); let nullTurnResponseIndex = 0; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7da222f44..efb676a80f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -83,6 +83,8 @@ beforeAll(() => { documentElement: { classList, offsetHeight: 0, + removeAttribute: () => {}, + setAttribute: () => {}, }, }); }); @@ -257,6 +259,132 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("renders command work entries as expandable rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ran command"); + expect(markup).toContain("vp test"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Ran command - vp test"'); + }); + + it("renders dynamic tool command metadata as expandable command rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Dynamic tool"); + expect(markup).toContain("vp test"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Dynamic tool - vp test"'); + }); + + it("does not render typed non-command stdout as command details", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Web search"); + expect(markup).not.toContain('aria-expanded="false"'); + expect(markup).not.toContain('aria-label="Expand Web search"'); + }); + + it("renders file-change work entries as expandable rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Changed files"); + expect(markup).toContain("apps/web/src/session-logic.ts"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand Changed files - apps/web/src/session-logic.ts"'); + }); + it("renders review comment contexts as structured cards instead of raw tags", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d340f7ac7c..af9645e825 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -22,8 +22,10 @@ import { } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; +import type { FileDiffMetadata, Hunk } from "@pierre/diffs/types"; import { deriveTimelineEntries, + formatDuration, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, @@ -66,6 +68,8 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, + getRenderableCommandOutputLines, + hasRenderableCommandOutput, MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, @@ -140,6 +144,7 @@ const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const COMMAND_OUTPUT_TAIL_LINES = 40; // --------------------------------------------------------------------------- // Props (public API) @@ -1543,6 +1548,375 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } +function ToolDetailBlock(props: { + title: string; + children: ReactNode; + mono?: boolean; + tone?: "default" | "error"; +}) { + return ( +
+

+ {props.title} +

+
+ {props.children} +
+
+ ); +} + +function hasExpandableWorkEntryDetails( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): boolean { + if (hasCommandWorkEntryDetails(workEntry) || hasFileChangeWorkEntryDetails(workEntry)) { + return true; + } + return buildToolCallExpandedBody(workEntry, workspaceRoot) !== null; +} + +function ToolEntryDetails({ + workEntry, + workspaceRoot, +}: { + workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; +}) { + const showCommandDetails = hasCommandWorkEntryDetails(workEntry); + const showFileChangeDetails = hasFileChangeWorkEntryDetails(workEntry); + const supplementalDetails = + showCommandDetails || showFileChangeDetails + ? buildSupplementalToolDetailBody(workEntry, { + dedupeRenderedCommandOutput: showCommandDetails, + }) + : null; + if (showCommandDetails || showFileChangeDetails) { + return ( + <> + {showCommandDetails && } + {showFileChangeDetails && } + {supplementalDetails ? : null} + + ); + } + + const genericDetails = buildToolCallExpandedBody(workEntry, workspaceRoot); + return genericDetails ? : null; +} + +function buildSupplementalToolDetailBody( + workEntry: TimelineWorkEntry, + options: { dedupeRenderedCommandOutput: boolean }, +): string | null { + const detail = workEntry.detail?.trim(); + if (!detail) { + return null; + } + const command = workEntry.command?.trim(); + const rawCommand = workEntry.rawCommand?.trim(); + const renderedOutputMatchesDetail = + options.dedupeRenderedCommandOutput && commandOutputMatchesDetail(workEntry, detail); + if (detail === command || detail === rawCommand || renderedOutputMatchesDetail) { + return null; + } + return detail; +} + +function commandOutputMatchesDetail(workEntry: TimelineWorkEntry, detail: string): boolean { + const hasStreamOutput = + hasRenderableCommandOutput(workEntry.stdout) || hasRenderableCommandOutput(workEntry.stderr); + return [workEntry.stdout, workEntry.stderr, !hasStreamOutput ? workEntry.output : undefined].some( + (value) => getRenderableCommandOutputLines(value).join("\n") === detail, + ); +} + +function hasCommandWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (!hasCommandWorkEntryMetadata(workEntry)) { + return false; + } + if (workEntry.itemType === "command_execution" || workEntry.requestKind === "command") { + return true; + } + if ( + workEntry.itemType === "file_change" || + workEntry.itemType === "collab_agent_tool_call" || + workEntry.requestKind === "file-change" + ) { + return false; + } + if (workEntry.itemType || workEntry.requestKind) { + return workEntry.itemType === "dynamic_tool_call" || workEntry.itemType === "mcp_tool_call"; + } + return Boolean(workEntry.command || workEntry.rawCommand); +} + +function hasCommandWorkEntryMetadata(workEntry: TimelineWorkEntry): boolean { + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); +} + +function hasFileChangeWorkEntryDetails(workEntry: TimelineWorkEntry): boolean { + if (workEntry.itemType === "file_change" || workEntry.requestKind === "file-change") { + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); + } + if (workEntry.itemType === "collab_agent_tool_call") { + return false; + } + return Boolean(workEntry.patch || (workEntry.changedFiles?.length ?? 0) > 0); +} + +function CommandEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + const command = workEntry.command ?? workEntry.rawCommand ?? null; + const rawCommand = + workEntry.rawCommand && workEntry.rawCommand !== command ? workEntry.rawCommand : null; + const hasStreamOutput = + hasRenderableCommandOutput(workEntry.stdout) || hasRenderableCommandOutput(workEntry.stderr); + + return ( +
+ {command && ( + + {command} + + )} + {rawCommand && } +
+ + Exit code {workEntry.exitCode ?? "unknown"} + + + Duration{" "} + {workEntry.durationMs !== undefined ? formatDuration(workEntry.durationMs) : "unknown"} + +
+ {hasRenderableCommandOutput(workEntry.stdout) ? ( + + ) : null} + {hasRenderableCommandOutput(workEntry.stderr) ? ( + + ) : null} + {!hasStreamOutput && hasRenderableCommandOutput(workEntry.output) ? ( + + ) : null} +
+ ); +} + +function CollapsedRawCommandBlock({ value }: { value: string }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded ? ( +
+ {value} +
+ ) : null} +
+ ); +} + +function CommandOutputBlock(props: { title: string; value: string; tone?: "default" | "error" }) { + const [showFull, setShowFull] = useState(false); + const lines = useMemo(() => getRenderableCommandOutputLines(props.value), [props.value]); + const isTruncated = lines.length > COMMAND_OUTPUT_TAIL_LINES; + const toggleLabel = `${showFull ? "Collapse" : "Expand"} ${props.title}`; + const visibleValue = + showFull || !isTruncated + ? lines.join("\n") + : lines.slice(-COMMAND_OUTPUT_TAIL_LINES).join("\n"); + const suffix = isTruncated + ? showFull + ? `${lines.length.toLocaleString()} lines` + : `last ${COMMAND_OUTPUT_TAIL_LINES} of ${lines.length.toLocaleString()} lines` + : `${lines.length.toLocaleString()} line${lines.length === 1 ? "" : "s"}`; + + return ( +
+ + +
+ ); +} + +function FileChangeEntryDetails({ workEntry }: { workEntry: TimelineWorkEntry }) { + const ctx = use(TimelineRowCtx); + const renderablePatch = getRenderablePatch( + workEntry.patch, + `tool-file-change:${workEntry.id}:${ctx.resolvedTheme}`, + ); + const hasInlineDiff = renderablePatch?.kind === "files"; + + return ( +
+ {!hasInlineDiff && (workEntry.changedFiles?.length ?? 0) > 0 && ( +
+ {workEntry.changedFiles?.map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, ctx.workspaceRoot); + return ( + + {displayPath} + + ); + })} +
+ )} + {hasInlineDiff && + renderablePatch.files.map((fileDiff) => ( + ( + + )} + options={{ + collapsed: false, + diffStyle: "unified", + theme: resolveDiffThemeName(ctx.resolvedTheme), + }} + /> + ))} + {renderablePatch?.kind === "raw" && ( + + {renderablePatch.text} + + )} +
+ ); +} + +function GenericToolEntryDetails({ value }: { value: string }) { + return ( +
+
+        {value}
+      
+
+ ); +} + +function InlineFileDiffHeader({ + fileDiff, + changedFiles, + workspaceRoot, +}: { + fileDiff: FileDiffMetadata; + changedFiles: ReadonlyArray | undefined; + workspaceRoot: string | undefined; +}) { + const displayPath = resolveInlineFileDiffDisplayPath(fileDiff, changedFiles, workspaceRoot); + const additions = countDiffHunkChangedLines(fileDiff.hunks, "additionLines"); + const deletions = countDiffHunkChangedLines(fileDiff.hunks, "deletionLines"); + + return ( +
+ + {displayPath} + + + + +
+ ); +} + +function resolveInlineFileDiffDisplayPath( + fileDiff: FileDiffMetadata, + changedFiles: ReadonlyArray | undefined, + workspaceRoot: string | undefined, +): string { + const rawPath = resolveFileDiffPath(fileDiff); + const normalizedRawPath = rawPath.replace(/\\/gu, "/"); + const matchedChangedFile = changedFiles?.find((filePath) => { + const normalizedChangedFile = filePath.replace(/\\/gu, "/"); + return ( + normalizedChangedFile === normalizedRawPath || + normalizedChangedFile.endsWith(`/${normalizedRawPath}`) || + normalizedRawPath.endsWith(`/${normalizedChangedFile.replace(/^\/+/u, "")}`) + ); + }); + + return formatWorkspaceRelativePath(matchedChangedFile ?? rawPath, workspaceRoot); +} + +function countDiffHunkChangedLines( + hunks: ReadonlyArray, + lineCountKey: "additionLines" | "deletionLines", +): number { + let count = 0; + for (const hunk of hunks) { + count += hunk[lineCountKey]; + } + return count; +} + const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { @@ -1564,8 +1938,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? null : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; - const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); - const canExpand = expandedBody !== null; + const canExpand = hasExpandableWorkEntryDetails(workEntry, workspaceRoot); const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -1594,7 +1967,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? { role: "button" as const, tabIndex: 0 as const, - "aria-label": displayText, + "aria-label": `${expanded ? "Collapse" : "Expand"} ${displayText}`, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -1689,15 +2063,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(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 beb40aadff..391ef8d559 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1114,11 +1114,194 @@ describe("deriveWorkLogEntries", () => { expect(entry).toMatchObject({ command: "bun run dev", detail: '{ "dev": "vite dev --port 3000" }', + output: '{ "dev": "vite dev --port 3000" }', + exitCode: 0, itemType: "command_execution", toolTitle: "bash", }); }); + it("keeps command stdout, stderr, exit code, and duration for expanded command details", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + stderr: "warning\n", + exitCode: 1, + durationMs: 1250, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "vp test", + stdout: "line 1\nline 2\n", + stderr: "warning\n", + exitCode: 1, + durationMs: 1250, + }); + }); + + it("uses completed cumulative command output instead of duplicating updated output", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\n", + stderr: "warning 1\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + stderr: "warning 1\nwarning 2\n", + exitCode: 0, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); + }); + + it("concatenates non-matching incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "Error", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "retrying", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Error\nretrying"); + }); + + it("strips fallback stdout exit-code metadata", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-result-stdout-exit-code", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + item: { + command: "node script.js", + result: { + stdout: "done\n", + }, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "node script.js", + stdout: "done", + exitCode: 7, + }); + }); + + it("keeps Codex command execution item output, exit code, and duration", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-command-tool-output", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + data: { + item: { + aggregatedOutput: "stdout ui smoke test\n", + command: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + commandActions: [{ command: "printf 'stdout ui smoke test\\n'", type: "unknown" }], + durationMs: 0, + exitCode: 0, + type: "commandExecution", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "printf 'stdout ui smoke test\\\\n'", + rawCommand: `/opt/homebrew/bin/bash -lc "printf 'stdout ui smoke test\\\\n'"`, + output: "stdout ui smoke test\n", + exitCode: 0, + durationMs: 0, + }); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1146,6 +1329,114 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("keeps file-change patches for inline expanded diff rendering", () => { + const patch = + "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + path: "app.ts", + patch, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.changedFiles).toEqual(["app.ts"]); + expect(entry?.patch).toBe(patch); + }); + + it("normalizes Codex file-change content diffs into unified patches", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { + path: "/Users/example/t3code/SMOKE_TEST_CHANGE.md", + kind: { type: "add" }, + diff: "Smoke test file-change row.\n", + }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.changedFiles).toEqual(["/Users/example/t3code/SMOKE_TEST_CHANGE.md"]); + expect(entry?.patch).toContain( + "diff --git a//Users/example/t3code/SMOKE_TEST_CHANGE.md b//Users/example/t3code/SMOKE_TEST_CHANGE.md", + ); + expect(entry?.patch).toContain("--- /dev/null"); + expect(entry?.patch).toContain("+Smoke test file-change row."); + }); + + it("keeps nested result file-change patches within the traversal budget", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-file-tool-result-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + data: { + item: { + result: { + changes: [ + { + path: "apps/web/src/session-logic.ts", + diff: "@@ -1 +1 @@\n-old\n+new", + }, + ], + }, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.changedFiles).toEqual(["apps/web/src/session-logic.ts"]); + expect(entry?.patch).toContain( + "diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts", + ); + expect(entry?.patch).toContain("+new"); + }); + + it("extracts top-level tool patches", () => { + const patch = + "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new\n"; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "top-level-file-tool-patch", + kind: "tool.completed", + summary: "File change", + payload: { + itemType: "file_change", + patch, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.patch).toBe(patch); + }); + it("drops duplicated tool detail when it only repeats the title", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5576ebeffc..90d21f9652 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -68,6 +68,12 @@ export interface WorkLogEntry { detail?: string; command?: string; rawCommand?: string; + output?: string; + stdout?: string; + stderr?: string; + exitCode?: number; + durationMs?: number; + patch?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; @@ -86,6 +92,11 @@ interface DerivedWorkLogEntry extends WorkLogEntry { toolCallId?: string; } +const MAX_PATCH_SEARCH_DEPTH = 4; +const MAX_PATCH_STRINGS = 4; +const MAX_INLINE_PATCH_CHARS = 200_000; +const PATCH_TOO_LARGE_MESSAGE = `[patch omitted: exceeds ${MAX_INLINE_PATCH_CHARS} characters]`; + export interface PendingApproval { requestId: ApprovalRequestId; requestKind: "command" | "file-read" | "file-change"; @@ -681,7 +692,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); + const commandResult = extractCommandResult(payload); const changedFiles = extractChangedFiles(payload); + const patch = extractToolPatch(payload); const title = extractToolTitle(payload); const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; const taskSummary = @@ -729,6 +742,34 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (commandPreview.rawCommand) { entry.rawCommand = commandPreview.rawCommand; } + const isCommandEntry = + itemType === "command_execution" || + requestKind === "command" || + Boolean(commandPreview.command || commandPreview.rawCommand); + if ( + commandResult.output && + !commandResult.stdout && + !commandResult.stderr && + !entry.output && + isCommandEntry + ) { + entry.output = commandResult.output; + } + if (commandResult.stdout) { + entry.stdout = commandResult.stdout; + } + if (commandResult.stderr) { + entry.stderr = commandResult.stderr; + } + if (commandResult.exitCode !== null) { + entry.exitCode = commandResult.exitCode; + } + if (commandResult.durationMs !== null) { + entry.durationMs = commandResult.durationMs; + } + if (patch) { + entry.patch = patch; + } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; } @@ -812,6 +853,12 @@ function mergeDerivedWorkLogEntries( const detail = next.detail ?? previous.detail; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; + const output = mergeTextOutput(previous.output, next.output); + const stdout = mergeTextOutput(previous.stdout, next.stdout); + const stderr = mergeTextOutput(previous.stderr, next.stderr); + const exitCode = next.exitCode ?? previous.exitCode; + const durationMs = next.durationMs ?? previous.durationMs; + const patch = next.patch ?? previous.patch; const toolTitle = next.toolTitle ?? previous.toolTitle; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; @@ -825,6 +872,12 @@ function mergeDerivedWorkLogEntries( ...(detail ? { detail } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), + ...(output ? { output } : {}), + ...(stdout ? { stdout } : {}), + ...(stderr ? { stderr } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(patch ? { patch } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(toolTitle ? { toolTitle } : {}), ...(itemType ? { itemType } : {}), @@ -836,6 +889,29 @@ function mergeDerivedWorkLogEntries( }; } +function mergeTextOutput( + previous: string | undefined, + next: string | undefined, +): string | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + if (previous === next) { + return next; + } + if (next.startsWith(previous)) { + return next; + } + if (previous.startsWith(next)) { + return previous; + } + const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; + return `${previous}${separator}${next}`; +} + function mergeChangedFiles( previous: ReadonlyArray | undefined, next: ReadonlyArray | undefined, @@ -1078,6 +1154,96 @@ function extractToolCommand(payload: Record | null): { }; } +function firstNumberFromRecord( + record: Record | null, + keys: ReadonlyArray, +): number | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = asNumber(record[key]); + if (value !== null) { + return value; + } + } + return null; +} + +function firstIntegerFromRecord( + record: Record | null, + keys: ReadonlyArray, +): number | null { + const value = firstNumberFromRecord(record, keys); + return value !== null && Number.isInteger(value) ? value : null; +} + +function extractCommandResult(payload: Record | null): { + output: string | null; + stdout: string | null; + stderr: string | null; + exitCode: number | null; + durationMs: number | null; +} { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + const rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); + const stdout = + rawOutputStdout ?? + firstRawStringFromRecord(itemResult, ["stdout"]) ?? + firstRawStringFromRecord(data, ["stdout"]) ?? + firstRawStringFromRecord(payload, ["stdout"]); + const stderr = + firstRawStringFromRecord(rawOutput, ["stderr"]) ?? + firstRawStringFromRecord(itemResult, ["stderr"]) ?? + firstRawStringFromRecord(data, ["stderr"]) ?? + firstRawStringFromRecord(payload, ["stderr"]); + const content = + stdout ?? + firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); + const strippedContent = content ? stripTrailingExitCode(content) : null; + const detailExit = + typeof payload?.detail === "string" ? stripTrailingExitCode(payload.detail) : null; + const exitCode = + firstIntegerFromRecord(rawOutput, ["exitCode", "code"]) ?? + firstIntegerFromRecord(itemResult, ["exitCode", "code"]) ?? + firstIntegerFromRecord(item, ["exitCode", "code"]) ?? + firstIntegerFromRecord(data, ["exitCode", "code"]) ?? + firstIntegerFromRecord(payload, ["exitCode", "code"]) ?? + strippedContent?.exitCode ?? + detailExit?.exitCode ?? + null; + const elapsedSeconds = + firstNumberFromRecord(rawOutput, ["elapsedSeconds"]) ?? + firstNumberFromRecord(itemResult, ["elapsedSeconds"]) ?? + firstNumberFromRecord(item, ["elapsedSeconds"]) ?? + firstNumberFromRecord(data, ["elapsedSeconds"]) ?? + firstNumberFromRecord(payload, ["elapsedSeconds"]); + const durationMs = + firstNumberFromRecord(rawOutput, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(itemResult, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(item, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(data, ["durationMs", "elapsedMs"]) ?? + firstNumberFromRecord(payload, ["durationMs", "elapsedMs"]) ?? + (elapsedSeconds !== null ? elapsedSeconds * 1000 : null); + const strippedStdout = stdout ? stripTrailingExitCode(stdout) : null; + const normalizedOutput = + strippedContent?.exitCode !== undefined ? strippedContent.output : (content ?? null); + + return { + // `output` is the legacy fallback stream; callers should prefer stdout/stderr when present. + output: normalizedOutput, + stdout: strippedStdout?.exitCode !== undefined ? strippedStdout.output : stdout, + stderr, + exitCode, + durationMs, + }; +} + function extractToolTitle(payload: Record | null): string | null { return asTrimmedString(payload?.title); } @@ -1211,6 +1377,156 @@ function stripTrailingExitCode(value: string): { }; } +function firstRawStringFromRecord( + record: Record | null, + keys: ReadonlyArray, +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return null; +} + +function looksLikeUnifiedDiff(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed.startsWith("diff --git ") || + trimmed.startsWith("--- ") || + trimmed.startsWith("@@ ") || + /^@@\s+-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/u.test(trimmed) + ); +} + +function codexChangeKindType(record: Record): string | null { + const kind = record.kind; + if (typeof kind === "string") { + return kind; + } + const kindRecord = asRecord(kind); + return asTrimmedString(kindRecord?.type); +} + +function patchPathFromRecord(record: Record): string | null { + return ( + asTrimmedString(record.path) ?? + asTrimmedString(record.filePath) ?? + asTrimmedString(record.relativePath) ?? + asTrimmedString(record.filename) ?? + asTrimmedString(record.newPath) ?? + asTrimmedString(record.oldPath) + ); +} + +function normalizeDiffHeaderPath(path: string): string { + return path.replace(/\\/gu, "/"); +} + +function toUnifiedPatchFromRecordDiff( + record: Record, + diff: string, +): string | null { + if (diff.startsWith("diff --git ") || diff.startsWith("--- ")) { + return diff; + } + const trimmed = diff.trimEnd(); + if (trimmed.length === 0) { + return null; + } + + const rawPath = patchPathFromRecord(record); + if (!rawPath) { + return looksLikeUnifiedDiff(trimmed) ? trimmed : null; + } + const path = normalizeDiffHeaderPath(rawPath); + + if (codexChangeKindType(record) === "add") { + if (trimmed.startsWith("@@ ")) { + return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n${trimmed}`; + } + const lines = trimmed.length > 0 ? trimmed.split(/\r?\n/u) : []; + const addedLines = lines.map((line) => `+${line}`).join("\n"); + return `diff --git a/${path} b/${path}\nnew file mode 100644\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1,${lines.length} @@\n${addedLines}`; + } + + if (trimmed.startsWith("@@ ")) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n${trimmed}`; + } + + return null; +} + +function collectPatchStrings( + value: unknown, + patches: string[], + seen: Set, + depth: number, + includeNested = true, +): void { + if (depth > MAX_PATCH_SEARCH_DEPTH || patches.length >= MAX_PATCH_STRINGS) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectPatchStrings(entry, patches, seen, depth + 1, includeNested); + if (patches.length >= MAX_PATCH_STRINGS) { + return; + } + } + return; + } + const record = asRecord(value); + if (!record) { + return; + } + for (const key of ["patch", "diff", "unifiedDiff"]) { + const rawCandidate = typeof record[key] === "string" ? record[key] : null; + const candidate = rawCandidate ? toUnifiedPatchFromRecordDiff(record, rawCandidate) : null; + if (!candidate || seen.has(candidate)) { + continue; + } + if (candidate.length > MAX_INLINE_PATCH_CHARS) { + seen.add(candidate); + patches.push(PATCH_TOO_LARGE_MESSAGE); + continue; + } + if (!looksLikeUnifiedDiff(candidate)) { + continue; + } + seen.add(candidate); + patches.push(candidate); + } + if (!includeNested) { + return; + } + for (const nestedKey of ["item", "result", "input", "data", "changes", "files", "edits"]) { + if (!(nestedKey in record)) { + continue; + } + collectPatchStrings(record[nestedKey], patches, seen, depth + 1, includeNested); + if (patches.length >= MAX_PATCH_STRINGS) { + return; + } + } +} + +function extractToolPatch(payload: Record | null): string | null { + const patches: string[] = []; + const seen = new Set(); + if (payload) { + collectPatchStrings(payload, patches, seen, 0, false); + } + const data = asRecord(payload?.data); + // Keep traversal bounded; provider payloads can nest raw tool data deeply. + collectPatchStrings(data, patches, seen, 0); + return patches.length > 0 ? patches.join("\n\n") : null; +} + function extractWorkLogItemType( payload: Record | null, ): WorkLogEntry["itemType"] | undefined { @@ -1322,7 +1638,10 @@ function compareActivitiesByOrder( return lifecycleRankComparison; } - return left.id.localeCompare(right.id); + // Stable sort preserves arrival order for unsequenced same-timestamp events. + // Streaming text chunks can share millisecond timestamps; sorting those by + // random event ids can scramble the reconstructed output. + return 0; } function compareActivityLifecycleRank(kind: string): number { From 90e55b1ec6a7d9acb9f6c264cb1f244c71d103bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 18 Jun 2026 14:57:41 +0100 Subject: [PATCH 02/16] Add browser coverage for activity boxes --- .../chat/MessagesTimeline.browser.tsx | 187 +++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index 3afa085240..ccbe7453c3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -3,7 +3,7 @@ import "../../index.css"; import { EnvironmentId } from "@t3tools/contracts"; import { createRef } from "react"; import type { LegendListRef } from "@legendapp/list/react"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; @@ -182,6 +182,7 @@ describe("MessagesTimeline", () => { expect(document.querySelector('[data-slot="tooltip-popup"]')).toBeNull(); await commandTrigger.click(); + await page.getByRole("button", { name: "Raw command (show)" }).click(); await expect .element(page.getByText("git diff -- apps/web/src/components/ChatMarkdown.tsx --stat")) .toBeVisible(); @@ -190,6 +191,190 @@ describe("MessagesTimeline", () => { } }); + it("expands command activity rows and toggles long stdout from the browser", async () => { + const stdout = Array.from({ length: 45 }, (_, index) => `stdout line ${index + 1}`).join("\n"); + const screen = await render( + , + ); + + try { + const commandTrigger = page.getByLabelText("Expand Ran command - pnpm test"); + await expect.element(commandTrigger).toHaveAttribute("aria-expanded", "false"); + expect(document.body.textContent ?? "").not.toContain("stdout line 45"); + + await commandTrigger.click(); + + const commandCollapseTrigger = page.getByLabelText("Collapse Ran command - pnpm test"); + await expect.element(commandCollapseTrigger).toHaveAttribute("aria-expanded", "true"); + await expect.element(page.getByText("Command", { exact: true })).toBeVisible(); + expect(document.body.textContent ?? "").toContain("pnpm test"); + await expect.element(page.getByText("Exit code 0")).toBeVisible(); + await expect.element(page.getByText("Duration 1.2s")).toBeVisible(); + await expect.element(page.getByText("Stderr", { exact: true })).toBeVisible(); + await expect.element(page.getByText("stderr warning")).toBeVisible(); + + const rawCommandToggle = page.getByRole("button", { name: "Raw command (show)" }); + await expect.element(rawCommandToggle).toHaveAttribute("aria-expanded", "false"); + await rawCommandToggle.click(); + await expect.element(page.getByText("bash -lc 'pnpm test'")).toBeVisible(); + + await expect.element(page.getByText("Stdout", { exact: true })).toBeVisible(); + await expect.element(page.getByText("(last 40 of 45 lines)")).toBeVisible(); + expect(document.body.textContent ?? "").not.toMatch(/stdout line 1(?:\n|$)/u); + expect(document.body.textContent ?? "").toContain("stdout line 6"); + expect(document.body.textContent ?? "").toContain("stdout line 45"); + + const stdoutToggle = page.getByRole("button", { name: "Expand Stdout" }).first(); + await expect.element(stdoutToggle).toHaveAttribute("aria-expanded", "false"); + await stdoutToggle.click(); + await expect + .element(page.getByRole("button", { name: "Collapse Stdout" }).first()) + .toHaveAttribute("aria-expanded", "true"); + await expect.element(page.getByText("(45 lines)")).toBeVisible(); + expect(document.body.textContent ?? "").toMatch(/stdout line 1(?:\n|$)/u); + + document + .querySelector('[aria-label="Collapse Ran command - pnpm test"]') + ?.focus(); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByLabelText("Expand Ran command - pnpm test")) + .toHaveAttribute("aria-expanded", "false"); + expect(document.body.textContent ?? "").not.toContain("stdout line 45"); + } finally { + await screen.unmount(); + } + }); + + it("expands file-change activity rows with inline diffs or path lists", async () => { + const props = buildProps(); + const screen = await render( + , + ); + + try { + const patchTrigger = page.getByLabelText( + "Expand Changed files - apps/web/src/session-logic.ts", + ); + await expect.element(patchTrigger).toHaveAttribute("aria-expanded", "false"); + expect(document.body.textContent ?? "").not.toContain("+new"); + + await patchTrigger.click(); + + const patchCollapseTrigger = page.getByLabelText( + "Collapse Changed files - apps/web/src/session-logic.ts", + ); + await expect.element(patchCollapseTrigger).toHaveAttribute("aria-expanded", "true"); + expect(document.body.textContent ?? "").toContain("apps/web/src/session-logic.ts"); + expect(document.body.textContent ?? "").toContain("+1"); + expect(document.body.textContent ?? "").toContain("-1"); + + document + .querySelector( + '[aria-label="Collapse Changed files - apps/web/src/session-logic.ts"]', + ) + ?.focus(); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByLabelText("Expand Changed files - apps/web/src/session-logic.ts")) + .toHaveAttribute("aria-expanded", "false"); + expect(document.body.textContent ?? "").not.toContain("+1"); + + await screen.rerender( + , + ); + + const listTrigger = page.getByLabelText( + "Expand Changed files - apps/web/src/components/chat/MessagesTimeline.tsx +1 more", + ); + await listTrigger.click(); + + await expect + .element( + page.getByLabelText( + "Collapse Changed files - apps/web/src/components/chat/MessagesTimeline.tsx +1 more", + ), + ) + .toHaveAttribute("aria-expanded", "true"); + await expect + .element( + page.getByText("apps/web/src/components/chat/MessagesTimeline.tsx", { exact: true }), + ) + .toBeVisible(); + await expect + .element( + page.getByText("apps/web/src/components/chat/MessagesTimeline.test.tsx", { + exact: true, + }), + ) + .toBeVisible(); + } finally { + await screen.unmount(); + } + }); + it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { const requestAnimationFrameSpy = vi .spyOn(window, "requestAnimationFrame") From d1583db06a08f0a6388a0fa1a788a3f749daa587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 12:34:51 +0100 Subject: [PATCH 03/16] Skip blank stdout in command output extraction - Treat whitespace-only stdout as absent\n- Fall back to aggregated command output --- apps/web/src/session-logic.test.ts | 30 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 30 +++++++++++++++++++----------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dbbf8740fa..bcd3ff2b47 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1302,6 +1302,36 @@ describe("deriveWorkLogEntries", () => { }); }); + it("falls back to aggregated command output when stdout is blank-only", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-command-tool-blank-stdout", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + command: "vp test", + rawOutput: { + stdout: " \n\t ", + }, + item: { + aggregatedOutput: "tests passed\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "vp test", + output: "tests passed\n", + itemType: "command_execution", + }); + expect(entry?.stdout).toBeUndefined(); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 1fb618a99e..2f9e980314 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1188,22 +1188,22 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? - firstRawStringFromRecord(itemResult, ["stdout"]) ?? - firstRawStringFromRecord(data, ["stdout"]) ?? - firstRawStringFromRecord(payload, ["stdout"]); + firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? + firstCommandOutputStringFromRecord(data, ["stdout"]) ?? + firstCommandOutputStringFromRecord(payload, ["stdout"]); const stderr = - firstRawStringFromRecord(rawOutput, ["stderr"]) ?? - firstRawStringFromRecord(itemResult, ["stderr"]) ?? - firstRawStringFromRecord(data, ["stderr"]) ?? - firstRawStringFromRecord(payload, ["stderr"]); + firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? + firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? + firstCommandOutputStringFromRecord(data, ["stderr"]) ?? + firstCommandOutputStringFromRecord(payload, ["stderr"]); const content = stdout ?? - firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? - firstRawStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? - firstRawStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); + firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; const detailExit = typeof payload?.detail === "string" ? stripTrailingExitCode(payload.detail) : null; @@ -1392,6 +1392,14 @@ function firstRawStringFromRecord( return null; } +function firstCommandOutputStringFromRecord( + record: Record | null, + keys: ReadonlyArray, +): string | null { + const value = firstRawStringFromRecord(record, keys); + return value !== null && value.trim().length > 0 ? value : null; +} + function looksLikeUnifiedDiff(value: string): boolean { const trimmed = value.trim(); return ( From c0fe7fa81a0270441cbbc61b8a43b599cbc47b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 12:44:46 +0100 Subject: [PATCH 04/16] Preserve whitespace command stream chunks - Keep blank stream chunks for live command updates - Avoid synthetic separators around whitespace chunks --- apps/web/src/session-logic.test.ts | 59 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 21 ++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index bcd3ff2b47..21221e9fa3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1241,6 +1241,65 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); + it("preserves whitespace-only incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("hello world"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 2f9e980314..6e4662c308 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -691,7 +691,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); - const commandResult = extractCommandResult(payload); + const commandResult = extractCommandResult(payload, { + preserveBlankRawOutputStreams: activity.kind === "tool.updated", + }); const changedFiles = extractChangedFiles(payload); const patch = extractToolPatch(payload); const title = extractToolTitle(payload); @@ -907,7 +909,7 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } - const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; + const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; return `${previous}${separator}${next}`; } @@ -1177,7 +1179,12 @@ function firstIntegerFromRecord( return value !== null && Number.isInteger(value) ? value : null; } -function extractCommandResult(payload: Record | null): { +function extractCommandResult( + payload: Record | null, + options: { + readonly preserveBlankRawOutputStreams?: boolean; + } = {}, +): { output: string | null; stdout: string | null; stderr: string | null; @@ -1188,14 +1195,18 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stdout"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? firstCommandOutputStringFromRecord(data, ["stdout"]) ?? firstCommandOutputStringFromRecord(payload, ["stdout"]); const stderr = - firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? + (options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stderr"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stderr"])) ?? firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); From de5480baba51680dda7b2abbe3160cdb40caf01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 12:51:47 +0100 Subject: [PATCH 05/16] Fix streamed command output merging - Preserve incremental chunks without injecting separators - Keep blank-only raw output content during tool updates - Avoid trim allocation when checking command output --- apps/web/src/session-logic.test.ts | 145 ++++++++++++++++++++++++++++- apps/web/src/session-logic.ts | 13 ++- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 21221e9fa3..442cae18ab 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1213,7 +1213,7 @@ describe("deriveWorkLogEntries", () => { toolCallId: "command-1", command: "vp test", rawOutput: { - stdout: "Error", + stdout: "Error\n", }, }, }, @@ -1241,6 +1241,90 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); + it("concatenates split incremental command output without adding separators", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf Downloading", + rawOutput: { + stdout: "Down", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf Downloading", + rawOutput: { + stdout: "loading", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Downloading"); + }); + + it("keeps incremental command chunks that match the accumulated prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf aba", + rawOutput: { + stdout: "a\nb", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf aba", + rawOutput: { + stdout: "a", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("a\nba"); + }); + it("preserves whitespace-only incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1300,6 +1384,65 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("hello world"); }); + it("preserves whitespace-only incremental raw output content chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.output).toBe("hello world"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6e4662c308..805af68684 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -906,11 +906,7 @@ function mergeTextOutput( if (next.startsWith(previous)) { return next; } - if (previous.startsWith(next)) { - return previous; - } - const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; - return `${previous}${separator}${next}`; + return `${previous}${next}`; } function mergeChangedFiles( @@ -1210,9 +1206,12 @@ function extractCommandResult( firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); + const rawOutputContent = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) + : firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]); const content = stdout ?? - firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + rawOutputContent ?? firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; @@ -1408,7 +1407,7 @@ function firstCommandOutputStringFromRecord( keys: ReadonlyArray, ): string | null { const value = firstRawStringFromRecord(record, keys); - return value !== null && value.trim().length > 0 ? value : null; + return value !== null && /\S/u.test(value) ? value : null; } function looksLikeUnifiedDiff(value: string): boolean { From 7ae56824a5ec29ae802f5b7cf27b1ee7d49631e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:17:57 +0100 Subject: [PATCH 06/16] Revert "Fix streamed command output merging" This reverts commit de5480baba51680dda7b2abbe3160cdb40caf01c. --- apps/web/src/session-logic.test.ts | 145 +---------------------------- apps/web/src/session-logic.ts | 13 +-- 2 files changed, 8 insertions(+), 150 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 442cae18ab..21221e9fa3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1213,7 +1213,7 @@ describe("deriveWorkLogEntries", () => { toolCallId: "command-1", command: "vp test", rawOutput: { - stdout: "Error\n", + stdout: "Error", }, }, }, @@ -1241,90 +1241,6 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); - it("concatenates split incremental command output without adding separators", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "command-tool-output-update-1", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf Downloading", - rawOutput: { - stdout: "Down", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-2", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf Downloading", - rawOutput: { - stdout: "loading", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry?.stdout).toBe("Downloading"); - }); - - it("keeps incremental command chunks that match the accumulated prefix", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "command-tool-output-update-1", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf aba", - rawOutput: { - stdout: "a\nb", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-2", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf aba", - rawOutput: { - stdout: "a", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry?.stdout).toBe("a\nba"); - }); - it("preserves whitespace-only incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1384,65 +1300,6 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("hello world"); }); - it("preserves whitespace-only incremental raw output content chunks", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "command-tool-output-update-1", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - content: "hello", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-2", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - content: " ", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-3", - createdAt: "2026-02-23T00:00:03.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - content: "world", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry?.output).toBe("hello world"); - }); - it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 805af68684..6e4662c308 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -906,7 +906,11 @@ function mergeTextOutput( if (next.startsWith(previous)) { return next; } - return `${previous}${next}`; + if (previous.startsWith(next)) { + return previous; + } + const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; + return `${previous}${separator}${next}`; } function mergeChangedFiles( @@ -1206,12 +1210,9 @@ function extractCommandResult( firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); - const rawOutputContent = options.preserveBlankRawOutputStreams - ? firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) - : firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]); const content = stdout ?? - rawOutputContent ?? + firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; @@ -1407,7 +1408,7 @@ function firstCommandOutputStringFromRecord( keys: ReadonlyArray, ): string | null { const value = firstRawStringFromRecord(record, keys); - return value !== null && /\S/u.test(value) ? value : null; + return value !== null && value.trim().length > 0 ? value : null; } function looksLikeUnifiedDiff(value: string): boolean { From 58d0272791a4a599bd910e16afabd6dd8cd2162e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:17:57 +0100 Subject: [PATCH 07/16] Revert "Preserve whitespace command stream chunks" This reverts commit c0fe7fa81a0270441cbbc61b8a43b599cbc47b6a. --- apps/web/src/session-logic.test.ts | 59 ------------------------------ apps/web/src/session-logic.ts | 21 +++-------- 2 files changed, 5 insertions(+), 75 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 21221e9fa3..bcd3ff2b47 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1241,65 +1241,6 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); - it("preserves whitespace-only incremental command output chunks", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "command-tool-output-update-1", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - stdout: "hello", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-2", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - stdout: " ", - }, - }, - }, - }), - makeActivity({ - id: "command-tool-output-update-3", - createdAt: "2026-02-23T00:00:03.000Z", - kind: "tool.updated", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "command-1", - command: "printf hello", - rawOutput: { - stdout: "world", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry?.stdout).toBe("hello world"); - }); - it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6e4662c308..2f9e980314 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -691,9 +691,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); - const commandResult = extractCommandResult(payload, { - preserveBlankRawOutputStreams: activity.kind === "tool.updated", - }); + const commandResult = extractCommandResult(payload); const changedFiles = extractChangedFiles(payload); const patch = extractToolPatch(payload); const title = extractToolTitle(payload); @@ -909,7 +907,7 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } - const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; + const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; return `${previous}${separator}${next}`; } @@ -1179,12 +1177,7 @@ function firstIntegerFromRecord( return value !== null && Number.isInteger(value) ? value : null; } -function extractCommandResult( - payload: Record | null, - options: { - readonly preserveBlankRawOutputStreams?: boolean; - } = {}, -): { +function extractCommandResult(payload: Record | null): { output: string | null; stdout: string | null; stderr: string | null; @@ -1195,18 +1188,14 @@ function extractCommandResult( const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = options.preserveBlankRawOutputStreams - ? firstRawStringFromRecord(rawOutput, ["stdout"]) - : firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? firstCommandOutputStringFromRecord(data, ["stdout"]) ?? firstCommandOutputStringFromRecord(payload, ["stdout"]); const stderr = - (options.preserveBlankRawOutputStreams - ? firstRawStringFromRecord(rawOutput, ["stderr"]) - : firstCommandOutputStringFromRecord(rawOutput, ["stderr"])) ?? + firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); From aeb2b11af3ccc514641f68871af5f4cdb41cde86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:17:57 +0100 Subject: [PATCH 08/16] Revert "Skip blank stdout in command output extraction" This reverts commit d1583db06a08f0a6388a0fa1a788a3f749daa587. --- apps/web/src/session-logic.test.ts | 30 ------------------------------ apps/web/src/session-logic.ts | 30 +++++++++++------------------- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index bcd3ff2b47..dbbf8740fa 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1302,36 +1302,6 @@ describe("deriveWorkLogEntries", () => { }); }); - it("falls back to aggregated command output when stdout is blank-only", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "codex-command-tool-blank-stdout", - kind: "tool.completed", - summary: "Ran command", - payload: { - itemType: "command_execution", - data: { - command: "vp test", - rawOutput: { - stdout: " \n\t ", - }, - item: { - aggregatedOutput: "tests passed\n", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry).toMatchObject({ - command: "vp test", - output: "tests passed\n", - itemType: "command_execution", - }); - expect(entry?.stdout).toBeUndefined(); - }); - it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 2f9e980314..1fb618a99e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1188,22 +1188,22 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? - firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? - firstCommandOutputStringFromRecord(data, ["stdout"]) ?? - firstCommandOutputStringFromRecord(payload, ["stdout"]); + firstRawStringFromRecord(itemResult, ["stdout"]) ?? + firstRawStringFromRecord(data, ["stdout"]) ?? + firstRawStringFromRecord(payload, ["stdout"]); const stderr = - firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? - firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? - firstCommandOutputStringFromRecord(data, ["stderr"]) ?? - firstCommandOutputStringFromRecord(payload, ["stderr"]); + firstRawStringFromRecord(rawOutput, ["stderr"]) ?? + firstRawStringFromRecord(itemResult, ["stderr"]) ?? + firstRawStringFromRecord(data, ["stderr"]) ?? + firstRawStringFromRecord(payload, ["stderr"]); const content = stdout ?? - firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? - firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? - firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); + firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstRawStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; const detailExit = typeof payload?.detail === "string" ? stripTrailingExitCode(payload.detail) : null; @@ -1392,14 +1392,6 @@ function firstRawStringFromRecord( return null; } -function firstCommandOutputStringFromRecord( - record: Record | null, - keys: ReadonlyArray, -): string | null { - const value = firstRawStringFromRecord(record, keys); - return value !== null && value.trim().length > 0 ? value : null; -} - function looksLikeUnifiedDiff(value: string): boolean { const trimmed = value.trim(); return ( From 4ff626cda4426ecd900e626f9e7fc658aa00ae89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:24:57 +0100 Subject: [PATCH 09/16] Reapply "Skip blank stdout in command output extraction" This reverts commit aeb2b11af3ccc514641f68871af5f4cdb41cde86. --- apps/web/src/session-logic.test.ts | 30 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 30 +++++++++++++++++++----------- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dbbf8740fa..bcd3ff2b47 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1302,6 +1302,36 @@ describe("deriveWorkLogEntries", () => { }); }); + it("falls back to aggregated command output when stdout is blank-only", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "codex-command-tool-blank-stdout", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + command: "vp test", + rawOutput: { + stdout: " \n\t ", + }, + item: { + aggregatedOutput: "tests passed\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + command: "vp test", + output: "tests passed\n", + itemType: "command_execution", + }); + expect(entry?.stdout).toBeUndefined(); + }); + it("extracts changed file paths for file-change tool activities", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 1fb618a99e..2f9e980314 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1188,22 +1188,22 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = firstRawStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? - firstRawStringFromRecord(itemResult, ["stdout"]) ?? - firstRawStringFromRecord(data, ["stdout"]) ?? - firstRawStringFromRecord(payload, ["stdout"]); + firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? + firstCommandOutputStringFromRecord(data, ["stdout"]) ?? + firstCommandOutputStringFromRecord(payload, ["stdout"]); const stderr = - firstRawStringFromRecord(rawOutput, ["stderr"]) ?? - firstRawStringFromRecord(itemResult, ["stderr"]) ?? - firstRawStringFromRecord(data, ["stderr"]) ?? - firstRawStringFromRecord(payload, ["stderr"]); + firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? + firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? + firstCommandOutputStringFromRecord(data, ["stderr"]) ?? + firstCommandOutputStringFromRecord(payload, ["stderr"]); const content = stdout ?? - firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? - firstRawStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? - firstRawStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); + firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? + firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; const detailExit = typeof payload?.detail === "string" ? stripTrailingExitCode(payload.detail) : null; @@ -1392,6 +1392,14 @@ function firstRawStringFromRecord( return null; } +function firstCommandOutputStringFromRecord( + record: Record | null, + keys: ReadonlyArray, +): string | null { + const value = firstRawStringFromRecord(record, keys); + return value !== null && value.trim().length > 0 ? value : null; +} + function looksLikeUnifiedDiff(value: string): boolean { const trimmed = value.trim(); return ( From 206b4e2d51d62c443698df6d41de654265313dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:24:57 +0100 Subject: [PATCH 10/16] Reapply "Preserve whitespace command stream chunks" This reverts commit 58d0272791a4a599bd910e16afabd6dd8cd2162e. --- apps/web/src/session-logic.test.ts | 59 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 21 ++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index bcd3ff2b47..21221e9fa3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1241,6 +1241,65 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); + it("preserves whitespace-only incremental command output chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + stdout: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("hello world"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 2f9e980314..6e4662c308 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -691,7 +691,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? (activity.payload as Record) : null; const commandPreview = extractToolCommand(payload); - const commandResult = extractCommandResult(payload); + const commandResult = extractCommandResult(payload, { + preserveBlankRawOutputStreams: activity.kind === "tool.updated", + }); const changedFiles = extractChangedFiles(payload); const patch = extractToolPatch(payload); const title = extractToolTitle(payload); @@ -907,7 +909,7 @@ function mergeTextOutput( if (previous.startsWith(next)) { return previous; } - const separator = previous.endsWith("\n") || next.startsWith("\n") ? "" : "\n"; + const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; return `${previous}${separator}${next}`; } @@ -1177,7 +1179,12 @@ function firstIntegerFromRecord( return value !== null && Number.isInteger(value) ? value : null; } -function extractCommandResult(payload: Record | null): { +function extractCommandResult( + payload: Record | null, + options: { + readonly preserveBlankRawOutputStreams?: boolean; + } = {}, +): { output: string | null; stdout: string | null; stderr: string | null; @@ -1188,14 +1195,18 @@ function extractCommandResult(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const rawOutput = asRecord(data?.rawOutput); - const rawOutputStdout = firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); + const rawOutputStdout = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stdout"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stdout"]); const stdout = rawOutputStdout ?? firstCommandOutputStringFromRecord(itemResult, ["stdout"]) ?? firstCommandOutputStringFromRecord(data, ["stdout"]) ?? firstCommandOutputStringFromRecord(payload, ["stdout"]); const stderr = - firstCommandOutputStringFromRecord(rawOutput, ["stderr"]) ?? + (options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["stderr"]) + : firstCommandOutputStringFromRecord(rawOutput, ["stderr"])) ?? firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); From 69ecd2f0450b5ef976800ccd0872ebac223b9fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:24:57 +0100 Subject: [PATCH 11/16] Reapply "Fix streamed command output merging" This reverts commit 7ae56824a5ec29ae802f5b7cf27b1ee7d49631e5. --- apps/web/src/session-logic.test.ts | 145 ++++++++++++++++++++++++++++- apps/web/src/session-logic.ts | 13 ++- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 21221e9fa3..442cae18ab 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1213,7 +1213,7 @@ describe("deriveWorkLogEntries", () => { toolCallId: "command-1", command: "vp test", rawOutput: { - stdout: "Error", + stdout: "Error\n", }, }, }, @@ -1241,6 +1241,90 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("Error\nretrying"); }); + it("concatenates split incremental command output without adding separators", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf Downloading", + rawOutput: { + stdout: "Down", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf Downloading", + rawOutput: { + stdout: "loading", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Downloading"); + }); + + it("keeps incremental command chunks that match the accumulated prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf aba", + rawOutput: { + stdout: "a\nb", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf aba", + rawOutput: { + stdout: "a", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("a\nba"); + }); + it("preserves whitespace-only incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1300,6 +1384,65 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("hello world"); }); + it("preserves whitespace-only incremental raw output content chunks", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: "hello", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: " ", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-3", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "printf hello", + rawOutput: { + content: "world", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.output).toBe("hello world"); + }); + it("strips fallback stdout exit-code metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6e4662c308..805af68684 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -906,11 +906,7 @@ function mergeTextOutput( if (next.startsWith(previous)) { return next; } - if (previous.startsWith(next)) { - return previous; - } - const separator = /\s$/u.test(previous) || /^\s/u.test(next) ? "" : "\n"; - return `${previous}${separator}${next}`; + return `${previous}${next}`; } function mergeChangedFiles( @@ -1210,9 +1206,12 @@ function extractCommandResult( firstCommandOutputStringFromRecord(itemResult, ["stderr"]) ?? firstCommandOutputStringFromRecord(data, ["stderr"]) ?? firstCommandOutputStringFromRecord(payload, ["stderr"]); + const rawOutputContent = options.preserveBlankRawOutputStreams + ? firstRawStringFromRecord(rawOutput, ["content", "output", "text", "result"]) + : firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]); const content = stdout ?? - firstCommandOutputStringFromRecord(rawOutput, ["content", "output", "text", "result"]) ?? + rawOutputContent ?? firstCommandOutputStringFromRecord(itemResult, ["content", "output", "text", "result"]) ?? firstCommandOutputStringFromRecord(item, ["aggregatedOutput", "output", "text", "result"]); const strippedContent = content ? stripTrailingExitCode(content) : null; @@ -1408,7 +1407,7 @@ function firstCommandOutputStringFromRecord( keys: ReadonlyArray, ): string | null { const value = firstRawStringFromRecord(record, keys); - return value !== null && value.trim().length > 0 ? value : null; + return value !== null && /\S/u.test(value) ? value : null; } function looksLikeUnifiedDiff(value: string): boolean { From 065af3e73806025ac44f495f0577f237ad0fea3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 13:42:04 +0100 Subject: [PATCH 12/16] Fix regressive command output snapshots --- apps/web/src/session-logic.test.ts | 126 +++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 14 +++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 442cae18ab..51f776dc64 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1199,6 +1199,132 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stderr).toBe("warning 1\nwarning 2\n"); }); + it("uses later longer cumulative command output from updated snapshots", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + + it("keeps previously merged command output when completed output is a shorter snapshot", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + + it("keeps previously merged command output when updated output is a shorter line snapshot", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\nline 2\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "line 1\n", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("line 1\nline 2\n"); + }); + it("concatenates non-matching incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 805af68684..f1e3b18e14 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -854,9 +854,9 @@ function mergeDerivedWorkLogEntries( const detail = next.detail ?? previous.detail; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; - const output = mergeTextOutput(previous.output, next.output); - const stdout = mergeTextOutput(previous.stdout, next.stdout); - const stderr = mergeTextOutput(previous.stderr, next.stderr); + const output = mergeTextOutput(previous.output, next.output, next); + const stdout = mergeTextOutput(previous.stdout, next.stdout, next); + const stderr = mergeTextOutput(previous.stderr, next.stderr, next); const exitCode = next.exitCode ?? previous.exitCode; const durationMs = next.durationMs ?? previous.durationMs; const patch = next.patch ?? previous.patch; @@ -893,6 +893,7 @@ function mergeDerivedWorkLogEntries( function mergeTextOutput( previous: string | undefined, next: string | undefined, + nextEntry: DerivedWorkLogEntry, ): string | undefined { if (!previous) { return next; @@ -906,9 +907,16 @@ function mergeTextOutput( if (next.startsWith(previous)) { return next; } + if (previous.startsWith(next) && shouldKeepLongerOutputSnapshot(next, nextEntry)) { + return previous; + } return `${previous}${next}`; } +function shouldKeepLongerOutputSnapshot(next: string, nextEntry: DerivedWorkLogEntry): boolean { + return nextEntry.activityKind === "tool.completed" || next.endsWith("\n"); +} + function mergeChangedFiles( previous: ReadonlyArray | undefined, next: ReadonlyArray | undefined, From 5661ec9dec51a6f9ac71e8328668874eecbb1604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 15:12:18 +0100 Subject: [PATCH 13/16] Fix repeated prefix command snapshots --- apps/web/src/session-logic.test.ts | 42 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 22 +++++++++++++--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 51f776dc64..993144da5f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1325,6 +1325,48 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("line 1\nline 2\n"); }); + it("keeps previously merged command output when updated output is a shorter repeated prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "Hello World", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "Hello", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("Hello World"); + }); + it("concatenates non-matching incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f1e3b18e14..fd339fe3a0 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -907,14 +907,30 @@ function mergeTextOutput( if (next.startsWith(previous)) { return next; } - if (previous.startsWith(next) && shouldKeepLongerOutputSnapshot(next, nextEntry)) { + if (previous.startsWith(next) && shouldKeepLongerOutputSnapshot(previous, next, nextEntry)) { return previous; } return `${previous}${next}`; } -function shouldKeepLongerOutputSnapshot(next: string, nextEntry: DerivedWorkLogEntry): boolean { - return nextEntry.activityKind === "tool.completed" || next.endsWith("\n"); +function shouldKeepLongerOutputSnapshot( + previous: string, + next: string, + nextEntry: DerivedWorkLogEntry, +): boolean { + return ( + nextEntry.activityKind === "tool.completed" || + next.endsWith("\n") || + isLikelyShorterOutputSnapshot(previous, next) + ); +} + +function isLikelyShorterOutputSnapshot(previous: string, next: string): boolean { + if (next.length <= 1) { + return false; + } + const following = previous.slice(next.length, next.length + 1); + return following === " " || following === "\t" || following === "\n" || following === "\r"; } function mergeChangedFiles( From bc958ca82e3d1e1873ab4cc4a93bcd168b8b0cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 15:17:09 +0100 Subject: [PATCH 14/16] Preserve prefix chunks after newline output --- apps/web/src/session-logic.test.ts | 42 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 993144da5f..ea95277f1a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1493,6 +1493,48 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("a\nba"); }); + it("keeps multi-character incremental command chunks that match the accumulated prefix", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "PASS one\n", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "PASS", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("PASS one\nPASS"); + }); + it("preserves whitespace-only incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fd339fe3a0..ba6e6422dc 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -926,7 +926,7 @@ function shouldKeepLongerOutputSnapshot( } function isLikelyShorterOutputSnapshot(previous: string, next: string): boolean { - if (next.length <= 1) { + if (next.length <= 1 || previous.endsWith("\n")) { return false; } const following = previous.slice(next.length, next.length + 1); From 3ab69583103b850111cd65adb2f8ff23a0d321c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 15:21:11 +0100 Subject: [PATCH 15/16] Handle repeated prefixes in multiline chunks --- apps/web/src/session-logic.test.ts | 44 +++++++++++++++++++++++++++++- apps/web/src/session-logic.ts | 4 +-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index ea95277f1a..f4962b5c07 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1493,7 +1493,7 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("a\nba"); }); - it("keeps multi-character incremental command chunks that match the accumulated prefix", () => { + it("appends multi-character incremental command chunks that match the accumulated prefix", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "command-tool-output-update-1", @@ -1535,6 +1535,48 @@ describe("deriveWorkLogEntries", () => { expect(entry?.stdout).toBe("PASS one\nPASS"); }); + it("appends multi-character incremental command chunks after unterminated repeated-prefix lines", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-output-update-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "PASS one\nPASS two", + }, + }, + }, + }), + makeActivity({ + id: "command-tool-output-update-2", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.updated", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-1", + command: "vp test", + rawOutput: { + stdout: "PASS", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.stdout).toBe("PASS one\nPASS twoPASS"); + }); + it("preserves whitespace-only incremental command output chunks", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index ba6e6422dc..f9732af8ba 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -926,10 +926,10 @@ function shouldKeepLongerOutputSnapshot( } function isLikelyShorterOutputSnapshot(previous: string, next: string): boolean { - if (next.length <= 1 || previous.endsWith("\n")) { + if (next.length <= 1 || previous.endsWith("\n") || previous.indexOf("\n", next.length) !== -1) { return false; } - const following = previous.slice(next.length, next.length + 1); + const following = previous[next.length]; return following === " " || following === "\t" || following === "\n" || following === "\r"; } From d6ada8b1c580656130a69dcea8db50eceafbc13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 20 Jun 2026 15:26:08 +0100 Subject: [PATCH 16/16] Refine multiline prefix snapshot detection --- apps/web/src/session-logic.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f9732af8ba..c15310a2a1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -926,7 +926,11 @@ function shouldKeepLongerOutputSnapshot( } function isLikelyShorterOutputSnapshot(previous: string, next: string): boolean { - if (next.length <= 1 || previous.endsWith("\n") || previous.indexOf("\n", next.length) !== -1) { + if (next.length <= 1) { + return false; + } + // Multiline prefix matches are ambiguous; favor preserving incremental chunks over dropping output. + if (previous.includes("\n")) { return false; } const following = previous[next.length];