Add safety check notification and error handling (#19055)

Adds a new app-server notification that fires when a user account has
been flagged for potential safety reasons.
This commit is contained in:
Eric Traut
2026-04-22 22:24:12 -07:00
committed by GitHub
Unverified
parent 02170996e6
commit bbff4ee61a
61 changed files with 1414 additions and 15 deletions
@@ -480,6 +480,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -2260,6 +2261,34 @@
],
"type": "object"
},
"ModelVerification": {
"enum": [
"trustedAccessForCyber"
],
"type": "string"
},
"ModelVerificationNotification": {
"properties": {
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"verifications": {
"items": {
"$ref": "#/definitions/ModelVerification"
},
"type": "array"
}
},
"required": [
"threadId",
"turnId",
"verifications"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
@@ -5339,6 +5368,26 @@
"title": "Model/reroutedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"model/verification"
],
"title": "Model/verificationNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ModelVerificationNotification"
}
},
"required": [
"method",
"params"
],
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4424,6 +4424,26 @@
"title": "Model/reroutedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"model/verification"
],
"title": "Model/verificationNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ModelVerificationNotification"
}
},
"required": [
"method",
"params"
],
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -5985,6 +6005,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -10893,6 +10914,36 @@
],
"type": "object"
},
"ModelVerification": {
"enum": [
"trustedAccessForCyber"
],
"type": "string"
},
"ModelVerificationNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"verifications": {
"items": {
"$ref": "#/definitions/v2/ModelVerification"
},
"type": "array"
}
},
"required": [
"threadId",
"turnId",
"verifications"
],
"title": "ModelVerificationNotification",
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -2565,6 +2565,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -7628,6 +7629,36 @@
],
"type": "object"
},
"ModelVerification": {
"enum": [
"trustedAccessForCyber"
],
"type": "string"
},
"ModelVerificationNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"verifications": {
"items": {
"$ref": "#/definitions/ModelVerification"
},
"type": "array"
}
},
"required": [
"threadId",
"turnId",
"verifications"
],
"title": "ModelVerificationNotification",
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -10941,6 +10972,26 @@
"title": "Model/reroutedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"model/verification"
],
"title": "Model/verificationNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ModelVerificationNotification"
}
},
"required": [
"method",
"params"
],
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -9,6 +9,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ModelVerification": {
"enum": [
"trustedAccessForCyber"
],
"type": "string"
}
},
"properties": {
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
},
"verifications": {
"items": {
"$ref": "#/definitions/ModelVerification"
},
"type": "array"
}
},
"required": [
"threadId",
"turnId",
"verifications"
],
"title": "ModelVerificationNotification",
"type": "object"
}
@@ -32,6 +32,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -94,6 +94,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -94,6 +94,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -94,6 +94,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -32,6 +32,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -35,6 +35,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -32,6 +32,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -32,6 +32,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -32,6 +32,7 @@
"contextWindowExceeded",
"usageLimitExceeded",
"serverOverloaded",
"cyberPolicy",
"internalServerError",
"unauthorized",
"badRequest",
@@ -29,6 +29,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau
import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification";
import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification";
import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification";
import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification";
import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification";
import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification";
import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification";
@@ -63,4 +64,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -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" | "usageLimitExceeded" | "serverOverloaded" | { "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" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "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";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ModelVerification = "trustedAccessForCyber";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModelVerification } from "./ModelVerification";
export type ModelVerificationNotification = { threadId: string, turnId: string, verifications: Array<ModelVerification>, };
@@ -231,6 +231,8 @@ export type { ModelListResponse } from "./ModelListResponse";
export type { ModelRerouteReason } from "./ModelRerouteReason";
export type { ModelReroutedNotification } from "./ModelReroutedNotification";
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
export type { ModelVerification } from "./ModelVerification";
export type { ModelVerificationNotification } from "./ModelVerificationNotification";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
@@ -1060,6 +1060,7 @@ server_notification_definitions! {
/// Deprecated: Use `ContextCompaction` item type instead.
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
Warning => "warning" (v2::WarningNotification),
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
@@ -72,6 +72,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
use codex_protocol::protocol::HookScope as CoreHookScope;
use codex_protocol::protocol::HookSource as CoreHookSource;
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
use codex_protocol::protocol::ModelVerification as CoreModelVerification;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
@@ -163,6 +164,7 @@ pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
ServerOverloaded,
CyberPolicy,
HttpConnectionFailed {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
@@ -207,6 +209,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded,
CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => {
CodexErrorInfo::HttpConnectionFailed { http_status_code }
}
@@ -425,6 +428,12 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum ModelVerification from CoreModelVerification {
TrustedAccessForCyber
}
);
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop
@@ -7350,6 +7359,15 @@ pub struct ModelReroutedNotification {
pub reason: ModelRerouteReason,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelVerificationNotification {
pub thread_id: String,
pub turn_id: String,
pub verifications: Vec<ModelVerification>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -9952,6 +9970,14 @@ mod tests {
);
}
#[test]
fn codex_error_info_serializes_cyber_policy_in_camel_case() {
assert_eq!(
serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(),
json!("cyberPolicy")
);
}
#[test]
fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
let value = CodexErrorInfo::ActiveTurnNotSteerable {
+1
View File
@@ -1028,6 +1028,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `turn/diff/updated``{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items.
- `turn/plan/updated``{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
- `model/rerouted``{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
- `model/verification``{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
@@ -61,6 +61,7 @@ use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::ModelVerificationNotification;
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction;
@@ -399,6 +400,18 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
}
EventMsg::ModelVerification(event) => {
if let ApiVersion::V2 = api_version {
let notification = ModelVerificationNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
verifications: event.verifications.into_iter().map(Into::into).collect(),
};
outgoing
.send_server_notification(ServerNotification::ModelVerification(notification))
.await;
}
}
EventMsg::RealtimeConversationStarted(event) => {
if let ApiVersion::V2 = api_version {
let notification = ThreadRealtimeStartedNotification {
@@ -660,6 +660,8 @@ mod tests {
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::ModelRerouteReason;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::ModelVerification;
use codex_app_server_protocol::ModelVerificationNotification;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::ToolRequestUserInputParams;
@@ -863,6 +865,30 @@ mod tests {
);
}
#[test]
fn verify_model_verification_notification_serialization() {
let notification = ServerNotification::ModelVerification(ModelVerificationNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
verifications: vec![ModelVerification::TrustedAccessForCyber],
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
assert_eq!(
json!({
"method": "model/verification",
"params": {
"threadId": "thread-1",
"turnId": "turn-1",
"verifications": ["trustedAccessForCyber"],
},
}),
serde_json::to_value(jsonrpc_notification)
.expect("ensure the notification serializes correctly"),
"ensure the notification serializes correctly"
);
}
#[tokio::test]
async fn send_response_routes_to_target_connection() {
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
@@ -1,12 +1,16 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ModelRerouteReason;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::ModelVerification;
use codex_app_server_protocol::ModelVerificationNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
@@ -19,10 +23,14 @@ use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::ResponseTemplate;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const REQUESTED_MODEL: &str = "gpt-5.4";
const SERVER_MODEL: &str = "gpt-5.3-codex";
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
const CYBER_POLICY_MESSAGE: &str =
"This request has been flagged for potentially high-risk cyber activity.";
#[tokio::test]
async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> {
@@ -88,6 +96,75 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() ->
Ok(())
}
#[tokio::test]
async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": CYBER_POLICY_MESSAGE,
"type": "invalid_request",
"param": null,
"code": "cyber_policy"
}
}));
let _response_mock = responses::mount_response_once(&server, response).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some(REQUESTED_MODEL.to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "trigger cyber policy error".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let turn_start: TurnStartResponse = to_response(turn_resp)?;
let error = collect_cyber_policy_error_and_validate_no_reroute(&mut mcp).await?;
assert_eq!(
error,
ErrorNotification {
error: codex_app_server_protocol::TurnError {
message: CYBER_POLICY_MESSAGE.to_string(),
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
additional_details: None,
},
will_retry: false,
thread_id: thread.id,
turn_id: turn_start.turn.id,
}
);
Ok(())
}
#[tokio::test]
async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested()
-> Result<()> {
@@ -161,6 +238,73 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when
Ok(())
}
#[tokio::test]
async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_model_verification_metadata(
"resp-1",
vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION],
),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response = responses::sse_response(body);
let _response_mock = responses::mount_response_once(&server, response).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some(REQUESTED_MODEL.to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "trigger model verification".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let turn_start: TurnStartResponse = to_response(turn_resp)?;
let verification =
collect_model_verification_notifications_and_validate_no_warning_item(&mut mcp).await?;
assert_eq!(
verification,
ModelVerificationNotification {
thread_id: thread.id,
turn_id: turn_start.turn.id,
verifications: vec![ModelVerification::TrustedAccessForCyber],
}
);
Ok(())
}
async fn collect_turn_notifications_and_validate_no_warning_item(
mcp: &mut McpProcess,
) -> Result<ModelReroutedNotification> {
@@ -203,6 +347,90 @@ async fn collect_turn_notifications_and_validate_no_warning_item(
}
}
async fn collect_model_verification_notifications_and_validate_no_warning_item(
mcp: &mut McpProcess,
) -> Result<ModelVerificationNotification> {
let mut verification = None;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"model/verification" => {
let params = notification.params.ok_or_else(|| {
anyhow::anyhow!("model/verification notifications must include params")
})?;
let payload: ModelVerificationNotification = serde_json::from_value(params)?;
verification = Some(payload);
}
"warning" => {
anyhow::bail!("verification-only response must not emit warning");
}
"model/rerouted" => {
anyhow::bail!("verification-only response must not emit model/rerouted");
}
"item/started" => {
let params = notification.params.ok_or_else(|| {
anyhow::anyhow!("item/started notifications must include params")
})?;
let payload: ItemStartedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
}
"item/completed" => {
let params = notification.params.ok_or_else(|| {
anyhow::anyhow!("item/completed notifications must include params")
})?;
let payload: ItemCompletedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
}
"turn/completed" => {
let verification = verification.ok_or_else(|| {
anyhow::anyhow!(
"expected model/verification notification before turn/completed"
)
})?;
return Ok(verification);
}
_ => {}
}
}
}
async fn collect_cyber_policy_error_and_validate_no_reroute(
mcp: &mut McpProcess,
) -> Result<ErrorNotification> {
let mut error = None;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"error" => {
let params = notification
.params
.ok_or_else(|| anyhow::anyhow!("error notifications must include params"))?;
let payload: ErrorNotification = serde_json::from_value(params)?;
if payload.error.codex_error_info == Some(CodexErrorInfo::CyberPolicy) {
error = Some(payload);
}
}
"model/rerouted" => {
anyhow::bail!("cyber policy response must not emit model/rerouted");
}
"turn/completed" => {
return error.ok_or_else(|| {
anyhow::anyhow!("expected cyber policy error before turn/completed")
});
}
_ => {}
}
}
}
fn warning_text_from_item(item: &ThreadItem) -> Option<&str> {
let ThreadItem::UserMessage { content, .. } = item else {
return None;
+3
View File
@@ -69,6 +69,9 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value
codex_api::ResponseEvent::ServerModel(model) => {
json!({ "type": "response.server_model", "model": model })
}
codex_api::ResponseEvent::ModelVerifications(verifications) => {
json!({ "type": "response.model_verifications", "verifications": verifications })
}
codex_api::ResponseEvent::ServerReasoningIncluded(included) => {
json!({ "type": "response.server_reasoning_included", "included": included })
}
+17 -1
View File
@@ -32,6 +32,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
identity_error_code: None,
}),
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message },
ApiError::Transport(transport) => match transport {
TransportError::Http {
status,
@@ -55,7 +56,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
}
if status == http::StatusCode::BAD_REQUEST {
if body_text
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
&& let Some(error) = parsed.get("error")
&& error.get("code").and_then(Value::as_str)
== Some(CYBER_POLICY_ERROR_CODE)
{
let message = error
.get("message")
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
CodexErr::CyberPolicy { message }
} else if body_text
.contains("The image data you provided does not represent a valid image")
{
CodexErr::InvalidImageRequest()
@@ -125,6 +138,9 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
const CF_RAY_HEADER: &str = "cf-ray";
const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error";
const X_ERROR_JSON_HEADER: &str = "x-error-json";
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
"This request has been flagged for possible cybersecurity risk.";
#[cfg(test)]
#[path = "api_bridge_tests.rs"]
@@ -26,6 +26,104 @@ fn map_api_error_maps_server_overloaded_from_503_body() {
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_cyber_policy_from_400_body() {
let body = serde_json::json!({
"error": {
"message": "This request has been flagged for potentially high-risk cyber activity.",
"type": "invalid_request",
"param": null,
"code": "cyber_policy"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
body: Some(body),
}));
let CodexErr::CyberPolicy { message } = err else {
panic!("expected CodexErr::CyberPolicy, got {err:?}");
};
assert_eq!(
message,
"This request has been flagged for potentially high-risk cyber activity."
);
}
#[test]
fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() {
let body = serde_json::json!({
"type": "error",
"status": 400,
"error": {
"message": "This websocket request was flagged.",
"type": "invalid_request",
"code": "cyber_policy"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("ws://example.com/v1/responses".to_string()),
headers: None,
body: Some(body),
}));
let CodexErr::CyberPolicy { message } = err else {
panic!("expected CodexErr::CyberPolicy, got {err:?}");
};
assert_eq!(message, "This websocket request was flagged.");
}
#[test]
fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
let body = serde_json::json!({
"error": {
"code": "cyber_policy"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
body: Some(body),
}));
let CodexErr::CyberPolicy { message } = err else {
panic!("expected CodexErr::CyberPolicy, got {err:?}");
};
assert_eq!(
message,
"This request has been flagged for possible cybersecurity risk."
);
}
#[test]
fn map_api_error_keeps_unknown_400_errors_generic() {
let body = serde_json::json!({
"error": {
"message": "Some other bad request.",
"code": "some_other_policy"
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
body: Some(body.clone()),
}));
let CodexErr::InvalidRequest(message) = err else {
panic!("expected CodexErr::InvalidRequest, got {err:?}");
};
assert_eq!(message, body);
}
#[test]
fn map_api_error_maps_usage_limit_limit_name_header() {
let mut headers = HeaderMap::new();
+3
View File
@@ -3,6 +3,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::W3cTraceContext;
@@ -71,6 +72,8 @@ pub enum ResponseEvent {
/// Emitted when the server includes `OpenAI-Model` on the stream response.
/// This can differ from the requested model when backend safety routing applies.
ServerModel(String),
/// Emitted when the server recommends additional account verification.
ModelVerifications(Vec<ModelVerification>),
/// Emitted when `X-Reasoning-Included: true` is present on the response,
/// meaning the server already accounted for past reasoning tokens and the
/// client should not re-estimate them.
@@ -608,6 +608,7 @@ async fn run_websocket_response_stream(
continue;
}
};
let model_verifications = event.model_verifications();
if event.kind() == "codex.rate_limits" {
if let Some(snapshot) = parse_rate_limit_event(&text) {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
@@ -622,6 +623,16 @@ async fn run_websocket_response_stream(
.await;
last_server_model = Some(model);
}
if let Some(verifications) = model_verifications
&& tx_event
.send(Ok(ResponseEvent::ModelVerifications(verifications)))
.await
.is_err()
{
return Err(ApiError::Stream(
"response event consumer dropped".to_string(),
));
}
match process_responses_event(event) {
Ok(Some(event)) => {
let is_completed = matches!(event, ResponseEvent::Completed { .. });
+2
View File
@@ -27,6 +27,8 @@ pub enum ApiError {
RateLimit(String),
#[error("invalid request: {message}")]
InvalidRequest { message: String },
#[error("cyber policy: {message}")]
CyberPolicy { message: String },
#[error("server overloaded")]
ServerOverloaded,
}
+231 -3
View File
@@ -7,6 +7,7 @@ use codex_client::ByteStream;
use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::TokenUsage;
use eventsource_stream::Eventsource;
use futures::StreamExt;
@@ -27,6 +28,7 @@ use tracing::trace;
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
const OPENAI_MODEL_HEADER: &str = "openai-model";
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
/// Streams SSE events from an on-disk fixture for tests.
pub fn stream_from_fixture(
@@ -165,6 +167,7 @@ pub struct ResponsesStreamEvent {
#[serde(rename = "type")]
pub(crate) kind: String,
headers: Option<Value>,
metadata: Option<Value>,
response: Option<Value>,
item: Option<Value>,
item_id: Option<String>,
@@ -183,8 +186,7 @@ impl ResponsesStreamEvent {
///
/// Precedence:
/// 1. `response.headers` for standard Responses stream events.
/// 2. top-level `headers` for websocket metadata events (for example
/// `codex.response.metadata`).
/// 2. top-level `headers` for websocket metadata events.
pub fn response_model(&self) -> Option<String> {
let response_headers_model = self
.response
@@ -200,6 +202,17 @@ impl ResponsesStreamEvent {
.and_then(header_openai_model_value_from_json),
}
}
pub(crate) fn model_verifications(&self) -> Option<Vec<ModelVerification>> {
if self.kind() != "response.metadata" {
return None;
}
self.metadata
.as_ref()
.and_then(|metadata| metadata.get("openai_verification_recommendation"))
.and_then(model_verifications_from_json_value)
}
}
fn header_openai_model_value_from_json(value: &Value) -> Option<String> {
@@ -214,6 +227,38 @@ fn header_openai_model_value_from_json(value: &Value) -> Option<String> {
})
}
fn model_verifications_from_json_value(value: &Value) -> Option<Vec<ModelVerification>> {
let verifications = value
.as_array()
.map(|items| {
let mut verifications = Vec::new();
for verification in items
.iter()
.filter_map(Value::as_str)
.filter_map(parse_model_verification)
{
if !verifications.contains(&verification) {
verifications.push(verification);
}
}
verifications
})
.unwrap_or_default();
if verifications.is_empty() {
None
} else {
Some(verifications)
}
}
fn parse_model_verification(value: &str) -> Option<ModelVerification> {
match value {
TRUSTED_ACCESS_FOR_CYBER_VERIFICATION => Some(ModelVerification::TrustedAccessForCyber),
_ => None,
}
}
fn json_value_as_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
@@ -296,6 +341,9 @@ pub fn process_responses_event(
response_error = ApiError::QuotaExceeded;
} else if is_usage_not_included(&error) {
response_error = ApiError::UsageNotIncluded;
} else if is_cyber_policy_error(&error) {
let message = cyber_policy_message(error.message);
response_error = ApiError::CyberPolicy { message };
} else if is_invalid_prompt_error(&error) {
let message = error
.message
@@ -414,6 +462,7 @@ pub async fn process_sse(
continue;
}
};
let model_verifications = event.model_verifications();
if let Some(model) = event.response_model()
&& last_server_model.as_deref() != Some(model.as_str())
@@ -427,6 +476,14 @@ pub async fn process_sse(
}
last_server_model = Some(model);
}
if let Some(verifications) = model_verifications
&& tx_event
.send(Ok(ResponseEvent::ModelVerifications(verifications)))
.await
.is_err()
{
return;
}
match process_responses_event(event) {
Ok(Some(event)) => {
@@ -488,11 +545,25 @@ fn is_invalid_prompt_error(error: &Error) -> bool {
error.code.as_deref() == Some("invalid_prompt")
}
fn is_cyber_policy_error(error: &Error) -> bool {
error.code.as_deref() == Some("cyber_policy")
}
fn is_server_overloaded_error(error: &Error) -> bool {
error.code.as_deref() == Some("server_is_overloaded")
|| error.code.as_deref() == Some("slow_down")
}
fn cyber_policy_fallback_message() -> String {
"This request has been flagged for possible cybersecurity risk.".to_string()
}
fn cyber_policy_message(message: Option<String>) -> String {
message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(cyber_policy_fallback_message)
}
fn rate_limit_regex() -> &'static regex_lite::Regex {
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
#[expect(clippy::unwrap_used)]
@@ -841,6 +912,45 @@ mod tests {
assert_matches!(events[0], Err(ApiError::QuotaExceeded));
}
#[tokio::test]
async fn cyber_policy_error_is_fatal() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":"This request was flagged for cyber policy."},"incomplete_details":null}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::CyberPolicy { message }) => {
assert_eq!(message, "This request was flagged for cyber policy.");
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn cyber_policy_error_uses_fallback_for_empty_message() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":" "},"incomplete_details":null}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::CyberPolicy { message }) => {
assert_eq!(
message,
"This request has been flagged for possible cybersecurity risk."
);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn invalid_prompt_without_type_is_invalid_request() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#;
@@ -975,6 +1085,43 @@ mod tests {
}
}
#[tokio::test]
async fn spawn_response_stream_ignores_model_verification_header() {
let mut headers = HeaderMap::new();
headers.insert(
"openai-verification-recommendation",
HeaderValue::from_static(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION),
);
let completed = json!({
"type": "response.completed",
"response": { "id": "resp-1" }
});
let sse = format!("event: response.completed\ndata: {completed}\n\n");
let bytes = stream::iter(vec![Ok(Bytes::from(sse))]);
let stream_response = StreamResponse {
status: StatusCode::OK,
headers,
bytes: Box::pin(bytes),
};
let mut stream = spawn_response_stream(
stream_response,
idle_timeout(),
/*telemetry*/ None,
/*turn_state*/ None,
);
let mut events = Vec::new();
while let Some(event) = stream.rx_event.recv().await {
events.push(event.expect("expected ok event"));
}
assert!(
!events
.iter()
.any(|event| matches!(event, ResponseEvent::ModelVerifications(_)))
);
}
#[tokio::test]
async fn process_sse_ignores_response_model_field_in_payload() {
let events = run_sse(vec![
@@ -1042,10 +1189,44 @@ mod tests {
);
}
#[tokio::test]
async fn process_sse_emits_model_verification_field() {
let events = run_sse(vec![
json!({
"type": "response.metadata",
"sequence_number": 1,
"response_id": "resp-1",
"metadata": {
"openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]
}
}),
json!({
"type": "response.completed",
"response": {
"id": "resp-1"
}
}),
])
.await;
assert_matches!(
&events[0],
ResponseEvent::ModelVerifications(verifications)
if verifications == &vec![ModelVerification::TrustedAccessForCyber]
);
assert_matches!(
&events[1],
ResponseEvent::Completed {
response_id,
token_usage: None
} if response_id == "resp-1"
);
}
#[test]
fn responses_stream_event_response_model_reads_top_level_headers() {
let ev: ResponsesStreamEvent = serde_json::from_value(json!({
"type": "codex.response.metadata",
"type": "response.metadata",
"headers": {
"openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS,
}
@@ -1080,6 +1261,53 @@ mod tests {
);
}
#[test]
fn responses_stream_event_model_verification_reads_metadata_field() {
let event = json!({
"type": "response.metadata",
"sequence_number": 1,
"response_id": "resp-1",
"metadata": {
"openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]
}
});
let event: ResponsesStreamEvent =
serde_json::from_value(event).expect("expected event to deserialize");
assert_eq!(
event.model_verifications(),
Some(vec![ModelVerification::TrustedAccessForCyber])
);
}
#[test]
fn responses_stream_event_model_verification_ignores_unknown_field() {
let event = json!({
"type": "response.metadata",
"metadata": {
"openai_verification_recommendation": ["unknown"]
}
});
let event: ResponsesStreamEvent =
serde_json::from_value(event).expect("expected event to deserialize");
assert_eq!(event.model_verifications(), None);
}
#[test]
fn responses_stream_event_model_verification_ignores_non_array_field() {
let event = json!({
"type": "response.metadata",
"metadata": {
"openai_verification_recommendation": TRUSTED_ACCESS_FOR_CYBER_VERIFICATION
}
});
let event: ResponsesStreamEvent =
serde_json::from_value(event).expect("expected event to deserialize");
assert_eq!(event.model_verifications(), None);
}
#[test]
fn test_try_parse_retry_after() {
let err = Error {
+14
View File
@@ -331,6 +331,8 @@ use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::ModelRerouteEvent;
use codex_protocol::protocol::ModelRerouteReason;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::ModelVerificationEvent;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::Op;
@@ -2385,6 +2387,18 @@ impl Session {
true
}
pub(crate) async fn emit_model_verification(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
verifications: Vec<ModelVerification>,
) {
self.send_event(
turn_context,
EventMsg::ModelVerification(ModelVerificationEvent { verifications }),
)
.await;
}
pub(crate) async fn replace_history(
&self,
items: Vec<ResponseItem>,
+3
View File
@@ -1,5 +1,6 @@
use super::turn_context::image_generation_tool_auth_allowed;
use super::*;
use std::sync::atomic::AtomicBool;
/// Spawn a review thread using the given prompt.
pub(super) async fn spawn_review_thread(
@@ -140,6 +141,8 @@ pub(super) async fn spawn_review_thread(
turn_metadata_state,
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),
turn_timing_state: Arc::new(TurnTimingState::default()),
server_model_warning_emitted: AtomicBool::new(false),
model_verification_emitted: AtomicBool::new(false),
};
// Seed the child task with the review prompt as the initial user message.
+17 -7
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use crate::SkillInjections;
use crate::SkillLoadOutcome;
@@ -364,7 +365,6 @@ pub(crate) async fn run_turn(
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let mut server_model_warning_emitted_for_turn = false;
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
// one instance across retries within this turn.
@@ -455,7 +455,6 @@ pub(crate) async fn run_turn(
sampling_request_input,
&explicitly_enabled_connectors,
skills_outcome,
&mut server_model_warning_emitted_for_turn,
cancellation_token.child_token(),
)
.await
@@ -1022,7 +1021,6 @@ async fn run_sampling_request(
input: Vec<ResponseItem>,
explicitly_enabled_connectors: &HashSet<String>,
skills_outcome: Option<&SkillLoadOutcome>,
server_model_warning_emitted_for_turn: &mut bool,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let router = built_tools(
@@ -1076,7 +1074,6 @@ async fn run_sampling_request(
client_session,
turn_metadata_header,
Arc::clone(&turn_diff_tracker),
server_model_warning_emitted_for_turn,
&prompt,
cancellation_token.child_token(),
)
@@ -1490,6 +1487,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::RealtimeConversationRealtime(_)
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::TurnStarted(_)
@@ -1866,7 +1864,6 @@ async fn try_run_sampling_request(
client_session: &mut ModelClientSession,
turn_metadata_header: Option<&str>,
turn_diff_tracker: SharedTurnDiffTracker,
server_model_warning_emitted_for_turn: &mut bool,
prompt: &Prompt,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
@@ -2099,12 +2096,25 @@ async fn try_run_sampling_request(
}
}
ResponseEvent::ServerModel(server_model) => {
if !*server_model_warning_emitted_for_turn
if !turn_context
.server_model_warning_emitted
.load(Ordering::Relaxed)
&& sess
.maybe_warn_on_server_model_mismatch(&turn_context, server_model)
.await
{
*server_model_warning_emitted_for_turn = true;
turn_context
.server_model_warning_emitted
.store(true, Ordering::Relaxed);
}
}
ResponseEvent::ModelVerifications(verifications) => {
if !turn_context
.model_verification_emitted
.swap(true, Ordering::Relaxed)
{
sess.emit_model_verification(&turn_context, verifications)
.await;
}
}
ResponseEvent::ServerReasoningIncluded(included) => {
+12
View File
@@ -3,6 +3,8 @@ use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
matches!(
@@ -82,6 +84,8 @@ pub(crate) struct TurnContext {
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
pub(crate) turn_skills: TurnSkillsContext,
pub(crate) turn_timing_state: Arc<TurnTimingState>,
pub(crate) server_model_warning_emitted: AtomicBool,
pub(crate) model_verification_emitted: AtomicBool,
}
impl TurnContext {
pub(crate) fn permission_profile(&self) -> PermissionProfile {
@@ -216,6 +220,12 @@ impl TurnContext {
turn_metadata_state: self.turn_metadata_state.clone(),
turn_skills: self.turn_skills.clone(),
turn_timing_state: Arc::clone(&self.turn_timing_state),
server_model_warning_emitted: AtomicBool::new(
self.server_model_warning_emitted.load(Ordering::Relaxed),
),
model_verification_emitted: AtomicBool::new(
self.model_verification_emitted.load(Ordering::Relaxed),
),
}
}
@@ -469,6 +479,8 @@ impl Session {
turn_metadata_state,
turn_skills: TurnSkillsContext::new(skills_outcome),
turn_timing_state: Arc::new(TurnTimingState::default()),
server_model_warning_emitted: AtomicBool::new(false),
model_verification_emitted: AtomicBool::new(false),
}
}
+1
View File
@@ -143,6 +143,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool {
| ResponseEvent::ReasoningContentDelta { .. } => true,
ResponseEvent::Created
| ResponseEvent::ServerModel(_)
| ResponseEvent::ModelVerifications(_)
| ResponseEvent::ServerReasoningIncluded(_)
| ResponseEvent::ToolCallInputDelta { .. }
| ResponseEvent::Completed { .. }
+11
View File
@@ -632,6 +632,17 @@ pub fn ev_response_created(id: &str) -> Value {
})
}
pub fn ev_model_verification_metadata(id: &str, verifications: Vec<&str>) -> Value {
serde_json::json!({
"type": "response.metadata",
"sequence_number": 1,
"response_id": id,
"metadata": {
"openai_verification_recommendation": verifications,
}
})
}
pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value {
serde_json::json!({
"type": "response.completed",
@@ -2,13 +2,16 @@ use anyhow::Result;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ModelRerouteReason;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_model_verification_metadata;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_response_once;
use core_test_support::responses::mount_response_sequence;
@@ -20,9 +23,14 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use wiremock::ResponseTemplate;
const SERVER_MODEL: &str = "gpt-5.2";
const REQUESTED_MODEL: &str = "gpt-5.3-codex";
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
const CYBER_POLICY_MESSAGE: &str =
"This request has been flagged for potentially high-risk cyber activity.";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Result<()> {
@@ -113,6 +121,57 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": CYBER_POLICY_MESSAGE,
"type": "invalid_request",
"param": null,
"code": "cyber_policy"
}
}));
let mock = mount_response_once(&server, response).await;
let mut builder = test_codex().with_model(REQUESTED_MODEL);
let test = builder.build(&server).await?;
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: "trigger cyber policy error".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: REQUESTED_MODEL.to_string(),
effort: test.config.model_reasoning_effort,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await;
let EventMsg::Error(error) = error else {
panic!("expected error event");
};
assert_eq!(error.message, CYBER_POLICY_MESSAGE);
assert_eq!(error.codex_error_info, Some(CodexErrorInfo::CyberPolicy));
mock.single_request();
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn response_model_field_mismatch_emits_warning_when_header_matches_requested() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -318,3 +377,150 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_verification_emits_structured_event_without_reroute_or_warning() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = sse_response(sse(vec![
ev_response_created("resp-1"),
ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
core_test_support::responses::ev_completed("resp-1"),
]));
let _mock = mount_response_once(&server, response).await;
let mut builder = test_codex().with_model(REQUESTED_MODEL);
let test = builder.build(&server).await?;
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: "trigger model verification".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: REQUESTED_MODEL.to_string(),
effort: test.config.model_reasoning_effort,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let mut verification_count = 0;
let mut reroute_count = 0;
let mut warning_count = 0;
let mut warning_item_count = 0;
loop {
let event = wait_for_event(&test.codex, |_| true).await;
match event {
EventMsg::ModelVerification(event) => {
assert_eq!(
event.verifications,
vec![ModelVerification::TrustedAccessForCyber]
);
verification_count += 1;
}
EventMsg::Warning(_) => warning_count += 1,
EventMsg::ModelReroute(_) => reroute_count += 1,
EventMsg::RawResponseItem(raw)
if matches!(
&raw.item,
ResponseItem::Message { content, .. }
if content.iter().any(|item| matches!(
item,
ContentItem::InputText { text } if text.starts_with("Warning: ")
))
) =>
{
warning_item_count += 1;
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
assert_eq!(verification_count, 1);
assert_eq!(reroute_count, 0);
assert_eq!(warning_count, 0);
assert_eq!(warning_item_count, 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_verification_only_emits_once_per_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let tool_args = serde_json::json!({
"command": "echo hello",
"timeout_ms": 1_000
});
let first_response = sse_response(sse(vec![
ev_response_created("resp-1"),
ev_function_call(
"call-1",
"shell_command",
&serde_json::to_string(&tool_args)?,
),
ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
core_test_support::responses::ev_completed("resp-1"),
]));
let second_response = sse_response(sse(vec![
ev_response_created("resp-2"),
ev_model_verification_metadata("resp-2", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
ev_assistant_message("msg-1", "done"),
core_test_support::responses::ev_completed("resp-2"),
]));
let _mock = mount_response_sequence(&server, vec![first_response, second_response]).await;
let mut builder = test_codex().with_model(REQUESTED_MODEL);
let test = builder.build(&server).await?;
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: "trigger follow-up model verification".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: REQUESTED_MODEL.to_string(),
effort: test.config.model_reasoning_effort,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let mut verification_count = 0;
loop {
let event = wait_for_event(&test.codex, |_| true).await;
match event {
EventMsg::ModelVerification(_) => verification_count += 1,
EventMsg::Warning(warning) if warning.message.contains("high-risk cyber activity") => {
panic!("model verification should not emit a warning event");
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
assert_eq!(verification_count, 1);
Ok(())
}
@@ -292,6 +292,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
);
CodexStatus::Running
}
ServerNotification::ModelVerification(_) => CodexStatus::Running,
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.last_total_token_usage = Some(notification.token_usage);
CodexStatus::Running
@@ -489,6 +489,7 @@ impl EventProcessorWithJsonOutput {
}));
CodexStatus::Running
}
ServerNotification::ModelVerification(_) => CodexStatus::Running,
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.last_total_token_usage = Some(notification.token_usage);
CodexStatus::Running
+3
View File
@@ -1138,6 +1138,9 @@ fn should_process_notification(
ServerNotification::ModelRerouted(notification) => {
notification.thread_id == thread_id && notification.turn_id == turn_id
}
ServerNotification::ModelVerification(notification) => {
notification.thread_id == thread_id && notification.turn_id == turn_id
}
ServerNotification::ThreadTokenUsageUpdated(notification) => {
notification.thread_id == thread_id && notification.turn_id == turn_id
}
+3 -1
View File
@@ -263,7 +263,9 @@ async fn run_codex_tool_session_inner(
outgoing.send_response(request_id.clone(), result).await;
break;
}
EventMsg::Warning(_) | EventMsg::GuardianWarning(_) => {
EventMsg::Warning(_)
| EventMsg::GuardianWarning(_)
| EventMsg::ModelVerification(_) => {
continue;
}
EventMsg::GuardianAssessment(_) => {
@@ -1058,6 +1058,7 @@ impl SessionTelemetry {
"reasoning_summary_part_added".into()
}
ResponseEvent::ServerModel(_) => "server_model".into(),
ResponseEvent::ModelVerifications(_) => "model_verifications".into(),
ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(),
ResponseEvent::RateLimits(_) => "rate_limits".into(),
ResponseEvent::ModelsEtag(_) => "models_etag".into(),
+5 -1
View File
@@ -110,6 +110,8 @@ pub enum CodexErr {
UsageLimitReached(UsageLimitReachedError),
#[error("Selected model is at capacity. Please try a different model.")]
ServerOverloaded,
#[error("{message}")]
CyberPolicy { message: String },
#[error("{0}")]
ResponseStreamFailed(ResponseStreamFailed),
#[error("{0}")]
@@ -186,7 +188,8 @@ impl CodexErr {
| CodexErr::Spawn
| CodexErr::SessionConfiguredNotFirstEvent
| CodexErr::UsageLimitReached(_)
| CodexErr::ServerOverloaded => false,
| CodexErr::ServerOverloaded
| CodexErr::CyberPolicy { .. } => false,
CodexErr::Stream(..)
| CodexErr::Timeout
| CodexErr::UnexpectedStatus(_)
@@ -217,6 +220,7 @@ impl CodexErr {
| CodexErr::QuotaExceeded
| CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
CodexErr::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: self.http_status_code_value(),
},
+17
View File
@@ -1538,6 +1538,9 @@ pub enum EventMsg {
/// Model routing changed from the requested model to a different model.
ModelReroute(ModelRerouteEvent),
/// Backend recommends additional account verification for this turn.
ModelVerification(ModelVerificationEvent),
/// Conversation history was compacted (either automatically or manually).
ContextCompacted(ContextCompactedEvent),
@@ -1964,6 +1967,7 @@ pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
ServerOverloaded,
CyberPolicy,
HttpConnectionFailed {
http_status_code: Option<u16>,
},
@@ -2000,6 +2004,7 @@ impl CodexErrorInfo {
Self::ContextWindowExceeded
| Self::UsageLimitExceeded
| Self::ServerOverloaded
| Self::CyberPolicy
| Self::HttpConnectionFailed { .. }
| Self::ResponseStreamConnectionFailed { .. }
| Self::InternalServerError
@@ -2183,6 +2188,18 @@ pub struct ModelRerouteEvent {
pub reason: ModelRerouteReason,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum ModelVerification {
TrustedAccessForCyber,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct ModelVerificationEvent {
pub verifications: Vec<ModelVerification>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ContextCompactedEvent;
@@ -81,6 +81,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String {
ApiError::Retryable { .. } => "retryable error".to_string(),
ApiError::RateLimit(_) => "rate limit".to_string(),
ApiError::InvalidRequest { .. } => "invalid request".to_string(),
ApiError::CyberPolicy { .. } => "cyber policy".to_string(),
ApiError::ServerOverloaded => "server overloaded".to_string(),
}
}
+1
View File
@@ -138,6 +138,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::RealtimeConversationRealtime(_)
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
| EventMsg::ModelVerification(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
@@ -400,6 +400,9 @@ fn server_notification_thread_target(
}
ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelVerification(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeStarted(notification) => {
Some(notification.thread_id.as_str())
}
+52
View File
@@ -97,6 +97,7 @@ use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
@@ -194,6 +195,8 @@ use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
#[cfg(test)]
use codex_protocol::protocol::ModelVerification as CoreModelVerification;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::RateLimitReachedType;
@@ -256,6 +259,7 @@ const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session.";
const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING: &str = "Your account was flagged for potentially high-risk cyber activity. Requests may be slower while additional verification is applied. To regain faster access, apply for trusted access: https://chatgpt.com/cyber or learn more: https://developers.openai.com/codex/concepts/cyber-safety";
const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories";
const MEMORIES_ENABLE_TITLE: &str = "Enable memories?";
const MEMORIES_ENABLE_YES: &str = "Yes, enable";
@@ -651,6 +655,15 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<Ra
}
}
#[cfg(test)]
fn is_core_cyber_policy_error(info: &CoreCodexErrorInfo) -> bool {
matches!(info, CoreCodexErrorInfo::CyberPolicy)
}
fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ExternalEditorState {
#[default]
@@ -3040,6 +3053,16 @@ impl ChatWidget {
self.maybe_send_next_queued_input();
}
fn on_cyber_policy_error(&mut self) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_cyber_policy_error_event());
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
self.maybe_send_next_queued_input();
}
fn workspace_owner_usage_nudge_enabled(&self) -> bool {
self.config
.features
@@ -3105,6 +3128,11 @@ impl ChatWidget {
.as_ref()
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
{
} else if codex_error_info
.as_ref()
.is_some_and(is_app_server_cyber_policy_error)
{
self.on_cyber_policy_error();
} else if let Some(info) = codex_error_info
.as_ref()
.and_then(app_server_rate_limit_error_kind)
@@ -3125,6 +3153,19 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_core_model_verification(&mut self, verifications: &[CoreModelVerification]) {
if verifications.contains(&CoreModelVerification::TrustedAccessForCyber) {
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
}
}
fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) {
if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) {
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
}
}
/// Record one MCP startup update, promoting it into either the active startup
/// round or a buffered "next" round.
///
@@ -6579,6 +6620,9 @@ impl ChatWidget {
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
}
ServerNotification::ModelRerouted(_) => {}
ServerNotification::ModelVerification(notification) => {
self.on_app_server_model_verification(&notification.verifications)
}
ServerNotification::Warning(notification) => self.on_warning(notification.message),
ServerNotification::GuardianWarning(notification) => {
self.on_warning(notification.message)
@@ -7084,6 +7128,9 @@ impl ChatWidget {
| EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message),
EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev),
EventMsg::ModelReroute(_) => {}
EventMsg::ModelVerification(event) => {
self.on_core_model_verification(&event.verifications)
}
EventMsg::Error(ErrorEvent {
message,
codex_error_info,
@@ -7092,6 +7139,11 @@ impl ChatWidget {
.as_ref()
.is_some_and(|info| self.handle_steer_rejected_error(info))
{
} else if codex_error_info
.as_ref()
.is_some_and(is_core_cyber_policy_error)
{
self.on_cyber_policy_error();
} else if let Some(kind) = codex_error_info
.as_ref()
.and_then(core_rate_limit_error_kind)
+5
View File
@@ -70,6 +70,8 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface;
pub(super) use codex_app_server_protocol::McpServerStartupState;
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchChangeKind;
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
@@ -147,6 +149,7 @@ pub(super) use codex_protocol::protocol::CodexErrorInfo;
pub(super) use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
pub(super) use codex_protocol::protocol::CollabAgentSpawnEndEvent;
pub(super) use codex_protocol::protocol::CreditsSnapshot;
pub(super) use codex_protocol::protocol::ErrorEvent;
pub(super) use codex_protocol::protocol::Event;
pub(super) use codex_protocol::protocol::EventMsg;
pub(super) use codex_protocol::protocol::ExecApprovalRequestEvent;
@@ -169,6 +172,8 @@ pub(super) use codex_protocol::protocol::ItemCompletedEvent;
pub(super) use codex_protocol::protocol::McpStartupCompleteEvent;
pub(super) use codex_protocol::protocol::McpStartupStatus;
pub(super) use codex_protocol::protocol::McpStartupUpdateEvent;
pub(super) use codex_protocol::protocol::ModelVerification as CoreModelVerification;
pub(super) use codex_protocol::protocol::ModelVerificationEvent;
pub(super) use codex_protocol::protocol::NonSteerableTurnKind;
pub(super) use codex_protocol::protocol::Op;
pub(super) use codex_protocol::protocol::PatchApplyBeginEvent;
@@ -678,6 +678,71 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: "thread-1".to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
started_at: Some(0),
completed_at: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
drain_insert_history(&mut rx);
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "server fallback message".to_string(),
codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()),
additional_details: None,
},
will_retry: false,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This chat was flagged for possible cybersecurity risk"));
assert!(rendered.contains("Trusted Access for Cyber"));
assert!(!rendered.contains("server fallback message"));
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn live_app_server_model_verification_renders_warning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
ServerNotification::ModelVerification(ModelVerificationNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
verifications: vec![AppServerModelVerification::TrustedAccessForCyber],
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("flagged for potentially high-risk cyber activity"));
assert!(rendered.contains("slower while additional verification"));
assert!(rendered.contains("https://chatgpt.com/cyber"));
}
#[tokio::test]
async fn live_app_server_invalid_thread_name_update_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -28,6 +28,45 @@ async fn token_count_none_resets_context_indicator() {
assert_eq!(chat.bottom_pane.context_window_percent(), None);
}
#[tokio::test]
async fn core_cyber_policy_error_renders_dedicated_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "cyber-policy".into(),
msg: EventMsg::Error(ErrorEvent {
message: "server fallback message".to_string(),
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This chat was flagged for possible cybersecurity risk"));
assert!(rendered.contains("Trusted Access for Cyber"));
assert!(!rendered.contains("server fallback message"));
}
#[tokio::test]
async fn core_model_verification_renders_warning() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "model-verification".into(),
msg: EventMsg::ModelVerification(ModelVerificationEvent {
verifications: vec![CoreModelVerification::TrustedAccessForCyber],
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("flagged for potentially high-risk cyber activity"));
assert!(rendered.contains("slower while additional verification"));
assert!(rendered.contains("https://chatgpt.com/cyber"));
}
#[tokio::test]
async fn context_indicator_shows_used_tokens_when_window_unknown() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await;
+58
View File
@@ -1832,6 +1832,50 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
PrefixedWrappedHistoryCell::new(message.yellow(), "".yellow(), " ")
}
const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber";
#[derive(Debug)]
pub(crate) struct CyberPolicyNoticeCell;
pub(crate) fn new_cyber_policy_error_event() -> CyberPolicyNoticeCell {
CyberPolicyNoticeCell
}
impl HistoryCell for CyberPolicyNoticeCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(
vec![
"".cyan(),
"This chat was flagged for possible cybersecurity risk".bold(),
]
.into(),
);
let wrap_width = width.saturating_sub(2).max(1) as usize;
let body = Line::from(vec![
" If this seems wrong, try rephrasing your request. To get authorized for security work, join the "
.dim(),
"Trusted Access for Cyber".cyan().underlined(),
" program.".dim(),
]);
let wrapped = adaptive_wrap_line(
&body,
RtOptions::new(wrap_width).subsequent_indent(" ".into()),
);
push_owned_lines(&wrapped, &mut lines);
lines.push(
vec![
" ".into(),
TRUSTED_ACCESS_FOR_CYBER_URL.cyan().underlined(),
]
.into(),
);
lines
}
}
#[derive(Debug)]
pub(crate) struct DeprecationNoticeCell {
summary: String,
@@ -3255,6 +3299,20 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[test]
fn cyber_policy_error_event_snapshot() {
let cell = new_cyber_policy_error_event();
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn cyber_policy_error_event_narrow_snapshot() {
let cell = new_cyber_policy_error_event();
let rendered = render_lines(&cell.display_lines(/*width*/ 36)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_long_command_snapshot() {
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
@@ -0,0 +1,12 @@
---
source: tui/src/history_cell.rs
assertion_line: 3312
expression: rendered
---
ⓘ This chat was flagged for possible cybersecurity risk
If this seems wrong, try
rephrasing your request. To get
authorized for security work,
join the Trusted Access for
Cyber program.
https://chatgpt.com/cyber
@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
assertion_line: 3305
expression: rendered
---
ⓘ This chat was flagged for possible cybersecurity risk
If this seems wrong, try rephrasing your request. To get authorized for
security work, join the Trusted Access for Cyber program.
https://chatgpt.com/cyber