Skip to content

Commit af35a3f

Browse files
committed
fix(codex): preserve streamed command output
1 parent 893f580 commit af35a3f

2 files changed

Lines changed: 101 additions & 8 deletions

File tree

extensions/codex/src/app-server/event-projector.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,74 @@ describe("CodexAppServerEventProjector", () => {
13381338
});
13391339
});
13401340

1341+
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
1342+
const onAgentEvent = vi.fn();
1343+
const trajectoryRecorder = {
1344+
filePath: "trajectory.jsonl",
1345+
recordEvent: vi.fn(),
1346+
flush: vi.fn(async () => undefined),
1347+
};
1348+
const projector = await createProjector(
1349+
{
1350+
...(await createParams()),
1351+
onAgentEvent,
1352+
},
1353+
{
1354+
trajectoryRecorder,
1355+
},
1356+
);
1357+
1358+
await projector.handleNotification(
1359+
forCurrentTurn("item/commandExecution/outputDelta", {
1360+
itemId: "cmd-1",
1361+
delta: "status passed\n",
1362+
}),
1363+
);
1364+
await projector.handleNotification(
1365+
forCurrentTurn("item/commandExecution/outputDelta", {
1366+
itemId: "cmd-1",
1367+
delta: "json /tmp/scenario.json\n",
1368+
}),
1369+
);
1370+
await projector.handleNotification(
1371+
turnCompleted([
1372+
{
1373+
type: "commandExecution",
1374+
id: "cmd-1",
1375+
command: "python scripts/run_demo_scenario.py",
1376+
cwd: "/workspace",
1377+
processId: null,
1378+
source: "agent",
1379+
status: "completed",
1380+
commandActions: [],
1381+
aggregatedOutput: null,
1382+
exitCode: 0,
1383+
durationMs: 42,
1384+
},
1385+
]),
1386+
);
1387+
1388+
const result = projector.buildResult(buildEmptyToolTelemetry());
1389+
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
1390+
const toolResultContent = requireArray(toolResultMessage.content, "tool result content");
1391+
const toolResultContentItem = requireRecord(toolResultContent[0], "tool result content item");
1392+
expect(toolResultContentItem.content).toBe("status passed\njson /tmp/scenario.json");
1393+
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith(
1394+
"tool.result",
1395+
expect.objectContaining({
1396+
itemId: "cmd-1",
1397+
output: "status passed\njson /tmp/scenario.json",
1398+
}),
1399+
);
1400+
const toolResult = findAgentEvent(onAgentEvent, {
1401+
stream: "tool",
1402+
phase: "result",
1403+
itemId: "cmd-1",
1404+
name: "bash",
1405+
}).data;
1406+
expect(toolResult.result).toEqual({ status: "completed", exitCode: 0, durationMs: 42 });
1407+
});
1408+
13411409
it("does not duplicate native tool starts when the snapshot completes a started item", async () => {
13421410
const onAgentEvent = vi.fn();
13431411
const trajectoryRecorder = {

extensions/codex/src/app-server/event-projector.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export class CodexAppServerEventProjector {
126126
string,
127127
{ chars: number; messages: number; truncated: boolean }
128128
>();
129+
private readonly toolResultOutputTextByItem = new Map<string, string>();
129130
private readonly toolMetas = new Map<string, { toolName: string; meta?: string }>();
130131
private readonly toolTranscriptMessages: AgentMessage[] = [];
131132
private readonly toolTranscriptCallIds = new Set<string>();
@@ -685,7 +686,11 @@ export class CodexAppServerEventProjector {
685686
private handleOutputDelta(params: JsonObject, toolName: string): void {
686687
const itemId = readString(params, "itemId");
687688
const delta = readString(params, "delta");
688-
if (!itemId || !delta || !this.shouldEmitToolOutput()) {
689+
if (!itemId || !delta) {
690+
return;
691+
}
692+
appendToolOutputDeltaText(this.toolResultOutputTextByItem, itemId, delta);
693+
if (!this.shouldEmitToolOutput()) {
689694
return;
690695
}
691696
const state = this.toolResultOutputDeltaState.get(itemId) ?? {
@@ -920,7 +925,7 @@ export class CodexAppServerEventProjector {
920925
return;
921926
}
922927
const toolResult = itemToolResult(params.item).result;
923-
const output = itemOutputText(params.item);
928+
const output = itemOutputText(params.item, this.toolResultOutputTextByItem);
924929
this.options.trajectoryRecorder?.recordEvent("tool.result", {
925930
threadId: this.threadId,
926931
turnId: this.turnId,
@@ -1061,7 +1066,7 @@ export class CodexAppServerEventProjector {
10611066
return;
10621067
}
10631068
const toolName = itemName(item);
1064-
const output = itemOutputText(item);
1069+
const output = itemOutputText(item, this.toolResultOutputTextByItem);
10651070
if (!toolName || !output) {
10661071
return;
10671072
}
@@ -1151,7 +1156,7 @@ export class CodexAppServerEventProjector {
11511156
this.recordToolTranscriptResult({
11521157
id: item.id,
11531158
name,
1154-
text: itemTranscriptResultText(item),
1159+
text: itemTranscriptResultText(item, this.toolResultOutputTextByItem),
11551160
isError: isNonSuccessItemStatus(itemStatus(item)),
11561161
});
11571162
}
@@ -1716,9 +1721,12 @@ function itemMeta(
17161721
return undefined;
17171722
}
17181723

1719-
function itemOutputText(item: CodexThreadItem): string | undefined {
1724+
function itemOutputText(
1725+
item: CodexThreadItem,
1726+
outputTextByItem?: ReadonlyMap<string, string>,
1727+
): string | undefined {
17201728
if (item.type === "commandExecution") {
1721-
return item.aggregatedOutput?.trim() || undefined;
1729+
return item.aggregatedOutput?.trim() || outputTextByItem?.get(item.id)?.trim() || undefined;
17221730
}
17231731
if (item.type === "dynamicToolCall") {
17241732
return collectDynamicToolContentText(item.contentItems).trim() || undefined;
@@ -1732,15 +1740,32 @@ function itemOutputText(item: CodexThreadItem): string | undefined {
17321740
return undefined;
17331741
}
17341742

1735-
function itemTranscriptResultText(item: CodexThreadItem): string | undefined {
1736-
const output = itemOutputText(item);
1743+
function itemTranscriptResultText(
1744+
item: CodexThreadItem,
1745+
outputTextByItem?: ReadonlyMap<string, string>,
1746+
): string | undefined {
1747+
const output = itemOutputText(item, outputTextByItem);
17371748
if (output) {
17381749
return output;
17391750
}
17401751
const result = itemToolResult(item).result;
17411752
return result ? stringifyJsonValue(result) : itemStatus(item);
17421753
}
17431754

1755+
function appendToolOutputDeltaText(
1756+
outputTextByItem: Map<string, string>,
1757+
itemId: string,
1758+
delta: string,
1759+
): void {
1760+
const current = outputTextByItem.get(itemId) ?? "";
1761+
if (current.length >= TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS) {
1762+
return;
1763+
}
1764+
const remaining = TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS - current.length;
1765+
const next = current + (delta.length > remaining ? delta.slice(0, remaining) : delta);
1766+
outputTextByItem.set(itemId, next);
1767+
}
1768+
17441769
function normalizeToolTranscriptArguments(value: unknown): Record<string, unknown> {
17451770
if (!value || typeof value !== "object" || Array.isArray(value)) {
17461771
return {};

0 commit comments

Comments
 (0)