authorgravatar for 145937091+LikoKiko@users.noreply.github.comLikoKiko Tech <145937091+LikoKiko@users.noreply.github.com> 2026-08-15 15:01:45+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:01:45+00:00
log143f713c79103b8039a7e83cc16124aadcffa424
tree1c2b1905fcbebd48d02764bb26aa07ad47ed543c
parent5ffbf3ce4a4e8d14f8f37fae304167ad6c1e5218
signature Signed by PGP key B5690EEEBB952194

fix(web): show command output in work log (#4083)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 284 insertions(+), 5 deletions(-)

apps/server/src/orchestration/ActivityPayloadProjection.test.ts+53
......@@ -44,6 +44,59 @@ describe("projectActivityPayload agent-field survival", () => {
4444 expect(data.somethingClientNeverReads).toBeUndefined();
4545 });
4646
47 it("keeps a bounded Codex command output summary", () => {
48 const projected = projectActivityPayload(
49 activity({
50 itemType: "command_execution",
51 data: {
52 item: {
53 command: "/bin/zsh -lc 'printf hello'",
54 aggregatedOutput: `hello from codex\n${"x".repeat(5000)}`,
55 },
56 },
57 }),
58 );
59 const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
60 expect(data.item).toEqual({
61 command: "/bin/zsh -lc 'printf hello'",
62 aggregatedOutput: "hello from codex",
63 });
64 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
65 });
66
67 it("keeps bounded Claude and ACP command output summaries", () => {
68 const claude = projectActivityPayload(
69 activity({
70 itemType: "command_execution",
71 data: {
72 command: "printf hello",
73 rawOutput: { stdout: `hello from claude\n${"y".repeat(5000)}` },
74 },
75 }),
76 );
77 const acp = projectActivityPayload(
78 activity({
79 itemType: "command_execution",
80 data: {
81 command: "printf hello",
82 content: [
83 {
84 type: "content",
85 content: { type: "text", text: `hello from acp\n${"z".repeat(5000)}` },
86 },
87 ],
88 },
89 }),
90 );
91
92 const claudeData = (claude.payload as Record<string, unknown>).data as Record<string, unknown>;
93 const acpData = (acp.payload as Record<string, unknown>).data as Record<string, unknown>;
94 expect(claudeData.rawOutput).toEqual({ content: "hello from claude" });
95 expect(acpData.rawOutput).toEqual({ content: "hello from acp" });
96 expect(JSON.stringify(claude.payload).length).toBeLessThan(500);
97 expect(JSON.stringify(acp.payload).length).toBeLessThan(500);
98 });
99
47100 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
48101 const projected = projectActivityPayload(
49102 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+55-3
......@@ -91,14 +91,35 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
9191 projectedItem.command = item.command;
9292 }
9393
94 const aggregatedOutput = asTrimmedString(item.aggregatedOutput);
95 if (aggregatedOutput) {
96 const summary = summarizeToolTextOutput(aggregatedOutput);
97 if (summary) {
98 projectedItem.aggregatedOutput = summary;
99 }
100 }
101
94102 const input = asRecord(item.input);
95103 if (input && "command" in input) {
96104 projectedItem.input = { command: input.command };
97105 }
98106
99107 const result = asRecord(item.result);
100 if (result && "command" in result) {
101 projectedItem.result = { command: result.command };
108 if (result) {
109 const projectedResult: Record<string, unknown> = {};
110 if ("command" in result) {
111 projectedResult.command = result.command;
112 }
113 const content = asTrimmedString(result.content);
114 if (content) {
115 const summary = summarizeToolTextOutput(content);
116 if (summary) {
117 projectedResult.content = summary;
118 }
119 }
120 if (Object.keys(projectedResult).length > 0) {
121 projectedItem.result = projectedResult;
122 }
102123 }
103124
104125 return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
......@@ -232,6 +253,12 @@ function projectMcpToolCallData(data: Record<string, unknown>): Record<string, u
232253}
233254
234255function projectRawOutput(value: unknown): Record<string, unknown> | undefined {
256 const direct = asTrimmedString(value);
257 if (direct) {
258 const summary = summarizeToolTextOutput(direct);
259 return summary ? { content: summary } : undefined;
260 }
261
235262 const rawOutput = asRecord(value);
236263 if (!rawOutput) {
237264 return undefined;
......@@ -256,9 +283,34 @@ function projectRawOutput(value: unknown): Record<string, unknown> | undefined {
256283 return summary ? { content: summary } : undefined;
257284 }
258285
286 const stderr = asTrimmedString(rawOutput.stderr);
287 if (stderr) {
288 const summary = summarizeToolTextOutput(stderr);
289 return summary ? { content: summary } : undefined;
290 }
291
259292 return undefined;
260293}
261294
295function projectAcpContent(value: unknown): Record<string, unknown> | undefined {
296 if (!Array.isArray(value)) {
297 return undefined;
298 }
299
300 const text = value
301 .map((entryValue) => {
302 const entry = asRecord(entryValue);
303 const content = asRecord(entry?.content);
304 return entry?.type === "content" && content?.type === "text"
305 ? asTrimmedString(content.text)
306 : null;
307 })
308 .filter((entry): entry is string => entry !== null)
309 .join("\n");
310 const summary = summarizeToolTextOutput(text);
311 return summary ? { content: summary } : undefined;
312}
313
262314/**
263315 * Removes activity payload fields that no current client reads while retaining
264316 * the full payload in persistence and the event store.
......@@ -305,7 +357,7 @@ export function projectActivityPayload(
305357 projectedData.kind = data.kind;
306358 }
307359
308 const rawOutput = projectRawOutput(data.rawOutput);
360 const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content);
309361 if (rawOutput) {
310362 projectedData.rawOutput = rawOutput;
311363 }
apps/web/src/session-logic.command-output.test.ts created+85
......@@ -0,0 +1,85 @@
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { deriveWorkLogEntries } from "./session-logic";
5
6function makeCommandActivity(
7 id: string,
8 payload: Record<string, unknown>,
9): OrchestrationThreadActivity {
10 return {
11 id: EventId.make(id),
12 createdAt: "2026-07-17T10:00:00.000Z",
13 kind: "tool.completed",
14 summary: "Ran command",
15 tone: "tool",
16 payload,
17 turnId: TurnId.make("turn-1"),
18 };
19}
20
21describe("deriveWorkLogEntries command output", () => {
22 it("uses Codex aggregated output instead of repeating the command", () => {
23 const [entry] = deriveWorkLogEntries([
24 makeCommandActivity("codex-command", {
25 itemType: "command_execution",
26 title: "Ran command",
27 detail: "/bin/zsh -lc \"printf 'hello\\n'\"",
28 data: {
29 item: {
30 type: "commandExecution",
31 command: "/bin/zsh -lc \"printf 'hello\\n'\"",
32 commandActions: [{ command: "printf 'hello\\n'", type: "unknown" }],
33 aggregatedOutput: "hello\n<exited with exit code 0>",
34 status: "completed",
35 },
36 },
37 }),
38 ]);
39
40 expect(entry).toMatchObject({
41 command: "printf 'hello\\n'",
42 rawCommand: "/bin/zsh -lc \"printf 'hello\\n'\"",
43 detail: "hello",
44 });
45 });
46
47 it("uses a projected Claude output summary instead of repeating the command", () => {
48 const [entry] = deriveWorkLogEntries([
49 makeCommandActivity("claude-command", {
50 itemType: "command_execution",
51 title: "Ran command",
52 detail: "printf hello",
53 data: {
54 kind: "execute",
55 command: "printf hello",
56 rawOutput: {
57 content: "hello from claude",
58 },
59 },
60 }),
61 ]);
62
63 expect(entry).toMatchObject({
64 command: "printf hello",
65 detail: "hello from claude",
66 });
67 });
68
69 it("drops duplicated command detail when the command has no output", () => {
70 const [entry] = deriveWorkLogEntries([
71 makeCommandActivity("empty-command", {
72 itemType: "command_execution",
73 title: "Ran command",
74 detail: "true",
75 data: {
76 kind: "execute",
77 command: "true",
78 },
79 }),
80 ]);
81
82 expect(entry?.command).toBe("true");
83 expect(entry?.detail).toBeUndefined();
84 });
85});
apps/web/src/session-logic.ts+91-2
......@@ -1382,6 +1382,70 @@ function summarizeToolRawOutput(payload: Record<string, unknown> | null): string
13821382 return null;
13831383}
13841384
1385function extractAcpTextContent(value: unknown): string | null {
1386 if (!Array.isArray(value)) {
1387 return null;
1388 }
1389
1390 const chunks: string[] = [];
1391 for (const entryValue of value) {
1392 const entry = asRecord(entryValue);
1393 if (entry?.type !== "content") {
1394 continue;
1395 }
1396 const content = asRecord(entry.content);
1397 if (content?.type !== "text") {
1398 continue;
1399 }
1400 const text = asTrimmedString(content.text);
1401 if (text) {
1402 chunks.push(text);
1403 }
1404 }
1405
1406 return chunks.length > 0 ? chunks.join("\n") : null;
1407}
1408
1409function extractToolOutput(payload: Record<string, unknown> | null): string | null {
1410 const data = asRecord(payload?.data);
1411 const item = asRecord(data?.item);
1412 const itemResult = asRecord(item?.result);
1413 const rawOutput = asRecord(data?.rawOutput);
1414
1415 const outputStreams: string[] = [];
1416 const stdout = asTrimmedString(rawOutput?.stdout);
1417 const stderr = asTrimmedString(rawOutput?.stderr);
1418 if (stdout) {
1419 outputStreams.push(stdout);
1420 }
1421 if (stderr) {
1422 outputStreams.push(stderr);
1423 }
1424
1425 const candidates: unknown[] = [
1426 item?.aggregatedOutput,
1427 itemResult?.content,
1428 data?.rawOutput,
1429 rawOutput?.content,
1430 outputStreams.length > 0 ? outputStreams.join("\n") : null,
1431 rawOutput?.output,
1432 extractAcpTextContent(data?.content),
1433 ];
1434
1435 for (const candidate of candidates) {
1436 const text = asTrimmedString(candidate);
1437 if (!text) {
1438 continue;
1439 }
1440 const output = stripTrailingExitCode(text).output;
1441 if (output) {
1442 return output;
1443 }
1444 }
1445
1446 return null;
1447}
1448
13851449function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean {
13861450 const data = asRecord(payload?.data);
13871451 const kind = asTrimmedString(data?.kind)?.toLowerCase();
......@@ -1402,12 +1466,37 @@ function extractToolDetail(
14021466 const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null;
14031467 const normalizedHeading = normalizePreviewForComparison(heading);
14041468 const normalizedDetail = normalizePreviewForComparison(detail);
1469 const commandTool = isCommandToolDetail(payload, heading);
1470 const commandPreview = commandTool
1471 ? extractToolCommand(payload)
1472 : { command: null, rawCommand: null };
1473 const command = commandPreview.command;
1474 const normalizedCommand = normalizePreviewForComparison(command);
1475 const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand);
14051476
1406 if (detail && normalizedHeading !== normalizedDetail) {
1477 if (
1478 detail &&
1479 normalizedHeading !== normalizedDetail &&
1480 (!commandTool ||
1481 (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail))
1482 ) {
14071483 return detail;
14081484 }
14091485
1410 if (isCommandToolDetail(payload, heading)) {
1486 if (commandTool) {
1487 if (!command) {
1488 return null;
1489 }
1490
1491 const output = extractToolOutput(payload);
1492 const normalizedOutput = normalizePreviewForComparison(output);
1493 if (
1494 output &&
1495 normalizedOutput !== normalizedHeading &&
1496 normalizedOutput !== normalizedCommand
1497 ) {
1498 return output;
1499 }
14111500 return null;
14121501 }
14131502