diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts index 570629046a60..b5d3fdd22ea7 100644 --- a/apps/web/src/session-logic.command-output.test.ts +++ b/apps/web/src/session-logic.command-output.test.ts @@ -6,6 +6,7 @@ import { deriveWorkLogEntries } from "./session-logic"; function makeCommandActivity( id: string, payload: Record, + overrides: Partial> = {}, ): OrchestrationThreadActivity { return { id: EventId.make(id), @@ -15,9 +16,79 @@ function makeCommandActivity( tone: "tool", payload, turnId: TurnId.make("turn-1"), + ...overrides, }; } +// Claude puts the command in `detail` with a `Bash: ` prefix. The server +// projects `data.command` onto in-progress updates and keeps +// `data.input.command` on the completed activity. +function makeClaudeBashLifecycle(command: string, detail: string): OrchestrationThreadActivity[] { + const toolCallId = "toolu_01XghhMudoHnUpdrKNfqgcYW"; + return [ + makeCommandActivity( + "claude-updated", + { + itemType: "command_execution", + toolCallId, + status: "inProgress", + detail, + data: { command }, + }, + { kind: "tool.updated", createdAt: "2026-07-17T10:00:00.000Z", summary: "Command run" }, + ), + makeCommandActivity( + "claude-completed", + { + itemType: "command_execution", + toolCallId, + status: "completed", + detail, + data: { + toolName: "Bash", + input: { command, description: "List files" }, + result: { type: "tool_result", content: "", is_error: false }, + }, + }, + { kind: "tool.completed", createdAt: "2026-07-17T10:00:01.000Z", summary: "Command run" }, + ), + ]; +} + +describe("deriveWorkLogEntries Claude command detail", () => { + it("drops the tool-name-prefixed detail that repeats the command", () => { + const command = "cd /repo && grep -n -E \"F0[0-9][0-9]\" TRACKER.md | sed -n '1,120p'"; + const entries = deriveWorkLogEntries(makeClaudeBashLifecycle(command, `Bash: ${command}`)); + + expect(entries).toHaveLength(1); + expect(entries[0]?.command).toBe(command); + expect(entries[0]?.detail).toBeUndefined(); + }); + + it("drops the detail when the server truncated it", () => { + const command = `cd /repo && ${"x".repeat(200)} && ls`; + const detail = `Bash: ${command}`.slice(0, 177) + "..."; + const entries = deriveWorkLogEntries(makeClaudeBashLifecycle(command, detail)); + + expect(entries).toHaveLength(1); + expect(entries[0]?.command).toBe(command); + expect(entries[0]?.detail).toBeUndefined(); + }); + + it("reads the command from Claude's completed payload", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-only-completed", { + itemType: "command_execution", + detail: "Bash: bun test", + data: { toolName: "Bash", input: { command: "bun test" } }, + }), + ]); + + expect(entry?.command).toBe("bun test"); + expect(entry?.detail).toBeUndefined(); + }); +}); + describe("deriveWorkLogEntries command output", () => { it("uses Codex aggregated output instead of repeating the command", () => { const [entry] = deriveWorkLogEntries([ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..283b537a90d4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1410,6 +1410,8 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + // Claude completed activities carry the tool input as `data.input`. + const dataInput = asRecord(data?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1417,6 +1419,7 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, + dataInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1465,6 +1468,32 @@ function normalizePreviewForComparison(value: string | null | undefined): string return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase(); } +/** + * True when a normalized `detail` only repeats the normalized command. + * Claude writes the command into `detail` as `Bash: `, and the server + * truncates long details with a trailing `...`, so both are allowed here. + */ +function detailRepeatsCommand( + normalizedDetail: string | null, + normalizedCommand: string | null, +): boolean { + if (!normalizedDetail || !normalizedCommand) { + return false; + } + if (normalizedDetail === normalizedCommand) { + return true; + } + const withoutToolPrefix = normalizedDetail.replace(/^[a-z][\w.-]*:\s+/, ""); + if (withoutToolPrefix === normalizedCommand) { + return true; + } + const ellipsis = /\s*(?:\.\.\.|…)$/.exec(withoutToolPrefix); + if (!ellipsis || ellipsis.index === 0) { + return false; + } + return normalizedCommand.startsWith(withoutToolPrefix.slice(0, ellipsis.index)); +} + function summarizeToolTextOutput(value: string): string | null { const lines: Array = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -1605,7 +1634,8 @@ function extractToolDetail( detail && normalizedHeading !== normalizedDetail && (!commandTool || - (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail)) + (!detailRepeatsCommand(normalizedDetail, normalizedCommand) && + !detailRepeatsCommand(normalizedDetail, normalizedRawCommand))) ) { return detail; }