Skip to content

Commit 439c32a

Browse files
fix(web): show command exit codes (#11)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 4ee204a commit 439c32a

7 files changed

Lines changed: 254 additions & 18 deletions

File tree

apps/server/src/orchestration/ActivityPayloadProjection.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
1414
} as unknown as OrchestrationThreadActivity;
1515
}
1616

17+
function commandActivity(data: Record<string, unknown>): OrchestrationThreadActivity {
18+
return activity({
19+
itemType: "command_execution",
20+
status: "completed",
21+
title: "Ran command",
22+
detail: "/bin/zsh -lc 'echo ping'",
23+
data,
24+
});
25+
}
26+
1727
/**
1828
* Wire-survival regression: the slimming pass rewrites payload.data but must
1929
* never strip the top-level per-agent fields the subagent fold depends on.
@@ -115,3 +125,73 @@ describe("projectActivityPayload agent-field survival", () => {
115125
expect(projected.payload).toEqual(source.payload);
116126
});
117127
});
128+
129+
describe("projectActivityPayload command exit codes", () => {
130+
it("retains a Codex command exit code while dropping command output", () => {
131+
const projected = projectActivityPayload(
132+
commandActivity({
133+
completedAtMs: 1_785_974_254_706,
134+
item: {
135+
aggregatedOutput: "ping\n",
136+
command: "/bin/zsh -lc 'echo ping'",
137+
exitCode: 0,
138+
status: "completed",
139+
},
140+
}),
141+
);
142+
143+
expect(projected.payload).toMatchObject({
144+
data: {
145+
item: {
146+
command: "/bin/zsh -lc 'echo ping'",
147+
exitCode: 0,
148+
},
149+
},
150+
});
151+
expect(JSON.stringify(projected.payload)).not.toContain("aggregatedOutput");
152+
});
153+
154+
it("retains an ACP command exit code alongside its compact output summary", () => {
155+
const projected = projectActivityPayload(
156+
commandActivity({
157+
kind: "execute",
158+
command: "bun run check",
159+
rawOutput: {
160+
exitCode: 17,
161+
stdout: "check failed\nmore detail",
162+
stderr: "",
163+
},
164+
}),
165+
);
166+
167+
expect(projected.payload).toMatchObject({
168+
data: {
169+
kind: "execute",
170+
command: "bun run check",
171+
rawOutput: {
172+
exitCode: 17,
173+
content: "check failed",
174+
},
175+
},
176+
});
177+
});
178+
179+
it("retains an exit code when ACP command output is empty", () => {
180+
const projected = projectActivityPayload(
181+
commandActivity({
182+
kind: "execute",
183+
rawOutput: {
184+
exitCode: 0,
185+
stdout: "",
186+
stderr: "",
187+
},
188+
}),
189+
);
190+
191+
expect(projected.payload).toMatchObject({
192+
data: {
193+
rawOutput: { exitCode: 0 },
194+
},
195+
});
196+
});
197+
});

apps/server/src/orchestration/ActivityPayloadProjection.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ function asTrimmedString(value: unknown): string | null {
1818
return trimmed.length > 0 ? trimmed : null;
1919
}
2020

21+
function asInteger(value: unknown): number | null {
22+
return typeof value === "number" && Number.isInteger(value) ? value : null;
23+
}
24+
2125
function pushChangedFile(target: string[], seen: Set<string>, value: unknown): void {
2226
const normalized = asTrimmedString(value);
2327
if (!normalized || seen.has(normalized)) {
@@ -90,15 +94,29 @@ function projectCommandData(data: Record<string, unknown>): Record<string, unkno
9094
if ("command" in item) {
9195
projectedItem.command = item.command;
9296
}
97+
const exitCode = asInteger(item.exitCode);
98+
if (exitCode !== null) {
99+
projectedItem.exitCode = exitCode;
100+
}
93101

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 resultExitCode = asInteger(result.exitCode);
114+
if (resultExitCode !== null) {
115+
projectedResult.exitCode = resultExitCode;
116+
}
117+
if (Object.keys(projectedResult).length > 0) {
118+
projectedItem.result = projectedResult;
119+
}
102120
}
103121

104122
return Object.keys(projectedItem).length > 0 ? projectedItem : undefined;
@@ -237,26 +255,38 @@ function projectRawOutput(value: unknown): Record<string, unknown> | undefined {
237255
return undefined;
238256
}
239257

258+
const projected: Record<string, unknown> = {};
259+
const exitCode = asInteger(rawOutput.exitCode);
260+
if (exitCode !== null) {
261+
projected.exitCode = exitCode;
262+
}
263+
240264
if (typeof rawOutput.totalFiles === "number" && Number.isFinite(rawOutput.totalFiles)) {
241-
return {
242-
totalFiles: rawOutput.totalFiles,
243-
...(rawOutput.truncated === true ? { truncated: true } : {}),
244-
};
265+
projected.totalFiles = rawOutput.totalFiles;
266+
if (rawOutput.truncated === true) {
267+
projected.truncated = true;
268+
}
269+
return projected;
245270
}
246271

247272
const content = asTrimmedString(rawOutput.content);
248273
if (content) {
249274
const summary = summarizeToolTextOutput(content);
250-
return summary ? { content: summary } : undefined;
275+
if (summary) {
276+
projected.content = summary;
277+
}
278+
return Object.keys(projected).length > 0 ? projected : undefined;
251279
}
252280

253281
const stdout = asTrimmedString(rawOutput.stdout);
254282
if (stdout) {
255283
const summary = summarizeToolTextOutput(stdout);
256-
return summary ? { content: summary } : undefined;
284+
if (summary) {
285+
projected.content = summary;
286+
}
257287
}
258288

259-
return undefined;
289+
return Object.keys(projected).length > 0 ? projected : undefined;
260290
}
261291

262292
/**
@@ -290,6 +320,14 @@ export function projectActivityPayload(
290320
if ("command" in data) {
291321
projectedData.command = data.command;
292322
}
323+
const exitCode = asInteger(data.exitCode);
324+
if (exitCode !== null) {
325+
projectedData.exitCode = exitCode;
326+
}
327+
const resultExitCode = asInteger(asRecord(data.result)?.exitCode);
328+
if (resultExitCode !== null) {
329+
projectedData.result = { exitCode: resultExitCode };
330+
}
293331

294332
const changedFiles: string[] = [];
295333
collectChangedFiles(data, changedFiles, new Set<string>(), 0);

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,15 +671,47 @@ describe("MessagesTimeline", () => {
671671
createdAt: "2026-03-17T19:12:28.000Z",
672672
label: "Glob",
673673
tone: "tool",
674+
itemType: "command_execution",
674675
toolLifecycleStatus: "failed",
675676
detail: "No files found",
677+
exitCode: 17,
676678
},
677679
},
678680
]}
679681
/>,
680682
);
681683

682684
expect(markup).toContain("lucide-x");
683-
expect(markup).toContain('aria-label="Tool call failed"');
685+
expect(markup).toContain('aria-label="Exit code 17"');
686+
expect(markup).toContain("Exit code 17");
687+
});
688+
689+
it("renders a zero exit code for successful commands", () => {
690+
const markup = renderToStaticMarkup(
691+
<MessagesTimeline
692+
{...buildProps()}
693+
timelineEntries={[
694+
{
695+
id: "entry-1",
696+
kind: "work",
697+
createdAt: "2026-03-17T19:12:28.000Z",
698+
entry: {
699+
id: "work-1",
700+
createdAt: "2026-03-17T19:12:28.000Z",
701+
label: "Ran command",
702+
tone: "tool",
703+
itemType: "command_execution",
704+
toolLifecycleStatus: "completed",
705+
command: "bun run test",
706+
exitCode: 0,
707+
},
708+
},
709+
]}
710+
/>,
711+
);
712+
713+
expect(markup).toContain("lucide-check");
714+
expect(markup).toContain('aria-label="Exit code 0"');
715+
expect(markup).toContain("Exit code 0");
684716
});
685717
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2239,6 +2239,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
22392239
const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot);
22402240
const canExpand = expandedBody !== null;
22412241
const showFailedIndicator = workEntryIndicatesToolFailure(workEntry);
2242+
const exitCodeLabel =
2243+
workEntry.exitCode === undefined ? null : `Exit code ${workEntry.exitCode.toString()}`;
22422244
const showDestructiveRowStyle =
22432245
showFailedIndicator &&
22442246
(workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry));
@@ -2324,18 +2326,23 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
23242326
render={
23252327
<span
23262328
className="flex size-4 items-center justify-center"
2327-
aria-label="Tool call failed"
2329+
aria-label={exitCodeLabel ?? "Tool call failed"}
23282330
/>
23292331
}
23302332
>
23312333
<XIcon className="block size-3 shrink-0 text-destructive" aria-hidden />
23322334
</TooltipTrigger>
2333-
<TooltipPopup>Failed</TooltipPopup>
2335+
<TooltipPopup>{exitCodeLabel ?? "Failed"}</TooltipPopup>
23342336
</Tooltip>
23352337
) : showSuccessIndicator ? (
23362338
<Tooltip>
23372339
<TooltipTrigger
2338-
render={<span className="flex size-4 items-center justify-center" />}
2340+
render={
2341+
<span
2342+
className="flex size-4 items-center justify-center"
2343+
aria-label={exitCodeLabel ?? "Tool call completed"}
2344+
/>
2345+
}
23392346
>
23402347
<span className="inline-flex size-4 items-center justify-center">
23412348
<CheckIcon
@@ -2345,7 +2352,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
23452352
/>
23462353
</span>
23472354
</TooltipTrigger>
2348-
<TooltipPopup>Completed</TooltipPopup>
2355+
<TooltipPopup>{exitCodeLabel ?? "Completed"}</TooltipPopup>
23492356
</Tooltip>
23502357
) : showNeutralIndicator ? (
23512358
<Tooltip>

apps/web/src/connection/storage.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const SERVER_CONFIG_STORE_NAME = "server-config";
4242
const VCS_REFS_STORE_NAME = "vcs-refs";
4343
const CATALOG_KEY = "document";
4444
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
45+
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 4;
4546

4647
const StoredShellSnapshot = Schema.Struct({
4748
schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
@@ -54,9 +55,11 @@ const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
5455
// v3 adds windowed (paginated) snapshots carrying `page` metadata. The bump
5556
// exists for rollback safety: a pre-pagination client would decode a windowed
5657
// v2 record, silently drop the unknown `page` field, and treat the partial
57-
// thread as complete forever. Older entries fail to decode → cold cache.
58+
// thread as complete forever. v4 refreshes cached activity payloads after
59+
// command exit codes were added to the compact server projection. Older entries
60+
// fail to decode and are treated as a cold cache.
5861
const StoredThreadSnapshot = Schema.Struct({
59-
schemaVersion: Schema.Literal(3),
62+
schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
6063
environmentId: EnvironmentId,
6164
threadId: ThreadId,
6265
snapshot: OrchestrationThreadDetailSnapshot,
@@ -564,7 +567,7 @@ export const connectionStorageLayer = Layer.effectContext(
564567
saveThread: (environmentId, snapshot) =>
565568
Effect.gen(function* () {
566569
const encoded = yield* encodeStoredThreadSnapshot({
567-
schemaVersion: 3,
570+
schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
568571
environmentId,
569572
threadId: snapshot.thread.id,
570573
snapshot,

apps/web/src/session-logic.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,18 @@ describe("workEntryIndicatesToolFailure", () => {
631631
).toBe(true);
632632
});
633633

634+
it("is true when a command has a nonzero exit code", () => {
635+
expect(
636+
workEntryIndicatesToolFailure({
637+
...base,
638+
tone: "tool",
639+
itemType: "command_execution",
640+
toolLifecycleStatus: "completed",
641+
exitCode: 17,
642+
}),
643+
).toBe(true);
644+
});
645+
634646
it("detects file-not-found style tool output with completed lifecycle", () => {
635647
expect(
636648
workEntryIndicatesToolFailure({
@@ -1131,9 +1143,9 @@ describe("deriveWorkLogEntries", () => {
11311143
data: {
11321144
item: {
11331145
command: ["bun", "run", "dev"],
1146+
exitCode: 0,
11341147
result: {
11351148
content: '{ "dev": "vite dev --port 3000" } <exited with exit code 0>',
1136-
exitCode: 0,
11371149
},
11381150
},
11391151
},
@@ -1145,11 +1157,43 @@ describe("deriveWorkLogEntries", () => {
11451157
expect(entry).toMatchObject({
11461158
command: "bun run dev",
11471159
detail: '{ "dev": "vite dev --port 3000" }',
1160+
exitCode: 0,
11481161
itemType: "command_execution",
11491162
toolTitle: "bash",
11501163
});
11511164
});
11521165

1166+
it("extracts command exit codes from ACP raw output", () => {
1167+
const activities: OrchestrationThreadActivity[] = [
1168+
makeActivity({
1169+
id: "command-tool-failed",
1170+
kind: "tool.completed",
1171+
summary: "Ran command",
1172+
payload: {
1173+
itemType: "command_execution",
1174+
status: "completed",
1175+
data: {
1176+
kind: "execute",
1177+
command: "bun run check",
1178+
rawOutput: {
1179+
exitCode: 17,
1180+
stdout: "",
1181+
stderr: "check failed",
1182+
},
1183+
},
1184+
},
1185+
}),
1186+
];
1187+
1188+
const [entry] = deriveWorkLogEntries(activities);
1189+
expect(entry).toMatchObject({
1190+
command: "bun run check",
1191+
exitCode: 17,
1192+
itemType: "command_execution",
1193+
});
1194+
expect(entry && workEntryIndicatesToolFailure(entry)).toBe(true);
1195+
});
1196+
11531197
it("extracts changed file paths for file-change tool activities", () => {
11541198
const activities: OrchestrationThreadActivity[] = [
11551199
makeActivity({

0 commit comments

Comments
 (0)