diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index dc21a60a..db3c6e34 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -134,22 +134,24 @@ export interface SessionState { } export type SessionFailureCategory = - | "transport_lost" | "auth_required" | "rate_limited" | "quota_exhausted" | "overloaded" - | "context_exhausted" | "budget_exhausted" | "policy_denied" | "bad_request" - | "provider_error" | "internal_error"; + | "connection" | "access" | "limit" | "request" | "service" | "unknown"; -export type SessionFailureAction = "retry" | "reconnect" | "login" | "new_turn" | "new_session"; +export type SessionFailureAction = "retry" | "login" | "new_session"; + +/** + * How loudly the client should render the record. Absent on the wire means `error`, so an AIR build + * that predates warning support keeps treating every record it receives as a failure. + */ +export type SessionFailureSeverity = "error" | "warning"; export interface SessionFailure { id: string; revision: number; - phase: "active" | "cleared"; category: SessionFailureCategory; - source: "codex"; - safeMessage: string; - retryable: boolean; + severity: SessionFailureSeverity; + title: string; + details?: string; actions: SessionFailureAction[]; - turnId?: string; } const CODEX_PROCESS_EXITED_ERROR_CODE = 1001; @@ -2044,9 +2046,10 @@ export class CodexAcpServer { let eventHandler: CodexEventHandler | null = null; let promptNotificationsActive = true; const clearRecoveredSessionFailure = async (handler: CodexEventHandler): Promise => { + await handler.completeSuccessfulTurn(sessionState.currentTurnId); const current = sessionState.sessionFailure; - if (recoverableSessionFailure?.phase === "active" - && current?.phase === "active" + if (recoverableSessionFailure !== undefined + && current !== undefined && current.id === recoverableSessionFailure.id && current.revision === recoverableSessionFailure.revision) { await handler.clearSessionFailure(); @@ -2372,8 +2375,10 @@ export class CodexAcpServer { if (eventHandler !== null && clientSupportsTypedSessionFailures(this.clientCapabilities) && (isProcessExit || isUnexpectedFailure)) { - const category: SessionFailureCategory = isProcessExit ? "transport_lost" : "internal_error"; - eventHandler.recordSyntheticTerminalFailure(category, sessionState.currentTurnId); + eventHandler.recordSyntheticTerminalFailure( + isProcessExit ? "transport_lost" : "internal_error", + sessionState.currentTurnId, + ); const failureResponse = this.terminalFailurePromptResponse( sessionState, eventHandler, diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index df2ebf95..6418b624 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -4,6 +4,7 @@ import type { ServerNotification } from "./app-server"; import type { + SessionFailure, SessionFailureAction, SessionFailureCategory, SessionState, @@ -16,6 +17,7 @@ import type { CodexErrorInfo, CommandExecutionOutputDeltaNotification, ConfigWarningNotification, + DeprecationNoticeNotification, ErrorNotification, ItemGuardianApprovalReviewCompletedNotification, ItemGuardianApprovalReviewStartedNotification, @@ -87,24 +89,76 @@ export type CompletedPlan = { text: string; }; -const SESSION_FAILURE_PRESENTATION: Record = { - transport_lost: {message: "Connection to Codex was lost.", retryable: true, actions: ["reconnect", "retry"]}, - auth_required: {message: "Sign in to continue using Codex.", retryable: false, actions: ["login"]}, - rate_limited: {message: "The Codex rate limit was reached.", retryable: true, actions: ["retry"]}, - quota_exhausted: {message: "The Codex usage quota is exhausted.", retryable: false, actions: ["new_session"]}, - overloaded: {message: "Codex is temporarily overloaded.", retryable: true, actions: ["retry"]}, - context_exhausted: {message: "This conversation has reached its context limit.", retryable: false, actions: ["new_turn"]}, - budget_exhausted: {message: "This session has reached its usage budget.", retryable: false, actions: ["new_session"]}, - policy_denied: {message: "The request was blocked by provider policy.", retryable: false, actions: []}, - bad_request: {message: "Codex could not process this request.", retryable: false, actions: ["new_turn"]}, - provider_error: {message: "The model provider reported an error.", retryable: true, actions: ["retry"]}, - internal_error: {message: "Codex encountered an internal error.", retryable: true, actions: ["retry"]}, }; +const MAX_SESSION_FAILURE_TITLE_LENGTH = 240; + +const SESSION_FAILURE_POLICY: Record = { + transport_lost: { + category: "connection", + actions: ["retry", "new_session"], + }, + auth_required: { + category: "access", + actions: ["login"], + }, + rate_limited: { + category: "limit", + actions: ["retry"], + }, + quota_exhausted: { + category: "limit", + actions: [], + }, + overloaded: { + category: "service", + actions: ["retry"], + }, + context_exhausted: { + category: "limit", + actions: ["new_session"], + }, + budget_exhausted: { + category: "limit", + actions: ["new_session"], + }, + policy_denied: { + category: "request", + actions: [], + }, + bad_request: { + category: "request", actions: [], + }, + provider_error: { + category: "service", + actions: ["retry"], + }, + internal_error: { + category: "service", + actions: ["retry", "new_session"], + }, +}; + +const SYNTHETIC_FAILURE_TITLE: Record<"transport_lost" | "internal_error", string> = { + transport_lost: "Connection to Codex was lost.", + internal_error: "Codex encountered an internal error.", +}; + +/** + * Records sharing an id form one logical banner whose revisions must increase; a new id restarts at 1. + */ +function nextSessionFailureRevision(previous: SessionFailure | undefined, id: string): number { + return previous?.id === id ? previous.revision + 1 : 1; +} + type StringCodexErrorInfo = Extract; type StructuredCodexErrorInfo = Exclude; type KeysOfUnion = T extends unknown ? keyof T : never; @@ -127,7 +181,7 @@ const STRING_CODEX_ERROR_CATEGORIES = { threadRollbackFailed: "provider_error", sandboxError: "provider_error", other: "provider_error", -} satisfies Record; +} satisfies Record; const STRUCTURED_CODEX_ERROR_CATEGORIES = { httpConnectionFailed: "transport_lost", @@ -135,7 +189,7 @@ const STRUCTURED_CODEX_ERROR_CATEGORIES = { responseStreamDisconnected: "transport_lost", responseTooManyFailedAttempts: "transport_lost", activeTurnNotSteerable: "provider_error", -} satisfies Record; +} satisfies Record; export class CodexEventHandler { @@ -146,6 +200,12 @@ export class CodexEventHandler { private readonly supportsTypedSessionFailures: boolean; private readonly sessionFailureEpoch: string; private readonly pendingErrors: ErrorNotification[] = []; + private readonly failuresById = new Map(); + private readonly activeFailureIdByScope = new Map(); + private readonly failureTurnIdById = new Map(); + private readonly allocatedFailureScopes = new Set(); + private lastSessionNotice: {key: string; failure: SessionFailure} | undefined; + private nextNoticeId = 1; private failure: RequestError | null = null; private completedPlan: CompletedPlan | null = null; private readonly activeFuzzyFileSearchSessions = new Set(); @@ -177,6 +237,9 @@ export class CodexEventHandler { this.supportsTypedSessionFailures = supportsTypedSessionFailures; this.sessionFailureEpoch = sessionFailureEpoch; this.session = new ACPSessionConnection(connection, sessionState.sessionId); + if (sessionState.sessionFailure !== undefined) { + this.failuresById.set(sessionState.sessionFailure.id, sessionState.sessionFailure); + } } getFailure(): RequestError | null { @@ -189,17 +252,17 @@ export class CodexEventHandler { ): Record | null { const failure = this.sessionState.sessionFailure; if (!this.supportsTypedSessionFailures - || failure?.phase !== "active" - || (failure.turnId === undefined + || failure === undefined + || (this.failureTurnIdById.get(failure.id) === undefined ? !allowUnattributed - : turnId === null || failure.turnId !== turnId)) { + : turnId === null || this.failureTurnIdById.get(failure.id) !== turnId)) { return null; } return this.createSessionFailureMeta(failure); } - recordSyntheticTerminalFailure(category: SessionFailureCategory, turnId: string | null): void { - this.recordSessionFailure(category, turnId ?? undefined); + recordSyntheticTerminalFailure(kind: "transport_lost" | "internal_error", turnId: string | null): void { + this.recordSessionFailure(kind, turnId ?? undefined, "error", SYNTHETIC_FAILURE_TITLE[kind]); } /** @@ -218,12 +281,16 @@ export class CodexEventHandler { return; } if (notification.params.willRetry) { - await this.session.update(this.createSafeErrorDiagnostic(notification.params)); + await this.session.update(this.createSessionFailureUpdate(this.recordRetryWarning(notification.params, false))); return; } const failure = this.recordSessionFailure( - this.sessionFailureCategory(notification.params.error.codexErrorInfo), + this.sessionFailureKind(notification.params.error.codexErrorInfo), + notification.params.turnId, + "error", + notification.params.error.message, undefined, + false, ); await this.session.update(this.createSessionFailureUpdate(failure)); } @@ -252,17 +319,16 @@ export class CodexEventHandler { } async clearSessionFailure(): Promise { + delete this.sessionState.sessionFailure; + } + + async completeSuccessfulTurn(turnId: string | null): Promise { + this.lastSessionNotice = undefined; + if (!this.supportsTypedSessionFailures || turnId === null) return; const active = this.sessionState.sessionFailure; - if (!this.supportsTypedSessionFailures || active?.phase !== "active") { - return; - } - const cleared = { - ...active, - revision: active.revision + 1, - phase: "cleared" as const, - }; - await this.session.update(this.createSessionFailureUpdate(cleared)); - this.sessionState.sessionFailure = cleared; + if (active?.id !== this.activeFailureIdByScope.get(turnId) || active?.severity !== "warning") return; + this.activeFailureIdByScope.delete(turnId); + delete this.sessionState.sessionFailure; } async handleFailedTurn(turn: Turn): Promise { @@ -270,7 +336,7 @@ export class CodexEventHandler { if (!this.supportsTypedSessionFailures || turn.status !== "failed" || this.failure !== null - || (activeFailure?.phase === "active" && activeFailure.turnId === turn.id)) { + || this.failureTurnIdById.get(activeFailure?.id ?? "") === turn.id && activeFailure?.severity === "error") { return; } const error = turn.error ?? { @@ -343,14 +409,19 @@ export class CodexEventHandler { */ switch (notification.method) { case "item/agentMessage/delta": + this.completeRetryIncidentOnTurnProgress(); return await this.createTextEvent(notification.params); case "item/plan/delta": + this.completeRetryIncidentOnTurnProgress(); return this.createPlanDeltaEvent(notification.params); case "item/started": + this.completeRetryIncidentOnTurnProgress(); return await this.createItemEvent(notification.params); case "item/completed": + this.completeRetryIncidentOnTurnProgress(); return await this.completeItemEvent(notification.params); case "turn/plan/updated": + this.completeRetryIncidentOnTurnProgress(); return await this.updatePlan(notification.params); case "error": return await this.createErrorEvent(notification.params); @@ -391,8 +462,10 @@ export class CodexEventHandler { closed: true, }); case "item/commandExecution/outputDelta": + this.completeRetryIncidentOnTurnProgress(); return this.createCommandOutputDeltaEvent(notification.params); case "item/mcpToolCall/progress": + this.completeRetryIncidentOnTurnProgress(); return this.createMcpToolProgressEvent(notification.params); case "account/rateLimits/updated": this.handleRateLimitsUpdated(notification.params); @@ -403,6 +476,8 @@ export class CodexEventHandler { return this.createWarningEvent(notification.params); case "guardianWarning": return null; + case "deprecationNotice": + return this.createDeprecationNoticeEvent(notification.params); case "item/autoApprovalReview/started": return this.handleGuardianApprovalReviewStarted(notification.params); case "item/autoApprovalReview/completed": @@ -410,10 +485,13 @@ export class CodexEventHandler { case "thread/compacted": return this.createContextCompactedEvent(); case "item/reasoning/summaryTextDelta": + this.completeRetryIncidentOnTurnProgress(); return this.createReasoningDeltaEvent(notification.params); case "item/reasoning/textDelta": + this.completeRetryIncidentOnTurnProgress(); return this.createReasoningDeltaEvent(notification.params); case "item/reasoning/summaryPartAdded": + this.completeRetryIncidentOnTurnProgress(); return this.createReasoningSectionBreakEvent(notification.params); case "model/rerouted": return this.createModelReroutedEvent(notification.params); @@ -456,7 +534,6 @@ export class CodexEventHandler { case "windowsSandbox/setupCompleted": case "account/login/completed": case "skills/changed": - case "deprecationNotice": case "mcpServer/oauthLogin/completed": case "externalAgentConfig/import/completed": case "rawResponseItem/completed": @@ -487,11 +564,29 @@ export class CodexEventHandler { } private async createConfigWarningEvent(event: ConfigWarningNotification): Promise { - const detailsText = event.details ? `\n\n${event.details}` : ""; - return createAgentTextMessageChunk(`Config warning: ${event.summary}${detailsText}\n\n`); + if (this.supportsTypedSessionFailures) { + return this.createSessionFailureUpdate(this.recordSessionNotice(...this.sessionNoticeContent(event.summary, event.details))); + } + const text = event.details ? `${event.summary}\n\n${event.details}` : event.summary; + return createAgentTextMessageChunk(`Config warning: ${text}\n\n`); + } + + /** + * Unlike `warning` and `configWarning`, this notification was dropped outright, so there is no + * legacy rendering to preserve. It is surfaced only to clients that negotiated typed records; + * every other client keeps seeing exactly what it sees today, which is nothing. + */ + private createDeprecationNoticeEvent(event: DeprecationNoticeNotification): UpdateSessionEvent | null { + if (!this.supportsTypedSessionFailures) return null; + return this.createSessionFailureUpdate( + this.recordSessionNotice(...this.sessionNoticeContent(event.summary, event.details)), + ); } private createWarningEvent(event: WarningNotification): UpdateSessionEvent { + if (this.supportsTypedSessionFailures) { + return this.createSessionFailureUpdate(this.recordSessionNotice(event.message)); + } return createAgentTextMessageChunk(`Warning: ${event.message}\n\n`); } @@ -909,7 +1004,15 @@ export class CodexEventHandler { } if (params.turnId !== this.sessionState.currentTurnId) { if (this.supportsTypedSessionFailures) { - return this.createSafeErrorDiagnostic(params); + const failure = params.willRetry + ? this.recordRetryWarning(params) + : this.recordSessionFailure( + this.sessionFailureKind(params.error.codexErrorInfo), + params.turnId, + "error", + params.error.message, + ); + return this.createSessionFailureUpdate(failure); } return this.createCodexSessionInfoUpdate({ error: {...params.error, turnId: params.turnId, willRetry: params.willRetry}, @@ -917,7 +1020,7 @@ export class CodexEventHandler { } if (params.willRetry) { if (this.supportsTypedSessionFailures) { - return this.createSafeErrorDiagnostic(params); + return this.createSessionFailureUpdate(this.recordRetryWarning(params)); } return this.createCodexSessionInfoUpdate({ error: { @@ -945,49 +1048,106 @@ export class CodexEventHandler { return createAgentTextMessageChunk(`${params.error.message}\n\n`); } - private createSafeErrorDiagnostic(params: ErrorNotification): UpdateSessionEvent { - const category = this.sessionFailureCategory(params.error.codexErrorInfo); - return this.createCodexSessionInfoUpdate({ - error: { - category, - message: SESSION_FAILURE_PRESENTATION[category].message, - turnId: params.turnId, - willRetry: params.willRetry, - }, - }); - } - private recordTypedSessionFailure(params: ErrorNotification): void { - const category = this.sessionFailureCategory(params.error.codexErrorInfo); - this.recordSessionFailure(category, params.turnId); + const kind = this.sessionFailureKind(params.error.codexErrorInfo); + this.recordSessionFailure(kind, params.turnId, "error", params.error.message); } private recordSessionFailure( - category: SessionFailureCategory, + kind: CodexFailureKind, turnId: string | undefined, + severity: "warning" | "error", + title: string, + actionsOverride?: SessionFailureAction[], + attributeToTurn = true, ): NonNullable { - const presentation = SESSION_FAILURE_PRESENTATION[category]; - const previous = this.sessionState.sessionFailure; - const id = previous?.phase === "active" - ? previous.id - : turnId === undefined - ? `${this.sessionState.sessionId}:error:${this.sessionFailureEpoch}` - : `${turnId}:error`; + const policy = SESSION_FAILURE_POLICY[kind]; + const scope = turnId ?? this.sessionState.sessionId; + const id = this.activeFailureIdByScope.get(scope) + ?? this.allocateFailureId(scope, turnId); + const previous = this.failuresById.get(id); const failure: NonNullable = { id, - revision: previous?.id === id ? previous.revision + 1 : 1, - phase: "active" as const, - category, - source: "codex", - safeMessage: presentation.message, - retryable: presentation.retryable, - actions: presentation.actions, - ...(turnId === undefined ? {} : {turnId}), + revision: nextSessionFailureRevision(previous, id), + category: policy.category, + severity, + title, + actions: actionsOverride ?? policy.actions, }; + this.failuresById.set(id, failure); + this.failureTurnIdById.set(id, attributeToTurn ? turnId : undefined); + this.activeFailureIdByScope.set(scope, id); this.sessionState.sessionFailure = failure; + this.lastSessionNotice = undefined; return failure; } + private allocateFailureId(scope: string, turnId: string | undefined): string { + if (turnId !== undefined && !this.allocatedFailureScopes.has(scope)) { + this.allocatedFailureScopes.add(scope); + return `${turnId}:error`; + } + this.allocatedFailureScopes.add(scope); + return `${scope}:error:${this.sessionFailureEpoch}:${this.nextNoticeId++}`; + } + + /** + * A retry warning remains the active incident until Codex produces turn content again. That content is + * the only positive signal available from app-server that the turn recovered; a later error then starts + * a new incident instead of overwriting the historical reconnect entry. Terminal errors remain active so + * duplicate late notifications cannot append duplicate transcript rows. + */ + private completeRetryIncidentOnTurnProgress(): void { + const turnId = this.sessionState.currentTurnId; + if (turnId === null) return; + const activeId = this.activeFailureIdByScope.get(turnId); + if (activeId === undefined || this.failuresById.get(activeId)?.severity !== "warning") return; + this.activeFailureIdByScope.delete(turnId); + if (this.sessionState.sessionFailure?.id === activeId) { + delete this.sessionState.sessionFailure; + } + } + + private recordRetryWarning(params: ErrorNotification, attributeToTurn = true): SessionFailure { + const kind = this.sessionFailureKind(params.error.codexErrorInfo); + return this.recordSessionFailure( + kind, + params.turnId, + "warning", + params.error.message, + [], + attributeToTurn, + ); + } + + private recordSessionNotice(title: string, details?: string): SessionFailure { + const key = `${title}\u0000${details ?? ""}`; + const previous = this.lastSessionNotice?.key === key + ? this.lastSessionNotice.failure + : undefined; + const id = previous?.id + ?? `${this.sessionState.sessionId}:notice:${this.sessionFailureEpoch}:${this.nextNoticeId++}`; + const notice: SessionFailure = { + id, + revision: nextSessionFailureRevision(previous, id), + category: "unknown", + severity: "warning", + title, + ...(details === undefined ? {} : {details}), + actions: [], + }; + this.lastSessionNotice = {key, failure: notice}; + return notice; + } + + private sessionNoticeContent(summary: string, details: string | null): [title: string, details?: string] { + if (details === null) return [summary]; + const combinedTitle = `${summary} — ${details}`; + return combinedTitle.length <= MAX_SESSION_FAILURE_TITLE_LENGTH + ? [combinedTitle] + : [summary, details]; + } + private createSessionFailureMeta( failure: NonNullable, ): Record { @@ -1010,7 +1170,7 @@ export class CodexEventHandler { }; } - private sessionFailureCategory(error: CodexErrorInfo | null): SessionFailureCategory { + private sessionFailureKind(error: CodexErrorInfo | null): CodexFailureKind { if (this.isAuthenticationRequiredError(error)) return "auth_required"; if (this.getHttpStatusCode(error) === 429) return "rate_limited"; if (typeof error === "string") { diff --git a/src/__tests__/CodexACPAgent/auth-error-events.test.ts b/src/__tests__/CodexACPAgent/auth-error-events.test.ts index e49fdafa..ebfc7d13 100644 --- a/src/__tests__/CodexACPAgent/auth-error-events.test.ts +++ b/src/__tests__/CodexACPAgent/auth-error-events.test.ts @@ -78,7 +78,7 @@ describe("CodexEventHandler - auth error events", () => { sessionId: "typed-failure-session", account: { type: "apiKey" }, }), { - message: "raw upstream payload must not be shown", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret raw details", }, false, typedFailureCapabilities); @@ -92,25 +92,21 @@ describe("CodexEventHandler - auth error events", () => { sessionFailure: { id: "turn-id:error", revision: 1, - phase: "active", - category: "overloaded", - source: "codex", - safeMessage: "Codex is temporarily overloaded.", - retryable: true, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", actions: ["retry"], - turnId: "turn-id", }, }, }, }, }); - expect(JSON.stringify(result)).not.toContain("raw upstream payload"); expect(JSON.stringify(result)).not.toContain("secret raw details"); expect(updates).toEqual([]); const wire = JSON.parse(JSON.stringify({jsonrpc: "2.0", id: 1, result})); expect(wire.result._meta.jetbrains.air.sessionFailure).toEqual( - expect.objectContaining({id: "turn-id:error", category: "overloaded"}), + expect.objectContaining({id: "turn-id:error", category: "service"}), ); }); @@ -119,28 +115,21 @@ describe("CodexEventHandler - auth error events", () => { sessionId: "foreign-turn-session", account: { type: "apiKey" }, }), { - message: "another turn failed", + message: "Codex could not complete the previous turn.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret foreign-turn details", }, false, typedFailureCapabilities, "foreign-turn"); expect(result).toMatchObject({stopReason: "end_turn"}); - expect(JSON.stringify(updates)).not.toContain("sessionFailure"); - expect(JSON.stringify(updates)).not.toContain("another turn failed"); expect(JSON.stringify(updates)).not.toContain("secret foreign-turn details"); - expect(updates).toEqual([{ + expect(updates).toEqual([expect.objectContaining({ sessionUpdate: "session_info_update", - _meta: { - codex: { - error: { - category: "overloaded", - message: "Codex is temporarily overloaded.", - turnId: "foreign-turn", - willRetry: false, - }, - }, - }, - }]); + _meta: {jetbrains: {air: expect.objectContaining({sessionFailure: expect.objectContaining({ + id: "foreign-turn:error", + category: "service", + severity: "error", + })})}}, + })]); }); it("does not attach a foreign error that arrives before the active turn id", async () => { @@ -154,7 +143,13 @@ describe("CodexEventHandler - auth error events", () => { }, false, typedFailureCapabilities, "foreign-turn", true); expect(result).toMatchObject({stopReason: "end_turn"}); - expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + expect(updates).toEqual([expect.objectContaining({ + sessionUpdate: "session_info_update", + _meta: {jetbrains: {air: expect.objectContaining({sessionFailure: expect.objectContaining({ + id: "foreign-turn:error", + category: "service", + })})}}, + })]); }); it("buffers a current-turn error until the active turn id is known", async () => { @@ -170,7 +165,7 @@ describe("CodexEventHandler - auth error events", () => { expect(result).toMatchObject({ stopReason: "end_turn", - _meta: {jetbrains: {air: {sessionFailure: {turnId: "turn-id", category: "overloaded"}}}}, + _meta: {jetbrains: {air: {sessionFailure: {id: "turn-id:error", category: "service"}}}}, }); expect(updates).toEqual([]); const logText = JSON.stringify(log.mock.calls); @@ -179,29 +174,32 @@ describe("CodexEventHandler - auth error events", () => { log.mockRestore(); }); - it("does not publish a terminal failure while Codex will retry", async () => { + it("publishes an inline warning while Codex will retry", async () => { const {result, updates} = await runPromptWithError(createTestSessionState({ sessionId: "typed-retrying-session", account: { type: "apiKey" }, }), { - message: "raw retry payload", - codexErrorInfo: "serverOverloaded", + message: "Reconnecting... 1/5", + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: null}}, additionalDetails: "secret retry details", }, true, typedFailureCapabilities); expect(result).toMatchObject({stopReason: "end_turn"}); - expect(JSON.stringify(updates)).not.toContain("sessionFailure"); - expect(JSON.stringify(updates)).not.toContain("raw retry payload"); expect(JSON.stringify(updates)).not.toContain("secret retry details"); expect(updates).toEqual([{ sessionUpdate: "session_info_update", _meta: { - codex: { - error: { - category: "overloaded", - message: "Codex is temporarily overloaded.", - turnId: "turn-id", - willRetry: true, + jetbrains: { + air: { + version: 1, + sessionFailure: { + id: "turn-id:error", + revision: 1, + category: "connection", + severity: "warning", + title: "Reconnecting... 1/5", + actions: [], + }, }, }, }, @@ -213,23 +211,27 @@ describe("CodexEventHandler - auth error events", () => { sessionId: "typed-rate-limit-retry-session", account: { type: "apiKey" }, }), { - message: "raw 429 retry payload", + message: "Rate limit reached. Please try again later.", codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: 429}}, additionalDetails: "secret rate-limit details", }, true, typedFailureCapabilities); expect(result).toMatchObject({stopReason: "end_turn"}); - expect(JSON.stringify(updates)).not.toContain("raw 429 retry payload"); expect(JSON.stringify(updates)).not.toContain("secret rate-limit details"); expect(updates).toEqual([{ sessionUpdate: "session_info_update", _meta: { - codex: { - error: { - category: "rate_limited", - message: "The Codex rate limit was reached.", - turnId: "turn-id", - willRetry: true, + jetbrains: { + air: { + version: 1, + sessionFailure: { + id: "turn-id:error", + revision: 1, + category: "limit", + severity: "warning", + title: "Rate limit reached. Please try again later.", + actions: [], + }, }, }, }, @@ -242,16 +244,15 @@ describe("CodexEventHandler - auth error events", () => { account: null, authConfigured: false, }), { - message: "raw authentication payload", + message: "Authentication required", codexErrorInfo: "unauthorized", additionalDetails: "secret authentication details", }, false, typedFailureCapabilities); expect(result).toMatchObject({ stopReason: "end_turn", - _meta: {jetbrains: {air: {sessionFailure: {category: "auth_required"}}}}, + _meta: {jetbrains: {air: {sessionFailure: {category: "access"}}}}, }); - expect(JSON.stringify(result)).not.toContain("raw authentication payload"); expect(JSON.stringify(result)).not.toContain("secret authentication details"); expect(updates).toEqual([]); }); @@ -261,16 +262,15 @@ describe("CodexEventHandler - auth error events", () => { sessionId: "newer-capability-session", account: { type: "apiKey" }, }), { - message: "raw newer-version error", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: null, }, false, {_meta: {jetbrains: {air: {version: 2, capabilities: ["sessionFailure"]}}}}); expect(result).toMatchObject({ stopReason: "end_turn", - _meta: {jetbrains: {air: {version: 1, sessionFailure: {category: "overloaded"}}}}, + _meta: {jetbrains: {air: {version: 1, sessionFailure: {category: "service"}}}}, }); - expect(JSON.stringify(result)).not.toContain("raw newer-version error"); expect(updates).toEqual([]); }); @@ -290,23 +290,23 @@ describe("CodexEventHandler - auth error events", () => { }); it.each([ - ["transport_lost", {responseStreamDisconnected: {httpStatusCode: 503}}], - ["auth_required", "unauthorized"], - ["rate_limited", {responseStreamDisconnected: {httpStatusCode: 429}}], - ["quota_exhausted", "usageLimitExceeded"], - ["overloaded", "serverOverloaded"], - ["context_exhausted", "contextWindowExceeded"], - ["budget_exhausted", "sessionBudgetExceeded"], - ["policy_denied", "cyberPolicy"], - ["bad_request", "badRequest"], - ["internal_error", "internalServerError"], - ["provider_error", "threadRollbackFailed"], - ["provider_error", "sandboxError"], - ["provider_error", "other"], - ["transport_lost", {httpConnectionFailed: {httpStatusCode: null}}], - ["transport_lost", {responseStreamConnectionFailed: {httpStatusCode: 503}}], - ["transport_lost", {responseTooManyFailedAttempts: {httpStatusCode: 503}}], - ["provider_error", {activeTurnNotSteerable: {turnKind: "review"}}], + ["connection", {responseStreamDisconnected: {httpStatusCode: 503}}], + ["access", "unauthorized"], + ["limit", {responseStreamDisconnected: {httpStatusCode: 429}}], + ["limit", "usageLimitExceeded"], + ["service", "serverOverloaded"], + ["limit", "contextWindowExceeded"], + ["limit", "sessionBudgetExceeded"], + ["request", "cyberPolicy"], + ["request", "badRequest"], + ["service", "internalServerError"], + ["service", "threadRollbackFailed"], + ["service", "sandboxError"], + ["service", "other"], + ["connection", {httpConnectionFailed: {httpStatusCode: null}}], + ["connection", {responseStreamConnectionFailed: {httpStatusCode: 503}}], + ["connection", {responseTooManyFailedAttempts: {httpStatusCode: 503}}], + ["service", {activeTurnNotSteerable: {turnKind: "review"}}], ] as const)("maps a terminal Codex error to %s", async (category, codexErrorInfo) => { const {result, updates} = await runPromptWithError(createTestSessionState({ sessionId: `category-${category}`, @@ -323,45 +323,28 @@ describe("CodexEventHandler - auth error events", () => { expect(updates).toEqual([]); }); - it("clears the active failure with the same id and a greater revision after recovery", async () => { + it("keeps a historical failure and clears only producer runtime state after recovery", async () => { const sessionState = createTestSessionState({ sessionId: "recovered-session", account: { type: "apiKey" }, sessionFailure: { id: "failed-turn:error", revision: 3, - phase: "active", - category: "overloaded", - source: "codex", - safeMessage: "Codex is temporarily overloaded.", - retryable: true, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", actions: ["retry"], - turnId: "failed-turn", }, }); const {result, updates} = await runSuccessfulPrompt(sessionState, typedFailureCapabilities); expect(result).toMatchObject({stopReason: "end_turn"}); - expect(updates).toHaveLength(1); - expect(updates[0]).toMatchObject({ - sessionUpdate: "session_info_update", - _meta: { - jetbrains: { - air: { - sessionFailure: { - id: "failed-turn:error", - revision: 4, - phase: "cleared", - }, - }, - }, - }, - }); - expect(sessionState.sessionFailure).toMatchObject({revision: 4, phase: "cleared"}); + expect(updates).toEqual([]); + expect(sessionState.sessionFailure).toBeUndefined(); }); - it("keeps one failure id across failed retry turns and clears it after recovery", async () => { + it("uses a new failure id for each failed turn", async () => { const sessionState = createTestSessionState({ sessionId: "retry-chain-session", account: {type: "apiKey"}, @@ -380,21 +363,17 @@ describe("CodexEventHandler - auth error events", () => { const recovered = await runSuccessfulPrompt(sessionState, typedFailureCapabilities, "turn-3"); expect(first.result).toMatchObject({ - _meta: {jetbrains: {air: {sessionFailure: {id: "turn-1:error", revision: 1, phase: "active"}}}}, + _meta: {jetbrains: {air: {sessionFailure: {id: "turn-1:error", revision: 1}}}}, }); expect(second.result).toMatchObject({ _meta: {jetbrains: {air: {sessionFailure: { - id: "turn-1:error", - revision: 2, - phase: "active", - turnId: "turn-2", + id: "turn-2:error", + revision: 1, }}}}, }); expect(first.updates).toEqual([]); expect(second.updates).toEqual([]); - expect(recovered.updates[0]).toMatchObject({ - _meta: {jetbrains: {air: {sessionFailure: {id: "turn-1:error", revision: 3, phase: "cleared"}}}}, - }); + expect(recovered.updates).toEqual([]); }); it("publishes a typed failure when a failed completion has no error notification", async () => { @@ -406,7 +385,7 @@ describe("CodexEventHandler - auth error events", () => { sessionState, typedFailureCapabilities, createTurn("failed", "failed-turn", { - message: "raw completion payload", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret completion details", }), @@ -416,12 +395,10 @@ describe("CodexEventHandler - auth error events", () => { stopReason: "end_turn", _meta: {jetbrains: {air: {sessionFailure: { id: "failed-turn:error", - category: "overloaded", - turnId: "failed-turn", + category: "service", }}}}, }); expect(updates).toEqual([]); - expect(JSON.stringify(result)).not.toContain("raw completion payload"); expect(JSON.stringify(result)).not.toContain("secret completion details"); }); @@ -459,7 +436,7 @@ describe("CodexEventHandler - auth error events", () => { turnId: "completed-turn", willRetry: false, error: { - message: "raw late provider failure", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret late details", }, @@ -475,24 +452,21 @@ describe("CodexEventHandler - auth error events", () => { air: { version: 1, sessionFailure: { - id: expect.stringMatching(/^idle-error-session:error:[0-9a-f-]+$/), + id: "completed-turn:error", revision: 1, - phase: "active", - category: "overloaded", - source: "codex", - safeMessage: "Codex is temporarily overloaded.", - retryable: true, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", actions: ["retry"], }, }, }, }, }]); - expect(JSON.stringify(updates)).not.toContain("raw late provider failure"); expect(JSON.stringify(updates)).not.toContain("secret late details"); }); - it("keeps a retrying late idle error diagnostic-only", async () => { + it("publishes a retrying late idle error as a typed warning", async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); @@ -525,8 +499,8 @@ describe("CodexEventHandler - auth error events", () => { turnId: "completed-turn", willRetry: true, error: { - message: "raw retry detail", - codexErrorInfo: "serverOverloaded", + message: "Reconnecting... 1/5", + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: null}}, additionalDetails: "secret retry detail", }, }, @@ -537,18 +511,22 @@ describe("CodexEventHandler - auth error events", () => { expect(updates).toEqual([{ sessionUpdate: "session_info_update", _meta: { - codex: { - error: { - category: "overloaded", - message: "Codex is temporarily overloaded.", - turnId: "completed-turn", - willRetry: true, + jetbrains: { + air: { + version: 1, + sessionFailure: { + id: "completed-turn:error", + revision: 1, + category: "connection", + severity: "warning", + title: "Reconnecting... 1/5", + actions: [], + }, }, }, }, }]); - expect(sessionState.sessionFailure).toBeUndefined(); - expect(JSON.stringify(updates)).not.toContain("raw retry detail"); + expect(sessionState.sessionFailure).toMatchObject({id: "completed-turn:error", severity: "warning"}); expect(JSON.stringify(updates)).not.toContain("secret retry detail"); }); @@ -581,7 +559,7 @@ describe("CodexEventHandler - auth error events", () => { turnId: "late-provider-turn", willRetry: false, error: { - message: "raw slash command failure", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret slash command detail", }, @@ -601,29 +579,25 @@ describe("CodexEventHandler - auth error events", () => { air: { version: 1, sessionFailure: { - id: expect.stringMatching(/^local-command-error-session:error:[0-9a-f-]+$/), + id: "late-provider-turn:error", revision: 1, - phase: "active", - category: "overloaded", - source: "codex", - safeMessage: "Codex is temporarily overloaded.", - retryable: true, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", actions: ["retry"], }, }, }, }, }]); - expect(sessionState.sessionFailure).toMatchObject({phase: "active", revision: 1}); - expect(sessionState.sessionFailure).not.toHaveProperty("turnId"); - expect(JSON.stringify(updates)).not.toContain("raw slash command failure"); + expect(sessionState.sessionFailure).toMatchObject({severity: "error", revision: 1}); expect(JSON.stringify(updates)).not.toContain("secret slash command detail"); } finally { commandSpy.mockRestore(); } }); - it("uses a new session-scoped failure id after recreating the consumer", async () => { + it("preserves provider turn identity after recreating the consumer", async () => { const createFailureId = async (): Promise => { const state = createTestSessionState({ sessionId: "restored-session", @@ -657,9 +631,68 @@ describe("CodexEventHandler - auth error events", () => { const firstId = await createFailureId(); const restartedId = await createFailureId(); - expect(firstId).toMatch(/^restored-session:error:[0-9a-f-]+$/); - expect(restartedId).toMatch(/^restored-session:error:[0-9a-f-]+$/); - expect(restartedId).not.toBe(firstId); + expect(firstId).toBe("old-turn:error"); + expect(restartedId).toBe(firstId); + }); + + it("does not recommend a new session for an exhausted account usage quota", async () => { + const {result} = await runPromptWithError(createTestSessionState({ + sessionId: "usage-quota-actions-session", + account: {type: "apiKey"}, + }), { + message: "You have no usage left.", + codexErrorInfo: "usageLimitExceeded", + additionalDetails: null, + }, false, typedFailureCapabilities); + + expect(result).toMatchObject({ + _meta: {jetbrains: {air: {sessionFailure: {category: "limit", actions: []}}}}, + }); + }); + + it("starts a new incident when turn output proves a retry warning recovered", async () => { + const state = createTestSessionState({ + sessionId: "two-reconnect-incidents", + currentTurnId: "turn-id", + account: {type: "apiKey"}, + }); + const updates: Array<{_meta?: Record}> = []; + const connection = { + notify: vi.fn(async (_method: unknown, params: {update: {_meta?: Record}}) => { + updates.push(params.update); + }), + } as unknown as AcpClientConnection; + const handler = new CodexEventHandler(connection, state, false, true, "test-epoch"); + const retryError = (message: string) => ({ + method: "error" as const, + params: { + threadId: state.sessionId, + turnId: "turn-id", + willRetry: true, + error: { + message, + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: null}}, + additionalDetails: null, + }, + }, + }); + + await handler.handleNotification(retryError("First reconnect incident")); + await handler.handleNotification({ + method: "item/agentMessage/delta", + params: {threadId: state.sessionId, turnId: "turn-id", itemId: "message", delta: "Recovered."}, + }); + await handler.handleNotification(retryError("Second reconnect incident")); + + const failures = updates.flatMap(update => { + const air = (update._meta as {jetbrains?: {air?: {sessionFailure?: {id: string; revision: number}}}} | undefined) + ?.jetbrains?.air; + return air?.sessionFailure === undefined ? [] : [air.sessionFailure]; + }); + expect(failures).toHaveLength(2); + expect(failures[0]).toEqual(expect.objectContaining({id: "turn-id:error", revision: 1})); + expect(failures[1]).toEqual(expect.objectContaining({revision: 1})); + expect(failures[1]!.id).not.toBe(failures[0]!.id); }); it("preserves negotiated prompt validation RequestErrors", async () => { diff --git a/src/__tests__/CodexACPAgent/plan-review-events.test.ts b/src/__tests__/CodexACPAgent/plan-review-events.test.ts index aa114624..76e5c80e 100644 --- a/src/__tests__/CodexACPAgent/plan-review-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-review-events.test.ts @@ -265,7 +265,7 @@ describe("CodexACPAgent - plan review", () => { turnId: "plan-turn", willRetry: false, error: { - message: "raw post-turn approval error", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret approval detail", }, @@ -283,29 +283,26 @@ describe("CodexACPAgent - plan review", () => { air: { version: 1, sessionFailure: { - id: expect.stringMatching(/^plan-review-session:error:[0-9a-f-]+$/), + id: "plan-turn:error", revision: 1, - phase: "active", - category: "overloaded", - source: "codex", - safeMessage: "Codex is temporarily overloaded.", - retryable: true, + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", actions: ["retry"], }, }, }, }, }]); - expect(JSON.stringify(updates)).not.toContain("raw post-turn approval error"); expect(JSON.stringify(updates)).not.toContain("secret approval detail"); expect(turnStart).toHaveBeenCalledTimes(1); permission.resolve({outcome: {outcome: "cancelled"}}); await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); - expect(sessionState.sessionFailure).toMatchObject({phase: "active", revision: 1}); + expect(sessionState.sessionFailure).toMatchObject({severity: "error", revision: 1}); }); - it("clears an unchanged plan-approval failure after successful implementation", async () => { + it("keeps the plan-approval failure in history after successful implementation", async () => { const permission = deferred(); const {promptPromise, sessionState, turnStart, implementationTurn} = await startPlanPrompt(null, { typedFailures: true, @@ -354,10 +351,9 @@ describe("CodexACPAgent - plan review", () => { .map(event => event.args[0].update?._meta?.jetbrains?.air?.sessionFailure) .filter(Boolean); expect(failures).toEqual([ - expect.objectContaining({id: activeId, phase: "active", revision: 1}), - expect.objectContaining({id: activeId, phase: "cleared", revision: 2}), + expect.objectContaining({id: activeId, severity: "error", revision: 1}), ]); - expect(sessionState.sessionFailure).toMatchObject({id: activeId, phase: "cleared", revision: 2}); + expect(sessionState.sessionFailure).toBeUndefined(); }); it("keeps an implementation failure terminal on the prompt response", async () => { @@ -368,7 +364,7 @@ describe("CodexACPAgent - plan review", () => { await vi.waitFor(() => expect(turnStart).toHaveBeenCalledTimes(2)); fixture.clearAcpConnectionDump(); const implementationError = { - message: "raw implementation failure", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded" as const, additionalDetails: "secret implementation detail", }; @@ -402,15 +398,12 @@ describe("CodexACPAgent - plan review", () => { stopReason: "end_turn", _meta: {jetbrains: {air: {sessionFailure: { id: "implementation-turn:error", - phase: "active", - category: "overloaded", - turnId: "implementation-turn", + category: "service", + severity: "error", }}}}, }); - expect(JSON.stringify(response)).not.toContain("raw implementation failure"); expect(JSON.stringify(response)).not.toContain("secret implementation detail"); - expect(sessionState.sessionFailure).toMatchObject({phase: "active", revision: 1}); - expect(JSON.stringify(fixture.getAcpConnectionEvents([]))).not.toContain('"phase":"cleared"'); + expect(sessionState.sessionFailure).toMatchObject({severity: "error", revision: 1}); }); it("keeps the approval-to-implementation-start gap session-scoped", async () => { @@ -443,8 +436,7 @@ describe("CodexACPAgent - plan review", () => { }, }); await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId); - expect(sessionState.sessionFailure).toMatchObject({phase: "active", revision: 1}); - expect(sessionState.sessionFailure).not.toHaveProperty("turnId"); + expect(sessionState.sessionFailure).toMatchObject({severity: "error", revision: 1}); implementationStart.resolve({ turn: { @@ -472,6 +464,6 @@ describe("CodexACPAgent - plan review", () => { }, }); await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); - expect(sessionState.sessionFailure).toMatchObject({phase: "cleared", revision: 2}); + expect(sessionState.sessionFailure).toBeUndefined(); }); }); diff --git a/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts b/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts index 824d0142..790c2614 100644 --- a/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts +++ b/src/__tests__/CodexACPAgent/typed-session-failure-wire.test.ts @@ -38,10 +38,10 @@ describe("typed session failures over ACP transport", () => { air: { version: 1, sessionFailure: { - category: "transport_lost", - safeMessage: "Connection to Codex was lost.", - retryable: true, - actions: ["reconnect", "retry"], + category: "connection", + severity: "error", + title: "Connection to Codex was lost.", + actions: ["retry", "new_session"], }, }, }, @@ -101,7 +101,7 @@ describe("typed session failures over ACP transport", () => { turnId: "turn-id", willRetry: false, error: { - message: "raw idle provider detail", + message: "Codex is temporarily overloaded.", codexErrorInfo: "serverOverloaded", additionalDetails: "secret idle detail", }, @@ -118,9 +118,10 @@ describe("typed session failures over ACP transport", () => { jetbrains: { air: { sessionFailure: { - id: expect.stringMatching(/^wire-idle-error:error:[0-9a-f-]+$/), - category: "overloaded", - safeMessage: "Codex is temporarily overloaded.", + id: "turn-id:error", + category: "service", + severity: "error", + title: "Codex is temporarily overloaded.", }, }, }, @@ -131,11 +132,285 @@ describe("typed session failures over ACP transport", () => { jetbrains: {air: {sessionFailure: Record}}; }).jetbrains.air.sessionFailure; expect(wireFailure).not.toHaveProperty("turnId"); - expect(JSON.stringify(fixture.updates)).not.toContain("raw idle provider detail"); + expect(wireFailure).not.toHaveProperty("safeMessage"); expect(JSON.stringify(fixture.updates)).not.toContain("secret idle detail"); }); + + it("delivers an app-server warning as a typed advisory instead of assistant text", async () => { + const fixture = await createIdleFixture("wire-warning"); + + fixture.sendServerNotification({ + method: "warning", + params: { + threadId: fixture.sessionId, + message: "Heads up: Long threads and multiple compactions can cause the model to be less accurate.", + }, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + + expect(fixture.updates[0]).toMatchObject({ + update: { + sessionUpdate: "session_info_update", + _meta: { + jetbrains: { + air: { + sessionFailure: { + id: expect.stringMatching(/^wire-warning:notice:[0-9a-f-]+:\d+$/), + category: "unknown", + severity: "warning", + revision: 1, + actions: [], + title: + "Heads up: Long threads and multiple compactions can cause the model to be less accurate.", + }, + }, + }, + }, + }, + }); + // The whole point of the change: it must not arrive as an agent message chunk. + expect(JSON.stringify(fixture.updates)).not.toContain("agent_message_chunk"); + expect(JSON.stringify(fixture.updates)).not.toContain("Warning: "); + }); + + it("folds a config warning's details into the advisory message", async () => { + const fixture = await createIdleFixture("wire-config-warning"); + + fixture.sendServerNotification({ + method: "configWarning", + params: {summary: "Unknown key `foo`", details: "in ~/.codex/config.toml"}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + + expect(fixture.updates[0]).toMatchObject({ + update: { + _meta: { + jetbrains: { + air: { + sessionFailure: { + category: "unknown", + severity: "warning", + title: "Unknown key `foo` — in ~/.codex/config.toml", + }, + }, + }, + }, + }, + }); + }); + + it("surfaces a deprecation notice that used to be dropped outright", async () => { + const fixture = await createIdleFixture("wire-deprecation"); + + fixture.sendServerNotification({ + method: "deprecationNotice", + params: {summary: "`--legacy-flag` is deprecated", details: "Use `--flag` instead."}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + + expect(fixture.updates[0]).toMatchObject({ + update: { + sessionUpdate: "session_info_update", + _meta: { + jetbrains: { + air: { + sessionFailure: { + category: "unknown", + severity: "warning", + title: "`--legacy-flag` is deprecated — Use `--flag` instead.", + }, + }, + }, + }, + }, + }); + }); + + it("still drops a deprecation notice when the capability is absent", async () => { + const fixture = await createIdleFixture("wire-legacy-deprecation", {}); + + fixture.sendServerNotification({ + method: "deprecationNotice", + params: {summary: "`--legacy-flag` is deprecated", details: null}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + + // This notification produced nothing before typed records existed; a client that did not + // negotiate them must not suddenly start seeing it. + expect(fixture.updates).toEqual([]); + }); + + it("uses details only when a notice is too large for the title", async () => { + const fixture = await createIdleFixture("wire-long-warning"); + const details = "A".repeat(300); + + fixture.sendServerNotification({ + method: "configWarning", + params: {summary: "Configuration requires attention", details}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + + expect(fixture.updates[0]!.update).toMatchObject({ + _meta: {jetbrains: {air: {sessionFailure: { + title: "Configuration requires attention", + details, + }}}}, + }); + }); + + it("keeps warnings as assistant text when the capability is absent", async () => { + const fixture = await createIdleFixture("wire-legacy-warning", {}); + + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message: "legacy advisory"}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(1)); + + expect(fixture.updates[0]!.update).toMatchObject({ + sessionUpdate: "agent_message_chunk", + content: {type: "text", text: "Warning: legacy advisory\n\n"}, + }); + }); + + it("keeps an advisory in its own id namespace so it never bumps an active failure's revision", async () => { + const fixture = await createIdleFixture("wire-mixed"); + + fixture.sendServerNotification({ + method: "error", + params: { + threadId: fixture.sessionId, + turnId: "turn-id", + willRetry: false, + error: {message: "provider blew up", codexErrorInfo: "serverOverloaded", additionalDetails: null}, + }, + }); + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message: "unrelated advisory"}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(2)); + + const records = fixture.updates.map(update => (update.update._meta as { + jetbrains: {air: {sessionFailure: {id: string; revision: number; severity?: string}}}; + }).jetbrains.air.sessionFailure); + // Distinct ids, each starting its own revision sequence at 1. + expect(records[0]).toMatchObject({revision: 1, category: "service", severity: "error"}); + expect(records[1]).toMatchObject({revision: 1, category: "unknown", severity: "warning"}); + expect(records[0]!.id).not.toEqual(records[1]!.id); + }); + + it("reuses id and advances revision for a repeated warning", async () => { + const fixture = await createIdleFixture("wire-repeated-warning"); + const notification = { + method: "warning" as const, + params: {threadId: fixture.sessionId, message: "Same warning"}, + }; + + fixture.sendServerNotification(notification); + fixture.sendServerNotification(notification); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(2)); + + const records = fixture.updates.map(update => (update.update._meta as { + jetbrains: {air: {sessionFailure: {id: string; revision: number}}}; + }).jetbrains.air.sessionFailure); + expect(records[1]!.id).toBe(records[0]!.id); + expect(records.map(record => record.revision)).toEqual([1, 2]); + }); + + it("uses a new id when the same warning occurs again after another incident", async () => { + const fixture = await createIdleFixture("wire-recurring-warning"); + + for (const message of ["Recurring warning", "Different warning", "Recurring warning"]) { + fixture.sendServerNotification({ + method: "warning", + params: {threadId: fixture.sessionId, message}, + }); + } + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(3)); + + const records = fixture.updates.map(update => (update.update._meta as { + jetbrains: {air: {sessionFailure: {id: string; revision: number}}}; + }).jetbrains.air.sessionFailure); + expect(records.map(record => record.revision)).toEqual([1, 1, 1]); + expect(records[2]!.id).not.toBe(records[0]!.id); + }); + + it("updates one late retry incident from warning attempts to terminal error", async () => { + const fixture = await createIdleFixture("wire-late-retry-chain"); + const error = { + message: "Connection to Codex was lost.", + codexErrorInfo: {responseStreamDisconnected: {httpStatusCode: null}}, + additionalDetails: null, + }; + + for (const [willRetry, message] of [ + [true, "Reconnecting to Codex, attempt 1 of 2."], + [true, "Reconnecting to Codex, attempt 2 of 2."], + [false, "Connection to Codex was lost after 2 attempts."], + ] as const) { + fixture.sendServerNotification({ + method: "error", + params: {threadId: fixture.sessionId, turnId: "turn-id", willRetry, error: {...error, message}}, + }); + } + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(3)); + + const records = fixture.updates.map(update => (update.update._meta as { + jetbrains: {air: {sessionFailure: {id: string; revision: number; severity: string; actions: string[]}}}; + }).jetbrains.air.sessionFailure); + expect(new Set(records.map(record => record.id)).size).toBe(1); + expect(records.map(record => record.revision)).toEqual([1, 2, 3]); + expect(records.map(record => record.severity)).toEqual(["warning", "warning", "error"]); + expect(records.slice(0, 2).every(record => record.actions.length === 0)).toBe(true); + + fixture.sendServerNotification({ + method: "error", + params: {threadId: fixture.sessionId, turnId: "turn-id", willRetry: false, error}, + }); + await fixture.codexClient.waitForSessionNotifications(fixture.sessionId); + await vi.waitFor(() => expect(fixture.updates).toHaveLength(4)); + const next = (fixture.updates[3]!.update._meta as { + jetbrains: {air: {sessionFailure: {id: string; revision: number}}}; + }).jetbrains.air.sessionFailure; + expect(next.id).toBe(records[0]!.id); + expect(next.revision).toBe(4); + }); }); +/** A fixture whose session already completed a turn, so notifications route to a live event handler. */ +async function createIdleFixture( + sessionId: string, + clientCapabilities: acp.ClientCapabilities = typedFailureCapabilities, +) { + const fixture = createWireFixture(); + await fixture.initialize(clientCapabilities); + const sessionState = createTestSessionState({sessionId, account: {type: "apiKey"}}); + vi.spyOn(fixture.server, "getSessionState").mockReturnValue(sessionState); + vi.spyOn(fixture.appServer, "turnStart").mockResolvedValue({turn: createTurn("inProgress")}); + vi.spyOn(fixture.appServer, "awaitTurnCompleted").mockResolvedValue({ + threadId: sessionId, + turn: createTurn("completed"), + }); + + await fixture.client.prompt({ + sessionId, + prompt: [{type: "text", text: "settle the session"}], + }); + fixture.updates.splice(0); + + return {...fixture, sessionId}; +} + function createWireFixture(options: {exitCode?: number | null; stderr?: string} = {}) { const mockConnections = createMockConnections(); const appServer = new CodexAppServerClient(mockConnections.mockCodexConnection);