diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 4ccdf686afa6..bc860f6bcdfc 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -274,6 +274,24 @@ function turnWithStatus(status: string, items: unknown[] = []): ProjectorNotific } as ProjectorNotification; } +function pendingCommandStarted(id: string): ProjectorNotification { + return forCurrentTurn("item/started", { + item: { + type: "commandExecution", + id, + command: "/bin/bash -lc 'sleep 600'", + cwd: "/workspace", + processId: null, + source: "agent", + status: "inProgress", + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }); +} + describe("CodexAppServerEventProjector", () => { it("projects assistant deltas and usage into embedded attempt results", async () => { const { onAssistantMessageStart, onPartialReply, projector } = @@ -1651,6 +1669,38 @@ describe("CodexAppServerEventProjector", () => { expect(result.assistantTexts).toEqual([]); }); + it("keeps missing tool detail without overriding an explicit abort", async () => { + const projector = await createProjector(); + projector.markAborted(); + + await projector.handleNotification(pendingCommandStarted("cmd-aborted")); + await projector.handleNotification(turnWithStatus("interrupted")); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.aborted).toBe(true); + expect(result.promptError).toBeNull(); + expect(result.promptErrorSource).toBeNull(); + expect(result.lastToolError).toMatchObject({ + toolName: "bash", + error: expect.stringContaining("without a matching tool.result"), + }); + }); + + it("fails closed when interrupted status has no abort marker", async () => { + const projector = await createProjector(); + + await projector.handleNotification(pendingCommandStarted("cmd-interrupted")); + await projector.handleNotification(turnWithStatus("interrupted")); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.aborted).toBe(false); + expect(result.promptError).toContain("without a matching tool.result"); + expect(result.promptErrorSource).toBe("prompt"); + expect(result.lastToolError).toBeUndefined(); + }); + it("does not fail a completed reply after a retryable app-server error notification", async () => { const projector = await createProjector(); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 99a83552a563..db0d59fc011d 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -784,7 +784,10 @@ export class CodexAppServerEventProjector { assistantTexts.some((text) => text.trim().length > 0); this.synthesizeMissingToolResults({ synthesize: legacyFailClosed, - recordPromptError: legacyFailClosed && !hasDeliverableAssistantOnCompletedTurn, + recordPromptError: + legacyFailClosed && + !hasDeliverableAssistantOnCompletedTurn && + !this.aborted, }); const lastAssistant = assistantTexts.length > 0