authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 18:46:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 18:46:14-07:00
log083d4de5b0fd33efa28363546b6ed89e191c5200
tree29b253f2af1a668bdb441f2073b4541a88cffa8f
parentcdbf324aa043b1bc5705159127d2b55f186d1b9b
signature Signed by PGP key B5690EEEBB952194

fix(clients): stop repeating expanded commands (#9120)

Expanded command rows use a neutral Command label and keep the full command plus real output in the detail area. Claude Bash results now cross the wire as a bounded summary. Truncated Claude echoes and ACP echoes without a kind are dropped as synthetic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

11 files changed, 685 insertions(+), 115 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+3-2
......@@ -338,7 +338,8 @@ export function ThreadWorkLog(props: {
338338 const canExpand = row.canExpand;
339339 const fullDetail = expanded ? row.getFullDetail() : null;
340340 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
341 const displayText = row.detail ?? row.summary;
341 const previewText = row.detail ?? row.summary;
342 const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText;
342343 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
343344 const failed = row.status === "failure";
344345 const showIcon = !row.groupedToolDetail || iconIsDestructive || failed;
......@@ -352,7 +353,7 @@ export function ThreadWorkLog(props: {
352353 >
353354 <Pressable
354355 accessibilityRole={canExpand ? "button" : undefined}
355 accessibilityLabel={failed ? `${displayText}, tool call failed` : displayText}
356 accessibilityLabel={failed ? `${previewText}, tool call failed` : previewText}
356357 accessibilityHint={
357358 canExpand
358359 ? "Double tap to show full details. Long press to copy."
apps/mobile/src/lib/threadActivity.test.ts+216
......@@ -234,6 +234,222 @@ function makeThread(
234234}
235235
236236describe("buildThreadFeed", () => {
237 it("keeps long Claude commands expandable without repeating them in full detail", () => {
238 const command = `printf 'first line\nsecond line'\n&& printf done`;
239 const thread = makeThread({
240 id: ThreadId.make("thread-long-command"),
241 projectId: ProjectId.make("project-1"),
242 title: "Long command",
243 activities: [
244 makeActivity({
245 id: EventId.make("long-command"),
246 kind: "tool.completed",
247 tone: "tool",
248 summary: "Command run",
249 createdAt: "2026-09-01T00:00:00.000Z",
250 payload: {
251 itemType: "command_execution",
252 title: "Command run",
253 detail: `Bash: ${command}`,
254 data: { toolName: "Bash", command },
255 },
256 }),
257 ],
258 });
259
260 const [group] = buildThreadFeed(thread);
261 expect(group?.type).toBe("activity-group");
262 if (group?.type !== "activity-group") return;
263 const [row] = group.activities;
264 expect(row).toMatchObject({ detail: command, canExpand: true });
265 expect(row?.getFullDetail()).toBe(command);
266 expect(row?.getCopyText()).toBe(`Command run\n${command}`);
267 });
268
269 it("keeps command output when it equals the displayed command", () => {
270 const command = "printf hello";
271 const thread = makeThread({
272 id: ThreadId.make("thread-matching-command-output"),
273 projectId: ProjectId.make("project-1"),
274 title: "Matching output",
275 activities: [
276 makeActivity({
277 id: EventId.make("matching-command-output"),
278 kind: "tool.completed",
279 tone: "tool",
280 summary: "Command run",
281 createdAt: "2026-09-01T00:00:00.000Z",
282 payload: {
283 itemType: "command_execution",
284 title: "Command run",
285 detail: `Bash: ${command}`,
286 data: { toolName: "Bash", command, rawOutput: { content: command } },
287 },
288 }),
289 ],
290 });
291
292 const [group] = buildThreadFeed(thread);
293 expect(group?.type).toBe("activity-group");
294 if (group?.type !== "activity-group") return;
295 const [row] = group.activities;
296 expect(row?.detail).toBe(command);
297 expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
298 expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`);
299 });
300
301 it("keeps OpenCode detail-only output when it equals the command", () => {
302 const command = "printf hello";
303 const thread = makeThread({
304 id: ThreadId.make("thread-opencode-detail-output"),
305 projectId: ProjectId.make("project-1"),
306 title: "OpenCode detail output",
307 activities: [
308 makeActivity({
309 id: EventId.make("opencode-detail-output"),
310 kind: "tool.completed",
311 tone: "tool",
312 summary: "bash",
313 createdAt: "2026-09-01T00:00:00.000Z",
314 payload: {
315 itemType: "command_execution",
316 title: "bash",
317 detail: command,
318 data: { command },
319 },
320 }),
321 ],
322 });
323
324 const [group] = buildThreadFeed(thread);
325 expect(group?.type).toBe("activity-group");
326 if (group?.type !== "activity-group") return;
327 const [row] = group.activities;
328 expect(row?.workEntry.detail).toBe(command);
329 expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
330 });
331
332 it("drops a truncated Claude echo of a long command", () => {
333 const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
334 const thread = makeThread({
335 id: ThreadId.make("thread-truncated-echo"),
336 projectId: ProjectId.make("project-1"),
337 title: "Truncated echo",
338 activities: [
339 makeActivity({
340 id: EventId.make("truncated-echo"),
341 kind: "tool.completed",
342 tone: "tool",
343 summary: "Command run",
344 createdAt: "2026-09-01T00:00:00.000Z",
345 payload: {
346 itemType: "command_execution",
347 title: "Command run",
348 detail: `Bash: ${command}`.slice(0, 177) + "...",
349 data: { toolName: "Bash", command },
350 },
351 }),
352 ],
353 });
354
355 const [group] = buildThreadFeed(thread);
356 expect(group?.type).toBe("activity-group");
357 if (group?.type !== "activity-group") return;
358 const [row] = group.activities;
359 expect(row?.workEntry.detail).toBeUndefined();
360 expect(row?.getFullDetail()).toBe(command);
361 });
362
363 it("drops an ACP command echo when the update omits the tool kind", () => {
364 const command = "pnpm test";
365 const thread = makeThread({
366 id: ThreadId.make("thread-acp-no-kind"),
367 projectId: ProjectId.make("project-1"),
368 title: "ACP no kind",
369 activities: [
370 makeActivity({
371 id: EventId.make("acp-no-kind"),
372 kind: "tool.completed",
373 tone: "tool",
374 summary: "Terminal",
375 createdAt: "2026-09-01T00:00:00.000Z",
376 payload: {
377 itemType: "command_execution",
378 title: "Terminal",
379 detail: command,
380 data: { toolCallId: "tool-1", command },
381 },
382 }),
383 ],
384 });
385
386 const [group] = buildThreadFeed(thread);
387 expect(group?.type).toBe("activity-group");
388 if (group?.type !== "activity-group") return;
389 const [row] = group.activities;
390 expect(row?.workEntry.detail).toBeUndefined();
391 expect(row?.getFullDetail()).toBe(command);
392 });
393
394 it("drops ACP command metadata when detail only repeats the command", () => {
395 const command = "printf hello";
396 const thread = makeThread({
397 id: ThreadId.make("thread-acp-command-detail"),
398 projectId: ProjectId.make("project-1"),
399 title: "ACP command detail",
400 activities: [
401 makeActivity({
402 id: EventId.make("acp-command-detail"),
403 kind: "tool.completed",
404 tone: "tool",
405 summary: "Terminal",
406 createdAt: "2026-09-01T00:00:00.000Z",
407 payload: {
408 itemType: "command_execution",
409 title: "Terminal",
410 detail: command,
411 data: { kind: "execute", command },
412 },
413 }),
414 ],
415 });
416
417 const [group] = buildThreadFeed(thread);
418 expect(group?.type).toBe("activity-group");
419 if (group?.type !== "activity-group") return;
420 const [row] = group.activities;
421 expect(row?.workEntry.detail).toBeUndefined();
422 expect(row?.getFullDetail()).toBe(command);
423 });
424
425 it("does not show command output when the command input is missing", () => {
426 const thread = makeThread({
427 id: ThreadId.make("thread-command-without-input"),
428 projectId: ProjectId.make("project-1"),
429 title: "Missing command input",
430 activities: [
431 makeActivity({
432 id: EventId.make("command-without-input"),
433 kind: "tool.completed",
434 tone: "tool",
435 summary: "Command run",
436 createdAt: "2026-09-01T00:00:00.000Z",
437 payload: {
438 itemType: "command_execution",
439 title: "Command run",
440 data: { rawOutput: { content: "output without command metadata" } },
441 },
442 }),
443 ],
444 });
445
446 const [group] = buildThreadFeed(thread);
447 expect(group?.type).toBe("activity-group");
448 if (group?.type !== "activity-group") return;
449 expect(group.activities[0]?.detail).toBeNull();
450 expect(group.activities[0]?.getFullDetail()).toBeNull();
451 });
452
237453 it("keeps setup failures visible without routine setup notices before or after a turn", () => {
238454 const thread = makeThread({
239455 id: ThreadId.make("thread-worktree-setup"),
apps/mobile/src/lib/threadActivity.ts+36-21
......@@ -14,6 +14,8 @@ import type {
1414} from "@t3tools/contracts";
1515import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1616import {
17 commandDetailRepeatsCommand,
18 extractCommandOutputText,
1719 isWorktreeSetupActivity,
1820 normalizeCompactToolLabel,
1921 omitSupersededLifecycleMarkers,
......@@ -440,16 +442,23 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
440442 const itemType = extractWorkLogItemType(payload);
441443 const requestKind = extractWorkLogRequestKind(payload);
442444 const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath);
443 if (
444 !taskDetailAsLabel &&
445 payload &&
446 typeof payload.detail === "string" &&
447 payload.detail.length > 0
448 ) {
445 const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null;
446 const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null;
447 if (!taskDetailAsLabel && output) {
448 entry.detail = output;
449 } else if (!taskDetailAsLabel && typeof payload?.detail === "string") {
449450 const detail = stripTrailingExitCode(payload.detail).output;
450 if (detail) {
451 entry.detail = detail;
452 }
451 const data = asRecord(payload.data);
452 const repeatsCommand =
453 detail !== null &&
454 commandDetailRepeatsCommand({
455 detail,
456 command: commandPreview.command,
457 rawCommand: commandPreview.rawCommand,
458 toolName: data?.toolName,
459 data,
460 });
461 if (detail && !repeatsCommand) entry.detail = detail;
453462 }
454463 if (viewedImagePath) {
455464 entry.viewedImagePath = viewedImagePath;
......@@ -757,20 +766,18 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
757766
758767function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
759768 const blocks: string[] = [];
760 const appendUniqueBlock = (value: string | null | undefined) => {
769 const appendBlock = (value: string | null | undefined) => {
761770 const trimmed = value?.trim();
762 if (trimmed && !blocks.includes(trimmed)) {
763 blocks.push(trimmed);
764 }
771 if (trimmed && (entry.command || !blocks.includes(trimmed))) blocks.push(trimmed);
765772 };
766773
767774 if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
768 appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
775 appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
769776 }
770 appendUniqueBlock(entry.rawCommand ?? entry.command);
771 appendUniqueBlock(entry.detail);
777 appendBlock(entry.rawCommand ?? entry.command);
778 appendBlock(entry.detail);
772779 if ((entry.changedFiles?.length ?? 0) > 0) {
773 appendUniqueBlock(entry.changedFiles!.join("\n"));
780 appendBlock(entry.changedFiles!.join("\n"));
774781 }
775782
776783 return blocks.length > 0 ? blocks.join("\n\n") : null;
......@@ -1765,13 +1772,21 @@ export function buildThreadFeed(
17651772 const summary = workEntryHeading(entry);
17661773 const detail = workEntryPreview(entry);
17671774 const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry));
1768 const getCopyText = memoizeValue(() =>
1769 [summary, detail, getFullDetail()]
1775 const getCopyText = memoizeValue(() => {
1776 const fullDetail = getFullDetail();
1777 if (entry.command) {
1778 const normalizedCommand =
1779 entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null;
1780 return [summary, normalizedCommand, fullDetail ?? entry.command]
1781 .filter((value): value is string => Boolean(value))
1782 .join("\n");
1783 }
1784 return [summary, detail, fullDetail]
17701785 .filter((value, index, values): value is string => {
17711786 return Boolean(value) && values.indexOf(value) === index;
17721787 })
1773 .join("\n"),
1774 );
1788 .join("\n");
1789 });
17751790 return {
17761791 type: "activity",
17771792 id: entry.id,
apps/server/src/orchestration/ActivityPayloadProjection.test.ts+14-4
......@@ -119,7 +119,7 @@ describe("projectActivityPayload", () => {
119119 expect(JSON.stringify(acp.payload).length).toBeLessThan(500);
120120 });
121121
122 it("normalizes Claude and OpenCode command inputs before slimming provider data", () => {
122 it("keeps bounded Claude command input and result summaries", () => {
123123 const claude = projectActivityPayload(
124124 activity({
125125 itemType: "command_execution",
......@@ -127,7 +127,13 @@ describe("projectActivityPayload", () => {
127127 data: {
128128 toolName: "Bash",
129129 input: { command: "vp test run" },
130 result: { content: "x".repeat(5_000) },
130 result: {
131 type: "tool_result",
132 content: [
133 { type: "text", text: "tests passed" },
134 { type: "text", text: "x".repeat(5_000) },
135 ],
136 },
131137 },
132138 }),
133139 );
......@@ -148,13 +154,17 @@ describe("projectActivityPayload", () => {
148154
149155 expect(claude.payload).toMatchObject({
150156 toolCallId: "claude-call-1",
151 data: { command: "vp test run" },
157 data: {
158 toolName: "Bash",
159 command: "vp test run",
160 rawOutput: { content: "tests passed" },
161 },
152162 });
153163 expect(openCode.payload).toMatchObject({
154164 toolCallId: "opencode-call-1",
155165 data: { command: "vp lint" },
156166 });
157 expect(JSON.stringify(claude.payload).length).toBeLessThan(200);
167 expect(JSON.stringify(claude.payload).length).toBeLessThan(250);
158168 expect(JSON.stringify(openCode.payload).length).toBeLessThan(200);
159169 });
160170
apps/server/src/orchestration/ActivityPayloadProjection.ts+7-1
......@@ -408,8 +408,14 @@ export function projectActivityPayload(
408408 if ("kind" in data) {
409409 projectedData.kind = data.kind;
410410 }
411 if ("toolName" in data) {
412 projectedData.toolName = data.toolName;
413 }
411414
412 const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content);
415 const rawOutput =
416 projectRawOutput(data.rawOutput) ??
417 projectAcpContent(data.content) ??
418 (payload.itemType === "command_execution" ? summarizeMcpResult(data.result) : undefined);
413419 if (rawOutput) {
414420 projectedData.rawOutput = rawOutput;
415421 }
apps/server/test/ActivityPayloadProjection.test.ts+68-1
......@@ -184,6 +184,52 @@ describe("projectActivityPayload", () => {
184184 });
185185 });
186186
187 it("projects a Claude Bash result for the web and mobile expanded rows", () => {
188 const command = `printf 'first line\nsecond line'\n&& printf done`;
189 const source: OrchestrationThreadActivity = {
190 ...makeActivity("claude-bash", "command_execution", {}),
191 summary: "Command run",
192 payload: {
193 itemType: "command_execution",
194 title: "Command run",
195 detail: `Bash: ${command}`,
196 status: "completed",
197 data: {
198 toolName: "Bash",
199 input: { command },
200 result: {
201 type: "tool_result",
202 tool_use_id: "toolu_1",
203 content: [
204 { type: "text", text: "first output line" },
205 { type: "text", text: "x".repeat(5_000) },
206 ],
207 },
208 },
209 },
210 };
211 const projected = projectActivityPayload(source);
212
213 expect(projected.payload).toMatchObject({
214 data: {
215 toolName: "Bash",
216 command,
217 rawOutput: { content: "first output line" },
218 },
219 });
220
221 const [webEntry] = deriveWorkLogEntries([projected]);
222 expect(webEntry).toMatchObject({ command, detail: "first output line" });
223
224 const [mobileGroup] = buildThreadFeed(makeThread([projected]));
225 expect(mobileGroup?.type).toBe("activity-group");
226 if (mobileGroup?.type !== "activity-group") return;
227 const [mobileRow] = mobileGroup.activities;
228 expect(mobileRow).toMatchObject({ detail: command, canExpand: true });
229 expect(mobileRow?.getFullDetail()).toBe(`${command}\n\nfirst output line`);
230 expect(mobileRow?.getCopyText()).toBe(`Command run\n${command}\n\nfirst output line`);
231 });
232
187233 it("slims MCP tool data to the fields the expanded row renders", () => {
188234 expect(projectActivityPayload(fixtures[4]!).payload).toEqual({
189235 itemType: "mcp_tool_call",
......@@ -201,9 +247,30 @@ describe("projectActivityPayload", () => {
201247 });
202248 });
203249
204 it("keeps current web and mobile derived output identical for every tool item type", () => {
250 it("keeps current web and mobile derived fields for every tool item type", () => {
205251 for (const activity of fixtures) {
206252 const projected = projectActivityPayload(activity);
253 if (activity === fixtures[0]) {
254 expect(deriveWorkLogEntries([projected])).toMatchObject([
255 {
256 command: "pnpm test",
257 rawCommand: 'bash -lc "pnpm test"',
258 detail: "first useful line",
259 },
260 ]);
261 expect(comparableThreadFeed([projected])).toMatchObject([
262 {
263 type: "activity-group",
264 activities: [
265 {
266 detail: "pnpm test",
267 fullDetail: 'bash -lc "pnpm test"\n\nfirst useful line',
268 },
269 ],
270 },
271 ]);
272 continue;
273 }
207274 if (activity === fixtures[4]) {
208275 // MCP is the one deliberate difference: the expanded row's toolData
209276 // loses result bulk but keeps the rendered identity fields.
apps/web/src/components/chat/MessagesTimeline.tsx+4-3
......@@ -2448,7 +2448,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24482448 const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry);
24492449 const entryIconName =
24502450 showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry);
2451 const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2451 const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2452 const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText;
24522453 const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
24532454 const viewedImagePath = workEntryViewedImagePath(workEntry);
24542455 const viewedImage =
......@@ -2483,8 +2484,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
24832484 : "text-foreground/80";
24842485 const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator;
24852486 const accessibleDisplayText = showFailedIndicator
2486 ? `${displayText}, tool call failed`
2487 : displayText;
2487 ? `${previewText}, tool call failed`
2488 : previewText;
24882489 const rowToggleProps = canExpand
24892490 ? {
24902491 role: "button" as const,
apps/web/src/session-logic.command-output.test.ts+87
......@@ -66,6 +66,93 @@ describe("deriveWorkLogEntries command output", () => {
6666 });
6767 });
6868
69 it("keeps command output that equals the command text", () => {
70 const [entry] = deriveWorkLogEntries([
71 makeCommandActivity("matching-output", {
72 itemType: "command_execution",
73 title: "Ran command",
74 detail: "printf hello",
75 data: {
76 command: "printf hello",
77 rawOutput: { content: "printf hello" },
78 },
79 }),
80 ]);
81
82 expect(entry).toMatchObject({
83 command: "printf hello",
84 detail: "printf hello",
85 });
86 });
87
88 it("keeps OpenCode detail-only output when it equals the command", () => {
89 const [entry] = deriveWorkLogEntries([
90 makeCommandActivity("opencode-detail-output", {
91 itemType: "command_execution",
92 title: "bash",
93 detail: "printf hello",
94 data: { command: "printf hello" },
95 }),
96 ]);
97
98 expect(entry).toMatchObject({
99 command: "printf hello",
100 detail: "printf hello",
101 });
102 });
103
104 it("drops a Claude tool-name detail when there is no output", () => {
105 const [entry] = deriveWorkLogEntries([
106 makeCommandActivity("claude-no-output", {
107 itemType: "command_execution",
108 title: "Command run",
109 detail: "Bash: printf hello",
110 data: {
111 toolName: "Bash",
112 command: "printf hello",
113 },
114 }),
115 ]);
116
117 expect(entry?.command).toBe("printf hello");
118 expect(entry?.detail).toBeUndefined();
119 });
120
121 it("drops a truncated Claude tool-name detail for a long command", () => {
122 const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
123 const [entry] = deriveWorkLogEntries([
124 makeCommandActivity("claude-long-command", {
125 itemType: "command_execution",
126 title: "Command run",
127 detail: `Bash: ${command}`.slice(0, 177) + "...",
128 data: {
129 toolName: "Bash",
130 command,
131 },
132 }),
133 ]);
134
135 expect(entry?.command).toBe(command);
136 expect(entry?.detail).toBeUndefined();
137 });
138
139 it("drops an ACP command echo when the update omits the tool kind", () => {
140 const [entry] = deriveWorkLogEntries([
141 makeCommandActivity("acp-no-kind", {
142 itemType: "command_execution",
143 title: "Terminal",
144 detail: "pnpm test",
145 data: {
146 toolCallId: "tool-1",
147 command: "pnpm test",
148 },
149 }),
150 ]);
151
152 expect(entry?.command).toBe("pnpm test");
153 expect(entry?.detail).toBeUndefined();
154 });
155
69156 it("drops duplicated command detail when the command has no output", () => {
70157 const [entry] = deriveWorkLogEntries([
71158 makeCommandActivity("empty-command", {
apps/web/src/session-logic.ts+24-83
......@@ -2,7 +2,11 @@ import * as Option from "effect/Option";
22import * as Arr from "effect/Array";
33import * as Schema from "effect/Schema";
44import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
5import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation";
5import {
6 commandDetailRepeatsCommand,
7 extractCommandOutputText,
8 isWorktreeSetupActivity,
9} from "@t3tools/client-runtime/work-log/presentation";
610import {
711 ApprovalRequestId,
812 isToolLifecycleItemType,
......@@ -1514,68 +1518,9 @@ function summarizeToolRawOutput(payload: Record<string, unknown> | null): string
15141518 return null;
15151519}
15161520
1517function extractAcpTextContent(value: unknown): string | null {
1518 if (!Array.isArray(value)) {
1519 return null;
1520 }
1521
1522 const chunks: string[] = [];
1523 for (const entryValue of value) {
1524 const entry = asRecord(entryValue);
1525 if (entry?.type !== "content") {
1526 continue;
1527 }
1528 const content = asRecord(entry.content);
1529 if (content?.type !== "text") {
1530 continue;
1531 }
1532 const text = asTrimmedString(content.text);
1533 if (text) {
1534 chunks.push(text);
1535 }
1536 }
1537
1538 return chunks.length > 0 ? chunks.join("\n") : null;
1539}
1540
15411521function extractToolOutput(payload: Record<string, unknown> | null): string | null {
1542 const data = asRecord(payload?.data);
1543 const item = asRecord(data?.item);
1544 const itemResult = asRecord(item?.result);
1545 const rawOutput = asRecord(data?.rawOutput);
1546
1547 const outputStreams: string[] = [];
1548 const stdout = asTrimmedString(rawOutput?.stdout);
1549 const stderr = asTrimmedString(rawOutput?.stderr);
1550 if (stdout) {
1551 outputStreams.push(stdout);
1552 }
1553 if (stderr) {
1554 outputStreams.push(stderr);
1555 }
1556
1557 const candidates: unknown[] = [
1558 item?.aggregatedOutput,
1559 itemResult?.content,
1560 data?.rawOutput,
1561 rawOutput?.content,
1562 outputStreams.length > 0 ? outputStreams.join("\n") : null,
1563 rawOutput?.output,
1564 extractAcpTextContent(data?.content),
1565 ];
1566
1567 for (const candidate of candidates) {
1568 const text = asTrimmedString(candidate);
1569 if (!text) {
1570 continue;
1571 }
1572 const output = stripTrailingExitCode(text).output;
1573 if (output) {
1574 return output;
1575 }
1576 }
1577
1578 return null;
1522 const output = extractCommandOutputText(payload?.data);
1523 return output ? stripTrailingExitCode(output).output : null;
15791524}
15801525
15811526function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean {
......@@ -1603,32 +1548,28 @@ function extractToolDetail(
16031548 ? extractToolCommand(payload)
16041549 : { command: null, rawCommand: null };
16051550 const command = commandPreview.command;
1606 const normalizedCommand = normalizePreviewForComparison(command);
1607 const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand);
16081551
1609 if (
1610 detail &&
1611 normalizedHeading !== normalizedDetail &&
1612 (!commandTool ||
1613 (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail))
1614 ) {
1552 if (commandTool && command) {
1553 const output = extractToolOutput(payload);
1554 if (output) return output;
1555 }
1556
1557 const data = asRecord(payload?.data);
1558 const repeatsCommand =
1559 detail !== null &&
1560 commandDetailRepeatsCommand({
1561 detail,
1562 command,
1563 rawCommand: commandPreview.rawCommand,
1564 toolName: data?.toolName,
1565 data,
1566 });
1567
1568 if (detail && normalizedHeading !== normalizedDetail && (!commandTool || !repeatsCommand)) {
16151569 return detail;
16161570 }
16171571
16181572 if (commandTool) {
1619 if (!command) {
1620 return null;
1621 }
1622
1623 const output = extractToolOutput(payload);
1624 const normalizedOutput = normalizePreviewForComparison(output);
1625 if (
1626 output &&
1627 normalizedOutput !== normalizedHeading &&
1628 normalizedOutput !== normalizedCommand
1629 ) {
1630 return output;
1631 }
16321573 return null;
16331574 }
16341575
packages/client-runtime/src/work-log/presentation.test.ts+85
......@@ -3,11 +3,96 @@ import { describe, expect, it } from "vite-plus/test";
33import { ThreadId } from "@t3tools/contracts";
44
55import {
6 commandDetailRepeatsCommand,
7 extractCommandOutputText,
68 resolveViewedImageAsset,
79 toolGroupAction,
810 workEntryViewedImagePath,
911} from "./presentation.js";
1012
13describe("command work-log details", () => {
14 it("extracts Claude result blocks and projected output", () => {
15 expect(
16 extractCommandOutputText({
17 result: {
18 content: [
19 { type: "text", text: "first" },
20 { type: "text", text: "second" },
21 ],
22 },
23 }),
24 ).toBe("first\nsecond");
25 expect(extractCommandOutputText({ rawOutput: { content: "projected summary" } })).toBe(
26 "projected summary",
27 );
28 });
29
30 it("only removes a detail with the matching tool-name prefix", () => {
31 expect(
32 commandDetailRepeatsCommand({
33 detail: "Bash: printf hello",
34 command: "printf hello",
35 rawCommand: null,
36 toolName: "Bash",
37 data: { toolName: "Bash", command: "printf hello" },
38 }),
39 ).toBe(true);
40 expect(
41 commandDetailRepeatsCommand({
42 detail: "warning: printf hello",
43 command: "printf hello",
44 rawCommand: null,
45 toolName: "Bash",
46 data: { toolName: "Bash", command: "printf hello" },
47 }),
48 ).toBe(false);
49 });
50
51 it("treats an ingestion-truncated echo of a long command as a repeat", () => {
52 const command = `git add -A && git commit -m "${"x".repeat(200)}"`;
53 const truncated = `Bash: ${command}`.slice(0, 177) + "...";
54 expect(
55 commandDetailRepeatsCommand({
56 detail: truncated,
57 command,
58 rawCommand: null,
59 toolName: "Bash",
60 data: { toolName: "Bash", command },
61 }),
62 ).toBe(true);
63 expect(
64 commandDetailRepeatsCommand({
65 detail: "Bash: printf hello...",
66 command: "printf goodbye",
67 rawCommand: null,
68 toolName: "Bash",
69 data: { toolName: "Bash", command: "printf goodbye" },
70 }),
71 ).toBe(false);
72 });
73
74 it("treats ACP command echoes as synthetic even without a tool kind", () => {
75 expect(
76 commandDetailRepeatsCommand({
77 detail: "pnpm test",
78 command: "pnpm test",
79 rawCommand: null,
80 toolName: undefined,
81 data: { toolCallId: "tool-1", command: "pnpm test" },
82 }),
83 ).toBe(true);
84 expect(
85 commandDetailRepeatsCommand({
86 detail: "pnpm test",
87 command: "pnpm test",
88 rawCommand: null,
89 toolName: undefined,
90 data: { command: "pnpm test" },
91 }),
92 ).toBe(false);
93 });
94});
95
1196describe("workEntryViewedImagePath", () => {
1297 const entry = { label: "Read", tone: "tool" } as const;
1398
packages/client-runtime/src/work-log/presentation.ts+141
......@@ -51,6 +51,147 @@ export function normalizeCompactToolLabel(value: string): string {
5151 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
5252}
5353
54function asRecord(value: unknown): Record<string, unknown> | null {
55 return value !== null && typeof value === "object" && !Array.isArray(value)
56 ? (value as Record<string, unknown>)
57 : null;
58}
59
60function nonEmptyString(value: unknown): string | null {
61 return typeof value === "string" && value.trim().length > 0 ? value : null;
62}
63
64function commandResultContent(value: unknown): string | null {
65 const direct = nonEmptyString(value);
66 if (direct) return direct;
67
68 const directContent = Array.isArray(value) ? value : null;
69 const record = asRecord(value);
70 const content = record?.content;
71 const contentText = nonEmptyString(content);
72 if (contentText) return contentText;
73 const blocks = directContent ?? (Array.isArray(content) ? content : null);
74 if (!blocks) return null;
75
76 const chunks = blocks.flatMap((entry) => {
77 const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text);
78 return text ? [text] : [];
79 });
80 return chunks.length > 0 ? chunks.join("\n") : null;
81}
82
83/** Returns provider command output before it is formatted for a work-log row. */
84export function extractCommandOutputText(dataValue: unknown): string | null {
85 const data = asRecord(dataValue);
86 const item = asRecord(data?.item);
87 const itemResult = asRecord(item?.result);
88 const rawOutput = asRecord(data?.rawOutput);
89 const outputStreams = [
90 nonEmptyString(rawOutput?.stdout),
91 nonEmptyString(rawOutput?.stderr),
92 ].filter((value): value is string => value !== null);
93 const acpContent = Array.isArray(data?.content)
94 ? data.content
95 .flatMap((entryValue) => {
96 const entry = asRecord(entryValue);
97 const content = asRecord(entry?.content);
98 const text = entry?.type === "content" ? nonEmptyString(content?.text) : null;
99 return text ? [text] : [];
100 })
101 .join("\n")
102 : null;
103
104 const candidates = [
105 item?.aggregatedOutput,
106 itemResult?.content,
107 data?.rawOutput,
108 rawOutput?.content,
109 outputStreams.length > 0 ? outputStreams.join("\n") : null,
110 rawOutput?.output,
111 acpContent,
112 data?.result,
113 ];
114 for (const candidate of candidates) {
115 const text = commandResultContent(candidate);
116 if (text) return text;
117 }
118 return null;
119}
120
121/**
122 * Ingestion caps tool details at 180 chars and appends "...", so a long command
123 * echo no longer equals the command it repeats. Treat a truncated prefix of the
124 * command as the same echo.
125 */
126function textRepeatsCommand(text: string, commands: ReadonlyArray<string | null>): boolean {
127 const truncated = text.endsWith("...")
128 ? text.slice(0, -3)
129 : text.endsWith("\u2026")
130 ? text.slice(0, -1)
131 : null;
132 return commands.some((candidate) => {
133 const command = candidate?.trim();
134 if (!command) return false;
135 if (command === text) return true;
136 return (
137 truncated !== null &&
138 truncated.length > 0 &&
139 command.length > truncated.length &&
140 command.startsWith(truncated)
141 );
142 });
143}
144
145/**
146 * Decides whether a command row's `detail` is a synthetic echo of the command
147 * rather than real output. OpenCode stores completed output in `detail` with no
148 * other output channel, so plain equality is only treated as synthetic when the
149 * payload shape shows the detail came from the command: Codex item metadata,
150 * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name
151 * prefix, or no structured command at all.
152 */
153export function commandDetailRepeatsCommand(input: {
154 readonly detail: string;
155 readonly command: string | null;
156 readonly rawCommand: string | null;
157 readonly toolName: unknown;
158 readonly data: unknown;
159}): boolean {
160 const toolName = nonEmptyString(input.toolName)?.trim();
161 const detail = input.detail.trim();
162 const commands = [input.command, input.rawCommand];
163 if (toolName) {
164 const prefix = `${toolName}:`;
165 if (detail.toLowerCase().startsWith(prefix.toLowerCase())) {
166 const unprefixed = detail.slice(prefix.length).trim();
167 if (textRepeatsCommand(unprefixed, commands)) return true;
168 }
169 }
170
171 if (!textRepeatsCommand(detail, commands)) return false;
172
173 const data = asRecord(input.data);
174 const item = asRecord(data?.item);
175 const itemInput = asRecord(item?.input);
176 const itemResult = asRecord(item?.result);
177 const hasStructuredCommand = [
178 item?.command,
179 itemInput?.command,
180 itemResult?.command,
181 data?.command,
182 ].some((value) =>
183 Array.isArray(value)
184 ? value.some((part) => nonEmptyString(part) !== null)
185 : nonEmptyString(value) !== null,
186 );
187 return (
188 !hasStructuredCommand ||
189 item !== null ||
190 data?.toolCallId !== undefined ||
191 nonEmptyString(data?.kind)?.toLowerCase() === "execute"
192 );
193}
194
54195function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
55196 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
56197 if (entry.command !== undefined && entry.command.trim().length > 0) return true;