diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 27c71f2566..1e20ed648d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -666,6 +666,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -6292,7 +6293,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index dc578ec14b..5a70f96f1a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8417,6 +8417,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25241,7 +25242,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f1d0fc3817..76c7ae81fc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4273,6 +4273,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -22955,7 +22956,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index bd27cd304b..7b065d6184 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -14,6 +14,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a4f4702daf..f84f980faf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -64,6 +64,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -1845,7 +1846,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 324dfbcabf..1a3c732bab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -145,6 +145,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2585,7 +2586,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 2b5713ec4d..997c5e0041 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index dec9a2b430..b3336a05be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 7d9935ab2c..9f11927d72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 76efd1e76e..9220eeecaa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -145,6 +145,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2637,7 +2638,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 51bc9f5eb0..ce1c4d51a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 66a0b378b9..01af33c3cc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -145,6 +145,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2585,7 +2586,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 743f729bc5..04776d73d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index ee92a8a2a5..6952ab146e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -64,6 +64,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -1845,7 +1846,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 779ee79907..6079f73c8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -67,6 +67,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -2376,7 +2377,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 1596283e07..2d61e08042 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -64,6 +64,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -1845,7 +1846,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index cc196125b0..82e5c34ef6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -64,6 +64,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -1845,7 +1846,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 00233950ae..1d988b8cf9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -64,6 +64,7 @@ "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -1845,7 +1846,7 @@ "type": "null" } ], - "description": "Only populated when the Turn's status is failed." + "description": "Error associated with a failed or interrupted turn." }, "id": { "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index e576ede54a..0a35c5522f 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 6de68dd2e7..f18fae87d1 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 90aa55ff97..294998e7ff 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index b8680256d1..e804fa974c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -20,7 +20,7 @@ items: Array, */ itemsView: TurnItemsView, status: TurnStatus, /** - * Only populated when the Turn's status is failed. + * Error associated with a failed or interrupted turn. */ error: TurnError | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index d31d414e88..95a2bba6cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1252,8 +1252,17 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let terminal_error = payload.error.as_ref().map(|error| V2TurnError { + message: error.message.clone(), + codex_error_info: error.codex_error_info.clone().map(Into::into), + misalignment: error.misalignment.clone().map(Into::into), + additional_details: None, + }); let apply_abort = |turn: &mut PendingTurn| { turn.status = TurnStatus::Interrupted; + if let Some(error) = terminal_error.as_ref() { + turn.error = Some(error.clone()); + } turn.completed_at = payload.completed_at; turn.duration_ms = payload.duration_ms; ThreadHistoryTurnMetadata::from_pending_turn(turn) @@ -1267,10 +1276,7 @@ impl ThreadHistoryBuilder { } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; - turn.completed_at = payload.completed_at; - turn.duration_ms = payload.duration_ms; - let changed_turn = ThreadHistoryTurnMetadata::from_pending_turn(turn); + let changed_turn = apply_abort(turn); self.record_changed_turn(changed_turn); return; } @@ -2794,6 +2800,7 @@ mod tests { turn_id: Some("turn-1".into()), started_at: None, reason: TurnAbortReason::Replaced, + error: None, completed_at: None, duration_ms: None, }), @@ -4471,6 +4478,7 @@ mod tests { turn_id: Some("turn-a".into()), started_at: None, reason: TurnAbortReason::Replaced, + error: None, completed_at: None, duration_ms: None, }), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs index 5256731ceb..b6ad558bb5 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs @@ -62,7 +62,12 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { turn_id: turn_id.clone(), root_turn_id: None, status: TurnStatus::Interrupted, - error: None, + error: event.error.as_ref().map(|error| TurnError { + message: error.message.clone(), + codex_error_info: error.codex_error_info.clone().map(Into::into), + misalignment: error.misalignment.clone().map(Into::into), + additional_details: None, + }), started_at: event.started_at, completed_at: event.completed_at, duration_ms: event.duration_ms, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs index 5a67d157d3..729f673e29 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs @@ -200,6 +200,7 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() { TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + error: None, started_at: None, completed_at: None, duration_ms: None, @@ -237,6 +238,7 @@ fn projects_identified_turn_aborts() { TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + error: None, started_at: Some(10), completed_at: Some(20), duration_ms: Some(10_000), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index f231df1f28..7b20419d4b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -83,6 +83,7 @@ pub enum CodexErrorInfo { ServerOverloaded, CyberPolicy, MisalignmentPolicyViolation, + TooManyDenials, HttpConnectionFailed { #[serde(rename = "httpStatusCode")] #[ts(rename = "httpStatusCode")] @@ -135,6 +136,7 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::MisalignmentPolicyViolation => { CodexErrorInfo::MisalignmentPolicyViolation } + CoreCodexErrorInfo::TooManyDenials => CodexErrorInfo::TooManyDenials, CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { CodexErrorInfo::HttpConnectionFailed { http_status_code } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 6d94cdd78b..5f5bcce4bc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -392,7 +392,7 @@ pub struct Turn { #[serde(default)] pub items_view: TurnItemsView, pub status: TurnStatus, - /// Only populated when the Turn's status is failed. + /// Error associated with a failed or interrupted turn. pub error: Option, /// Unix timestamp (in seconds) when the turn started. #[ts(type = "number | null")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 45ea19f54e..a6bcb1ab33 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,3 +1,12 @@ +# Guardian circuit-breaker errors + +Set `auto_review.circuit_break_action = "strict"` to include `TooManyDenials` in +`TurnAborted.error` when Guardian reaches its denial limit. App-server exposes it +as `turn.error.codexErrorInfo = "tooManyDenials"` in notifications and history. + +The default, `"default"`, leaves this error unset. Both modes preserve the warning, +denial limit, and interrupted status; neither emits a separate `Error` event. + # Model catalog provider requirements `model/list` and periodic model catalog refreshes check the startup provider against diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c5a87f6eed..d1b0ab8975 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1544,7 +1544,12 @@ async fn handle_turn_interrupted( event_turn_id, TurnCompletionMetadata { status: TurnStatus::Interrupted, - error: None, + error: turn_aborted_event.error.map(|error| TurnError { + message: error.message, + codex_error_info: error.codex_error_info.map(Into::into), + misalignment: error.misalignment.map(Into::into), + additional_details: None, + }), last_agent_message: None, started_at: turn_summary.started_at, completed_at: turn_aborted_event.completed_at, @@ -2189,6 +2194,7 @@ mod tests { turn_id: Some(turn_id.to_string()), started_at: None, reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + error: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), } diff --git a/codex-rs/app-server/src/request_processors/daemon_continuation.rs b/codex-rs/app-server/src/request_processors/daemon_continuation.rs index 04e05693d9..5c6ea2bb4d 100644 --- a/codex-rs/app-server/src/request_processors/daemon_continuation.rs +++ b/codex-rs/app-server/src/request_processors/daemon_continuation.rs @@ -105,6 +105,7 @@ impl ThreadRequestProcessor { TurnAbortedEvent { turn_id: Some(saved.turn_id.clone()), reason: TurnAbortReason::Interrupted, + error: None, started_at: None, completed_at: None, duration_ms: None, diff --git a/codex-rs/app-server/tests/suite/v2/guardian_sync_session_tests.rs b/codex-rs/app-server/tests/suite/v2/guardian_sync_session_tests.rs index 83eedc2186..0830c48079 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_sync_session_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_sync_session_tests.rs @@ -2,6 +2,8 @@ use super::*; use app_test_support::create_final_assistant_message_sse_response as assistant_response; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; @@ -18,6 +20,7 @@ use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_protocol::protocol::SubAgentSource; @@ -59,6 +62,116 @@ enum ReviewEnding { Interrupt, } +#[tokio::test] +async fn circuit_break_action_preserves_warning_and_reports_error_in_live_and_saved_turns() +-> Result<()> { + skip_if_no_network!(Ok(())); + + for action in [None, Some("default"), Some("strict")] { + let mut replies = Vec::new(); + for call in ["first", "second", "third"] { + for body in [ + tool_response(TEST_SERVER_NAME, TEST_TOOL_NAME, &[call]), + assistant_response( + r#"{"outcome":"deny","rationale":"Action is not authorized."}"#, + )?, + ] { + replies.push(vec![StreamingSseChunk { gate: None, body }]); + } + } + // Keep a racing parent request from completing before the interruption. + let (_continue_parent, parent_gate) = oneshot::channel(); + replies.push(vec![StreamingSseChunk { + gate: Some(parent_gate), + body: assistant_response("Done.")?, + }]); + let (server, _completions) = start_streaming_sse_server(replies).await; + let (mcp_url, mcp_server) = start_mcp_server(/*sensitive_action*/ None).await?; + let codex_home = TempDir::new()?; + let mut config = sync_config(server.uri()).with_extra_config(&format!( + "[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\"" + )); + if let Some(action) = action { + config = config.with_extra_config(&format!( + "[auto_review]\ncircuit_break_action = \"{action}\"" + )); + } + config.write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(TIMEOUT) + .await?; + let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + let started: TurnStartResponse = app + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Run the checks.".to_owned(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + // Individual denials also emit Guardian warnings before the circuit breaker. + let warning = timeout(TIMEOUT, async { + loop { + let warning: GuardianWarningNotification = + app.read_notification("guardianWarning").await?; + assert_eq!(warning.thread_id, thread.id); + if warning.message.contains("3 consecutive") { + break Ok::<_, anyhow::Error>(warning); + } + } + }) + .await??; + let completed: TurnCompletedNotification = + timeout(TIMEOUT, app.read_notification("turn/completed")).await??; + let expected_error = (action == Some("strict")).then_some(TurnError { + message: warning.message, + codex_error_info: Some(CodexErrorInfo::TooManyDenials), + additional_details: None, + misalignment: None, + }); + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, started.turn.id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + assert_eq!(completed.turn.error, expected_error); + assert!( + !app.pending_notification_methods() + .iter() + .any(|method| method == "error"), + "the error must be carried by the interrupted turn, not a separate notification" + ); + app.shutdown_gracefully().await?; + + // Restart to read persisted history rather than the in-memory turn summary. + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(TIMEOUT) + .await?; + let read: ThreadReadResponse = app + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: true, + }, + }) + .await?; + let saved = read.thread.turns.last().expect("saved interrupted turn"); + assert_eq!(saved.id, completed.turn.id); + assert_eq!(saved.status, completed.turn.status); + assert_eq!(saved.error, expected_error); + app.shutdown_gracefully().await?; + mcp_server.abort(); + server.shutdown().await; + } + Ok(()) +} + #[tokio::test] async fn managed_reviewer_refreshes_global_instructions_before_reuse() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 12beecc087..6f58231e30 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -4316,6 +4316,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re turn_id: Some(interrupted_turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }))?, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 3369f4613c..84b532177e 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -569,6 +569,10 @@ pub enum ThreadStoreToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct AutoReviewToml { + /// Controls whether circuit-breaker interruptions include a structured error. + /// Strict mode writes structured errors that older clients may not + /// recognize when reading shared history. Defaults to `default`. + pub circuit_break_action: Option, /// Additional policy instructions inserted into the guardian prompt. pub policy: Option, /// Additional policy text inserted into the Guardian template's `{{ extra_policy }}` slot. @@ -577,6 +581,16 @@ pub struct AutoReviewToml { pub experimental_policy_template: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CircuitBreakAction { + /// Emit the warning and interrupt without structured error details. + #[default] + Default, + /// Emit the same warning and interruption, with structured error details. + Strict, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ProjectConfig { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d908935343..af8f05ee51 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -390,6 +390,14 @@ }, "AutoReviewToml": { "properties": { + "circuit_break_action": { + "allOf": [ + { + "$ref": "#/definitions/CircuitBreakAction" + } + ], + "description": "Controls whether circuit-breaker interruptions include a structured error. Strict mode writes structured errors that older clients may not recognize when reading shared history. Defaults to `default`." + }, "experimental_policy_template": { "description": "Experimental full Guardian prompt template containing the tenant policy placeholder.", "type": "string" @@ -509,6 +517,24 @@ }, "type": "object" }, + "CircuitBreakAction": { + "oneOf": [ + { + "description": "Emit the warning and interrupt without structured error details.", + "enum": [ + "default" + ], + "type": "string" + }, + { + "description": "Emit the same warning and interruption, with structured error details.", + "enum": [ + "strict" + ], + "type": "string" + } + ] + }, "CloudToml": { "additionalProperties": false, "description": "Cloud-owned feature settings.", diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index 6f2cc44e71..cfa375c813 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -194,6 +194,7 @@ async fn mark_thread_interrupted(thread: &CodexThread) { turn_id: Some(turn.sub_id.clone()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 1a4c7bc12f..ab18abebb8 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -616,6 +616,7 @@ async fn on_event_updates_status_from_turn_aborted() { turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })); diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index d364ef184f..f8762c98ce 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -53,6 +53,7 @@ async fn forward_events_filters_private_events_before_blocked_send_is_cancelled( turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0322db4978..fca03ece47 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8447,6 +8447,7 @@ async fn load_config_uses_auto_review_guardian_policy_config_and_template() -> s let codex_home = TempDir::new()?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { + circuit_break_action: None, policy: Some(" Use the user-configured guardian policy. ".to_string()), extra_policy: Some(" Use the user-configured additional policy. ".to_string()), experimental_policy_template: Some( @@ -8505,6 +8506,7 @@ async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> .map_err(std::io::Error::other)?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { + circuit_break_action: None, policy: Some("Use the user-configured guardian policy.".to_string()), extra_policy: Some("Use the user-configured additional policy.".to_string()), experimental_policy_template: None, @@ -8543,6 +8545,7 @@ async fn load_config_ignores_empty_auto_review_guardian_policy_config() -> std:: let codex_home = TempDir::new()?; let cfg = ConfigToml { auto_review: Some(AutoReviewToml { + circuit_break_action: None, policy: Some(" ".to_string()), extra_policy: Some(" ".to_string()), experimental_policy_template: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a782af1a50..571fc2d81b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -25,6 +25,7 @@ use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::ThreadConfigLoader; +use codex_config::config_toml::CircuitBreakAction; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES; use codex_config::config_toml::ProjectConfig; @@ -703,6 +704,9 @@ pub struct Config { /// placeholder when a review session is built. pub guardian_policy_template: Option, + /// Include a structured error when Guardian's circuit breaker interrupts a turn. + pub guardian_circuit_break_action: CircuitBreakAction, + /// Whether to inject the `` developer block. pub include_permissions_instructions: bool, @@ -4384,6 +4388,11 @@ impl Config { guardian_policy_config, guardian_extra_policy, guardian_policy_template, + guardian_circuit_break_action: cfg + .auto_review + .as_ref() + .and_then(|auto_review| auto_review.circuit_break_action) + .unwrap_or_default(), model_reasoning_effort: cfg.model_reasoning_effort, plan_mode_reasoning_effort: cfg.plan_mode_reasoning_effort, model_reasoning_summary: cfg.model_reasoning_summary, diff --git a/codex-rs/core/src/guardian/review_request.rs b/codex-rs/core/src/guardian/review_request.rs index 9a6b615d01..746b4caba4 100644 --- a/codex-rs/core/src/guardian/review_request.rs +++ b/codex-rs/core/src/guardian/review_request.rs @@ -3,8 +3,10 @@ use super::*; use crate::codex_thread::GuardianAuthorizationVersion; +use codex_config::config_toml::CircuitBreakAction; use codex_guardian_reviewer::ReviewHost; use codex_protocol::approvals::GuardianReviewReason; +use codex_protocol::protocol::ErrorEvent; pub(in crate::guardian) struct PreparedApproval { request: GuardianApprovalRequest, @@ -273,9 +275,14 @@ impl ReviewHost for super::super::runtime::ReviewRuntime { } } - async fn interrupt(&self, turn_id: &str, warning: EventMsg) { + async fn interrupt(&self, turn_id: &str, warning: EventMsg, error: ErrorEvent) { + let Some(turn) = self.session.turn_context_for_sub_id(turn_id).await else { + return; + }; + let error = (turn.config.guardian_circuit_break_action == CircuitBreakAction::Strict) + .then_some(error); self.session - .interrupt_turn_with_warning(turn_id, warning) + .interrupt_turn_with_warning(turn_id, warning, error) .await; } } diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index 803ecb89bd..0a2eaf9445 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -193,6 +193,7 @@ fn turn_aborted_event(turn_id: &str) -> Event { turn_id: Some(turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }), diff --git a/codex-rs/core/src/realtime_history_tests.rs b/codex-rs/core/src/realtime_history_tests.rs index a617917c10..fd163ec459 100644 --- a/codex-rs/core/src/realtime_history_tests.rs +++ b/codex-rs/core/src/realtime_history_tests.rs @@ -140,6 +140,7 @@ fn interrupted_turn_is_not_associated_with_a_new_voice_session( let aborted = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: aborted_turn_id.map(str::to_string), reason: TurnAbortReason::Interrupted, + error: None, started_at: None, completed_at: None, duration_ms: None, @@ -174,6 +175,7 @@ fn interrupted_turn_keeps_its_existing_voice_session_for_late_artifacts() { state.observe(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + error: None, started_at: None, completed_at: None, duration_ms: None, diff --git a/codex-rs/core/src/session/extension_interruption.rs b/codex-rs/core/src/session/extension_interruption.rs index 010b3fdf92..87da41f206 100644 --- a/codex-rs/core/src/session/extension_interruption.rs +++ b/codex-rs/core/src/session/extension_interruption.rs @@ -6,6 +6,7 @@ use crate::codex_thread::CodexThread; use codex_extension_api::ThreadIdleCause; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnAbortReason; @@ -85,8 +86,12 @@ impl Session { // The caller may be inside the task that cancellation must join. let _ = reply.send(active_turn.is_some()); if let Some(active_turn) = active_turn { - self.finish_turn_abort(active_turn, TurnAbortReason::Interrupted) - .await; + self.finish_turn_abort( + active_turn, + TurnAbortReason::Interrupted, + /*error*/ None, + ) + .await; } } @@ -94,6 +99,7 @@ impl Session { self: &Arc, turn_id: &str, warning: EventMsg, + error: Option, ) { let Some(turn) = self.turn_context_for_sub_id(turn_id).await else { return; @@ -104,7 +110,7 @@ impl Session { let turn_id = turn_id.to_owned(); drop(runtime.spawn(async move { if session - .abort_turn_if_active(&turn_id, TurnAbortReason::Interrupted) + .abort_turn_if_active(&turn_id, TurnAbortReason::Interrupted, error) .await { // Extension aborts bypass normal task completion; user interrupts do not. diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 1861d0f85c..5d750651f3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -1599,6 +1599,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions( codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(format!("wake-{window_number}")), reason: TurnAbortReason::Interrupted, + error: None, started_at: None, completed_at: None, duration_ms: None, @@ -2130,6 +2131,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }, @@ -2268,6 +2270,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(unmatched_abort_turn_id), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0b3ed36cf4..7364ddd297 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -815,6 +815,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason, + error: _, started_at, completed_at, duration_ms, @@ -11473,6 +11474,7 @@ impl SessionTask for ExtensionInterruptedTask { EventMsg::Warning(codex_protocol::protocol::WarningEvent { message: "extension interrupted this turn".into(), }), + /*error*/ None, ) .await; @@ -11853,6 +11855,7 @@ async fn extension_interrupt_survives_the_calling_runtime() { EventMsg::Warning(codex_protocol::protocol::WarningEvent { message: "extension interrupted this turn".into(), }), + /*error*/ None, ) .await; }); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ab305bdac5..323c8015a5 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -47,6 +47,7 @@ use codex_otel::TURN_NETWORK_PROXY_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; use codex_otel::TURN_UNIFIED_EXEC_RUNNING_PROCESSES_METRIC; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::TokenUsage; @@ -541,8 +542,13 @@ impl Session { aborted_turn = task.is_some(); turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context)); if let Some(task) = task { - self.handle_task_abort(task, reason.clone(), &active_turn.turn_state) - .await; + self.handle_task_abort( + task, + reason.clone(), + &active_turn.turn_state, + /*error*/ None, + ) + .await; } if aborted_turn { active_turn_to_clear = Some(active_turn); @@ -567,6 +573,7 @@ impl Session { self: &Arc, turn_id: &str, reason: TurnAbortReason, + error: Option, ) -> bool { let active_turn = { let mut active = self.active_turn.lock().await; @@ -590,7 +597,7 @@ impl Session { return false; }; - self.finish_turn_abort(active_turn, reason).await; + self.finish_turn_abort(active_turn, reason, error).await; true } @@ -598,11 +605,12 @@ impl Session { self: &Arc, mut active_turn: ActiveTurn, reason: TurnAbortReason, + error: Option, ) { let task = active_turn.task.take(); let turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context)); if let Some(task) = task { - self.handle_task_abort(task, reason.clone(), &active_turn.turn_state) + self.handle_task_abort(task, reason.clone(), &active_turn.turn_state, error) .await; } if let Some(turn_context) = turn_context.as_deref() { @@ -819,6 +827,7 @@ impl Session { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(turn_context.sub_id.clone()), reason, + error: None, started_at, completed_at, duration_ms, @@ -913,6 +922,7 @@ impl Session { task: RunningTask, reason: TurnAbortReason, turn_state: &Mutex, + error: Option, ) { let sub_id = task.turn_context.sub_id.clone(); if task.cancellation_token.is_cancelled() { @@ -996,6 +1006,7 @@ impl Session { let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, + error, started_at, completed_at, duration_ms, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index ddaa1f19cb..db3d358ecf 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -2627,6 +2627,7 @@ fn append_interrupted_boundary( let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason: TurnAbortReason::Interrupted, + error: None, started_at, completed_at: None, duration_ms: None, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 79f635244c..99321ce694 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -2765,6 +2765,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })), @@ -2788,6 +2789,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })), @@ -2818,6 +2820,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })), @@ -2840,6 +2843,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }, @@ -2858,6 +2862,7 @@ fn interrupted_snapshot_is_not_mid_turn() { turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })), @@ -3038,6 +3043,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor turn_id: expected_turn_id, started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }), @@ -3160,6 +3166,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { turn_id: Some(turn_id), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })) if turn_id == "turn-explicit" diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 9c8268c542..e7c0f2dbc3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2185,6 +2185,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { turn_id: Some(aborted_turn.sub_id.clone()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, }), diff --git a/codex-rs/ext/guardian-reviewer/src/retry.rs b/codex-rs/ext/guardian-reviewer/src/retry.rs index 82cb29fc32..3adcb4d83a 100644 --- a/codex-rs/ext/guardian-reviewer/src/retry.rs +++ b/codex-rs/ext/guardian-reviewer/src/retry.rs @@ -116,6 +116,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool { | CodexErrorInfo::CyberPolicy | CodexErrorInfo::BioPolicy | CodexErrorInfo::MisalignmentPolicyViolation + | CodexErrorInfo::TooManyDenials | CodexErrorInfo::Unauthorized | CodexErrorInfo::BadRequest | CodexErrorInfo::InvalidPrompt diff --git a/codex-rs/ext/guardian-reviewer/src/review.rs b/codex-rs/ext/guardian-reviewer/src/review.rs index 3942d2ec75..f1d80af072 100644 --- a/codex-rs/ext/guardian-reviewer/src/review.rs +++ b/codex-rs/ext/guardian-reviewer/src/review.rs @@ -12,6 +12,8 @@ use codex_extension_api::ExtensionFuture; use codex_extension_api::SynchronousApprovalReviewer; use codex_protocol::approvals::GuardianReviewReason; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentOutcome; @@ -62,7 +64,12 @@ pub trait ReviewHost: Send + Sync { evidence: Self::Evidence, event: &GuardianAssessmentEvent, ) -> impl Future + Send; - fn interrupt(&self, turn_id: &str, warning: EventMsg) -> impl Future + Send; + fn interrupt( + &self, + turn_id: &str, + warning: EventMsg, + error: ErrorEvent, + ) -> impl Future + Send; } impl SynchronousApprovalReviewer for ReviewRequest<'_, H> { @@ -137,7 +144,14 @@ impl SynchronousApprovalReviewer for ReviewRequest<'_, H> { self.host .interrupt( &turn_id, - EventMsg::GuardianWarning(WarningEvent { message }), + EventMsg::GuardianWarning(WarningEvent { + message: message.clone(), + }), + ErrorEvent { + message, + codex_error_info: Some(CodexErrorInfo::TooManyDenials), + misalignment: None, + }, ) .await; } diff --git a/codex-rs/protocol/src/codex_error_info.rs b/codex-rs/protocol/src/codex_error_info.rs index c018fae107..10928a467c 100644 --- a/codex-rs/protocol/src/codex_error_info.rs +++ b/codex-rs/protocol/src/codex_error_info.rs @@ -26,6 +26,7 @@ enum CodexErrorInfoWire { CyberPolicy, BioPolicy, MisalignmentPolicyViolation, + TooManyDenials, HttpConnectionFailed { http_status_code: Option, }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index da9ba1ad09..abc4e90847 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1861,6 +1861,7 @@ pub enum CodexErrorInfo { CyberPolicy, BioPolicy, MisalignmentPolicyViolation, + TooManyDenials, HttpConnectionFailed { http_status_code: Option, }, @@ -1905,6 +1906,7 @@ impl CodexErrorInfo { | Self::CyberPolicy | Self::BioPolicy | Self::MisalignmentPolicyViolation + | Self::TooManyDenials | Self::HttpConnectionFailed { .. } | Self::ResponseStreamConnectionFailed { .. } | Self::InternalServerError @@ -4255,6 +4257,10 @@ pub struct Chunk { pub struct TurnAbortedEvent { pub turn_id: Option, pub reason: TurnAbortReason, + /// Optional error describing why the turn was interrupted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, /// Unix timestamp (in seconds) when the turn started. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] diff --git a/codex-rs/rollout/src/persistence_metrics_tests.rs b/codex-rs/rollout/src/persistence_metrics_tests.rs index 264ca889c6..02a600a8e1 100644 --- a/codex-rs/rollout/src/persistence_metrics_tests.rs +++ b/codex-rs/rollout/src/persistence_metrics_tests.rs @@ -67,6 +67,7 @@ fn turn_aborted(turn_id: &str) -> RolloutItem { turn_id: Some(turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, + error: None, completed_at: None, duration_ms: None, })) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 765315a11c..29e08651ff 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -267,6 +267,7 @@ async fn new_config( guardian_policy_config: None, guardian_extra_policy: None, guardian_policy_template: None, + guardian_circuit_break_action: Default::default(), include_permissions_instructions: false, include_apps_instructions: false, include_collaboration_mode_instructions: false, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 005fdd26b4..4d7d54aeb4 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -547,6 +547,7 @@ class CodexErrorInfoValue(Enum): server_overloaded = "serverOverloaded" cyber_policy = "cyberPolicy" misalignment_policy_violation = "misalignmentPolicyViolation" + too_many_denials = "tooManyDenials" internal_server_error = "internalServerError" unauthorized = "unauthorized" bad_request = "badRequest" @@ -11473,7 +11474,7 @@ class Turn(BaseModel): ), ] = None error: Annotated[ - TurnError | None, Field(description="Only populated when the Turn's status is failed.") + TurnError | None, Field(description="Error associated with a failed or interrupted turn.") ] = None id: Annotated[ str, Field(description="Identifier for this turn. Codex-generated turn IDs are UUIDv7.")