chore: merge main into Windows proxy ports

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-06 15:53:00 -07:00
co-authored by Codex noreply@openai.com
126 changed files with 4189 additions and 999 deletions
+1
View File
@@ -3264,6 +3264,7 @@ dependencies = [
"clap",
"codex-core",
"codex-install-context",
"codex-network-proxy",
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
-3
View File
@@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub mod config {
pub use codex_core::config::*;
@@ -556,6 +556,13 @@
},
"ConsumeAccountRateLimitResetCreditParams": {
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -8688,6 +8688,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -14533,11 +14540,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/v2/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/v2/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -14545,6 +14619,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -4928,6 +4928,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -10937,11 +10944,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -10949,6 +11023,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -49,11 +49,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -61,6 +128,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
* retrying that attempt.
*/
idempotencyKey: string, };
idempotencyKey: string,
/**
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
* available credit.
*/
creditId?: string | null, };
@@ -0,0 +1,27 @@
// 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 { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
import type { RateLimitResetType } from "./RateLimitResetType";
export type RateLimitResetCredit = {
/**
* Opaque backend identifier for this reset credit.
*/
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
/**
* Unix timestamp in seconds when the credit was granted.
*/
grantedAt: number,
/**
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
*/
expiresAt: number | null,
/**
* Backend-provided display title for this credit, or `null` when unavailable.
*/
title: string | null,
/**
* Backend-provided display description for this credit, or `null` when unavailable.
*/
description: string | null, };
@@ -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 RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
@@ -1,5 +1,14 @@
// 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 { RateLimitResetCredit } from "./RateLimitResetCredit";
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
export type RateLimitResetCreditsSummary = { availableCount: bigint,
/**
* Detail rows for available reset credits, when the backend provides them.
*
* `null` means only `availableCount` is known, while an empty array means details were fetched
* and no available credits were returned. The backend may cap this list, so its length can be
* less than `availableCount`.
*/
credits: Array<RateLimitResetCredit> | null, };
@@ -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 RateLimitResetType = "codexRateLimits" | "unknown";
@@ -335,7 +335,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
export type { RateLimitResetType } from "./RateLimitResetType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
@@ -3195,6 +3195,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: Some(true),
flush_transcript_tail_on_session_end: Some(true),
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: Some("silent context".to_string()),
@@ -3216,6 +3217,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": true,
"flushTranscriptTailOnSessionEnd": true,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": "silent context",
@@ -3240,6 +3242,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3261,6 +3264,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3280,6 +3284,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3301,6 +3306,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3518,6 +3524,7 @@ mod tests {
request_id: RequestId::Integer(1),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse {
#[ts(export_to = "v2/")]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
/// Detail rows for available reset credits, when the backend provides them.
///
/// `null` means only `availableCount` is known, while an empty array means details were fetched
/// and no available credits were returned. The backend may cap this list, so its length can be
/// less than `availableCount`.
pub credits: Option<Vec<RateLimitResetCredit>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RateLimitResetCredit {
/// Opaque backend identifier for this reset credit.
pub id: String,
pub reset_type: RateLimitResetType,
pub status: RateLimitResetCreditStatus,
/// Unix timestamp in seconds when the credit was granted.
#[ts(type = "number")]
pub granted_at: i64,
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
#[ts(type = "number | null")]
pub expires_at: Option<i64>,
/// Backend-provided display title for this credit, or `null` when unavailable.
pub title: Option<String>,
/// Backend-provided display description for this credit, or `null` when unavailable.
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetType {
CodexRateLimits,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetCreditStatus {
Available,
Redeeming,
Redeemed,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
/// retrying that attempt.
pub idempotency_key: String,
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
/// available credit.
#[ts(optional = nullable)]
pub credit_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams {
/// them automatically. Defaults to false.
#[ts(optional = nullable)]
pub client_managed_handoffs: Option<bool>,
/// Routes any transcript tail remaining at session end through Codex. Defaults to false.
/// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
#[ts(optional = nullable)]
pub flush_transcript_tail_on_session_end: Option<bool>,
/// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
#[ts(optional = nullable)]
pub codex_responses_as_items: Option<bool>,
@@ -685,7 +685,7 @@ pub struct ThreadIncrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadIncrementElicitationResponse {
/// Current out-of-band elicitation count after the increment.
pub count: u64,
pub count: i64,
/// Whether timeout accounting is paused after applying the increment.
pub paused: bool,
}
@@ -705,7 +705,7 @@ pub struct ThreadDecrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadDecrementElicitationResponse {
/// Current out-of-band elicitation count after the decrement.
pub count: u64,
pub count: i64,
/// Whether timeout accounting remains paused after applying the decrement.
pub paused: bool,
}
+34 -7
View File
@@ -1301,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse.
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
@@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
@@ -2032,7 +2032,30 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{
"id": 7,
"result": {
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
"rateLimitReachedType": null
},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{
"id": "RateLimitResetCredit_1",
"resetType": "codexRateLimits",
"status": "available",
"grantedAt": 1781654400,
"expiresAt": 1784246400,
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
}
]
}
}
}
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -2043,23 +2066,27 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
- Refetch `account/rateLimits/read` after consuming a reset.
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
### 9) Workspace messages (ChatGPT)
+11 -1
View File
@@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManager;
@@ -349,7 +350,16 @@ impl InProcessClientHandle {
/// This function sends `initialize` followed by `initialized` before returning
/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
let (path, range) = crate::exec_policy_warning_location(&err);
args.config_warnings.push(ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
});
}
let initialize = args.initialize.clone();
let client = start_uninitialized(args).await?;
+11 -8
View File
@@ -320,6 +320,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option<String>, Optio
}
}
fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification {
let (path, range) = exec_policy_warning_location(err);
ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
}
}
fn app_text_range(range: &CoreTextRange) -> AppTextRange {
AppTextRange {
start: AppTextPosition {
@@ -612,14 +622,7 @@ pub async fn run_main_with_transport_options(
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
};
config_warnings.push(message);
config_warnings.push(exec_policy_config_warning(&err));
}
if let Some(warning) = project_config_warning(&config) {
+2 -1
View File
@@ -447,7 +447,7 @@ impl MessageProcessor {
outgoing.clone(),
analytics_events_client.clone(),
Arc::clone(&config),
config_warnings,
config_warnings.clone(),
rpc_transport,
);
let marketplace_processor = MarketplaceRequestProcessor::new(
@@ -495,6 +495,7 @@ impl MessageProcessor {
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
config_warnings,
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -298,6 +301,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
@@ -891,9 +891,11 @@ impl AccountRequestProcessor {
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
@@ -920,6 +922,15 @@ impl AccountRequestProcessor {
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
response
.rate_limit_reset_credits
.map(|summary| RateLimitResetCreditsSummary {
available_count: summary.available_count,
credits: None,
})
});
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
@@ -928,11 +939,7 @@ impl AccountRequestProcessor {
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
rate_limit_reset_credits,
})
}
@@ -1,11 +1,47 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(super) async fn detailed_rate_limit_reset_credits(
client: &BackendClient,
) -> Option<RateLimitResetCreditsSummary> {
let details = match tokio::time::timeout(
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
client.list_rate_limit_reset_credits(),
)
.await
{
Ok(Ok(details)) => details,
Ok(Err(err)) => {
tracing::warn!(
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
);
return None;
}
Err(_) => {
tracing::warn!(
"rate limit reset credit detail request timed out; falling back to the usage response"
);
return None;
}
};
match rate_limit_reset_credits_from_backend(details) {
Ok(summary) => Some(summary),
Err(err) => {
tracing::warn!(
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
);
None
}
}
}
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
if params.credit_id.as_deref().is_some_and(str::is_empty) {
return Err(invalid_request("creditId must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
let response = tokio::time::timeout(request_timeout, async {
match params.credit_id.as_deref() {
Some(credit_id) => {
client
.consume_rate_limit_reset_credit_by_id(&params.idempotency_key, credit_id)
.await
}
None => {
client
.consume_rate_limit_reset_credit(&params.idempotency_key)
.await
}
}
})
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
fn rate_limit_reset_credits_from_backend(
details: BackendRateLimitResetCreditsDetails,
) -> Result<RateLimitResetCreditsSummary, String> {
let credits = details
.credits
.into_iter()
.map(rate_limit_reset_credit_from_backend)
.collect::<Result<Vec<_>, _>>()?;
Ok(RateLimitResetCreditsSummary {
available_count: details.available_count,
credits: Some(credits),
})
}
fn rate_limit_reset_credit_from_backend(
credit: BackendRateLimitResetCreditDetails,
) -> Result<RateLimitResetCredit, String> {
let reset_type = match credit.reset_type.as_str() {
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
_ => RateLimitResetType::Unknown,
};
let status = match credit.status.as_str() {
"available" => RateLimitResetCreditStatus::Available,
"redeeming" => RateLimitResetCreditStatus::Redeeming,
"redeemed" => RateLimitResetCreditStatus::Redeemed,
_ => RateLimitResetCreditStatus::Unknown,
};
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
let expires_at = credit
.expires_at
.as_deref()
.map(rate_limit_reset_credit_timestamp)
.transpose()
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
Ok(RateLimitResetCredit {
id: credit.id,
reset_type,
status,
granted_at,
expires_at,
title: credit.title,
description: credit.description,
})
}
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
DateTime::parse_from_rfc3339(timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
}
@@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
/// Outcome of trying to satisfy a resume request from an already loaded thread.
@@ -395,6 +396,7 @@ impl ThreadRequestProcessor {
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
initial_config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
Self {
auth_manager,
@@ -413,6 +415,7 @@ impl ThreadRequestProcessor {
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
initial_config_warnings: Arc::new(initial_config_warnings),
}
}
@@ -968,6 +971,7 @@ impl ThreadRequestProcessor {
};
let request_trace = request_context.request_trace();
let config_manager = self.config_manager.clone();
let initial_config_warnings = Arc::clone(&self.initial_config_warnings);
let outgoing = Arc::clone(&listener_task_context.outgoing);
let error_request_id = request_id.clone();
let thread_start_task = async move {
@@ -990,6 +994,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback,
experimental_raw_events,
request_trace,
initial_config_warnings,
)
.await
{
@@ -1066,6 +1071,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback: bool,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
) -> Result<(), JSONRPCErrorError> {
let thread_start_started_at = std::time::Instant::now();
let requested_cwd = typesafe_overrides.cwd.clone();
@@ -1139,6 +1145,21 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
if let Ok(Some(err)) =
codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await
{
let notification = crate::exec_policy_config_warning(&err);
if !initial_config_warnings.contains(&notification) {
listener_task_context
.outgoing
.send_server_notification_to_connections(
&[request_id.connection_id],
ServerNotification::ConfigWarning(notification),
)
.await;
}
}
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -1002,6 +1002,9 @@ impl TurnRequestProcessor {
thread.as_ref(),
Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false),
flush_transcript_tail_on_session_end: params
.flush_transcript_tail_on_session_end
.unwrap_or(false),
codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false),
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_prefix: params.codex_response_handoff_prefix,
@@ -7,6 +7,7 @@ use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
@@ -18,6 +19,8 @@ use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use futures::SinkExt;
use futures::StreamExt;
use hmac::Hmac;
@@ -104,6 +107,106 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() ->
Ok(())
}
#[tokio::test]
async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let project = TempDir::new()?;
std::fs::create_dir(project.path().join(".git"))?;
let rules_dir = project.path().join(".codex/rules");
std::fs::create_dir_all(&rules_dir)?;
let rules_path = rules_dir.join("broken.rules");
std::fs::write(&rules_path, "prefix_rule(")?;
set_project_trust_level(codex_home.path(), project.path(), TrustLevel::Trusted)?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut requester = connect_websocket(bind_addr).await?;
let mut other_client = connect_websocket(bind_addr).await?;
send_initialize_request(&mut requester, /*id*/ 1, "requester").await?;
read_response_for_id(&mut requester, /*id*/ 1).await?;
send_initialize_request(&mut other_client, /*id*/ 2, "other_client").await?;
read_response_for_id(&mut other_client, /*id*/ 2).await?;
send_request(
&mut requester,
"thread/start",
/*id*/ 3,
Some(serde_json::to_value(ThreadStartParams {
cwd: Some(project.path().display().to_string()),
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let target_id = RequestId::Integer(3);
let warning_summary = "Error parsing rules; custom rules not applied.";
let is_exec_policy_warning = |notification: &JSONRPCNotification| {
notification.method == "configWarning"
&& notification
.params
.as_ref()
.and_then(|params| params.get("summary"))
.and_then(serde_json::Value::as_str)
== Some(warning_summary)
};
let mut response = None;
let mut warning = None;
while response.is_none() || warning.is_none() {
match read_jsonrpc_message(&mut requester).await? {
JSONRPCMessage::Response(candidate) if candidate.id == target_id => {
response = Some(candidate);
}
JSONRPCMessage::Notification(candidate) if is_exec_policy_warning(&candidate) => {
warning = Some(candidate);
}
_ => {}
}
}
let _: ThreadStartResponse = to_response(response.context("missing thread/start response")?)?;
let warning: ConfigWarningNotification = serde_json::from_value(
warning
.context("missing exec-policy configWarning")?
.params
.context("configWarning should include params")?,
)?;
assert_eq!(
warning
.path
.as_deref()
.map(Path::new)
.and_then(Path::file_name),
Some(std::ffi::OsStr::new("broken.rules"))
);
match timeout(Duration::from_millis(250), async {
loop {
let message = read_jsonrpc_message(&mut other_client).await?;
if let JSONRPCMessage::Notification(notification) = message
&& is_exec_policy_warning(&notification)
{
return Ok::<_, anyhow::Error>(notification);
}
}
})
.await
{
Ok(Ok(_)) => bail!("exec-policy configWarning leaked to another connection"),
Ok(Err(err)) => return Err(err),
Err(_) => {}
}
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
@@ -82,6 +82,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
let request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -198,6 +199,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
let request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: None,
},
)
.await?;
@@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"redeem_request_id": "request-selected",
"credit_id": "credit-123",
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-selected".to_string(),
credit_id: Some("credit-123".to_string()),
},
)
.await?;
assert_eq!(
read_response::<ConsumeAccountRateLimitResetCreditResponse>(&mut mcp, request_id).await?,
ConsumeAccountRateLimitResetCreditResponse {
outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset,
}
);
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
@@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: String::new(),
credit_id: None,
},
)
.await?;
@@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: Some(String::new()),
},
)
.await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "creditId must not be empty");
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
@@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st
mcp.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: idempotency_key.to_string(),
credit_id: None,
},
)
.await
@@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
@@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
.expect("parse secondary reset timestamp")
.timestamp();
let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z")
.expect("parse reset credit grant timestamp")
.timestamp();
let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z")
.expect("parse reset credit expiry timestamp")
.timestamp();
let second_reset_credit_granted_at =
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "pro",
"rate_limit": {
@@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "future_reset_type",
"status": "future_status",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
})))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
@@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.into_iter()
.collect(),
),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
available_count: 2,
credits: Some(vec![
RateLimitResetCredit {
id: "credit-1".to_string(),
reset_type: RateLimitResetType::CodexRateLimits,
status: RateLimitResetCreditStatus::Available,
granted_at: reset_credit_granted_at,
expires_at: Some(reset_credit_expires_at),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCredit {
id: "credit-2".to_string(),
reset_type: RateLimitResetType::Unknown,
status: RateLimitResetCreditStatus::Unknown,
granted_at: second_reset_credit_granted_at,
expires_at: None,
title: None,
description: None,
},
]),
}),
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "pro",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 42,
"limit_window_seconds": 3600,
"reset_after_seconds": 120,
"reset_at": 1735689720
}
},
"rate_limit_reset_credits": { "available_count": 3 }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_get_account_rate_limits_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountRateLimitsResponse = to_response(response)?;
assert_eq!(
received.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary {
available_count: 3,
credits: None,
})
);
Ok(())
}
#[tokio::test]
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -349,6 +349,7 @@ impl RealtimeE2eHarness {
.mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs,
flush_transcript_tail_on_session_end: None,
thread_id: self.thread_id.clone(),
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
@@ -410,6 +411,7 @@ impl RealtimeE2eHarness {
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
thread_id: self.thread_id.clone(),
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
@@ -672,6 +674,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -963,6 +966,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1061,6 +1065,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1242,6 +1247,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1346,6 +1352,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2701,6 +2708,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2767,6 +2775,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -7,14 +7,18 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::TextPosition;
use codex_app_server_protocol::TextRange;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
@@ -54,6 +58,17 @@ use super::analytics::wait_for_analytics_payload;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const EXEC_POLICY_PARSE_WARNING_SUMMARY: &str = "Error parsing rules; custom rules not applied.";
fn is_exec_policy_config_warning(notification: &JSONRPCNotification) -> bool {
notification.method == "configWarning"
&& notification
.params
.as_ref()
.and_then(|params| params.get("summary"))
.and_then(Value::as_str)
== Some(EXEC_POLICY_PARSE_WARNING_SUMMARY)
}
async fn start_thread_with_model(
mcp: &mut TestAppServer,
@@ -104,6 +119,124 @@ model = "gpt-5.4-mini"
Ok(())
}
#[tokio::test]
async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let rules_dir = codex_home.path().join("rules");
std::fs::create_dir_all(&rules_dir)?;
let rules_path = rules_dir.join("broken.rules");
std::fs::write(&rules_path, "prefix_rule(")?;
let rules_path = std::fs::canonicalize(rules_path)?;
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadStartResponse = to_response(response)?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_matching_notification(
"exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await??;
let notification: ServerNotification = notification.try_into()?;
let ServerNotification::ConfigWarning(warning) = notification else {
anyhow::bail!("unexpected notification variant");
};
let ConfigWarningNotification {
summary,
details,
path,
range,
} = warning;
assert_eq!(
(summary, range),
(
"Error parsing rules; custom rules not applied.".to_string(),
Some(TextRange {
start: TextPosition {
line: 1,
column: 13,
},
end: TextPosition {
line: 1,
column: 13,
},
}),
)
);
let path = path.context("warning should include a path")?;
assert_eq!(
normalize_path_for_comparison(path),
normalize_path_for_comparison(&rules_path)
);
let details = details.context("warning should include details")?;
assert!(
details.contains("failed to parse rules file") && details.contains("broken.rules"),
"unexpected warning details: {details}"
);
assert!(
details.contains("Parse error"),
"unexpected warning details: {details}"
);
Ok(())
}
#[tokio::test]
async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result<()> {
let codex_home = TempDir::new()?;
let rules_dir = codex_home.path().join("rules");
std::fs::create_dir_all(&rules_dir)?;
std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_matching_notification(
"initialize exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await??;
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: ThreadStartResponse = to_response(response)?;
let duplicate_warning = timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_matching_notification(
"duplicate exec-policy configWarning",
is_exec_policy_config_warning,
),
)
.await;
assert!(
duplicate_warning.is_err(),
"thread/start repeated the initialize exec-policy warning"
);
Ok(())
}
#[tokio::test]
async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -3,6 +3,7 @@
use super::Client;
use super::PathStyle;
use crate::types::ConsumeRateLimitResetCreditResponse;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitStatusWithResetCredits;
use crate::types::RateLimitsWithResetCredits;
use anyhow::Result;
@@ -13,6 +14,8 @@ use serde::Serialize;
#[derive(Serialize)]
struct ConsumeRateLimitResetCreditRequest<'a> {
redeem_request_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
credit_id: Option<&'a str>,
}
impl Client {
@@ -31,9 +34,34 @@ impl Client {
self.decode_json(&url, &ct, &body)
}
pub async fn list_rate_limit_reset_credits(&self) -> Result<RateLimitResetCreditsDetails> {
let url = self.rate_limit_reset_credits_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn consume_rate_limit_reset_credit(
&self,
redeem_request_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None)
.await
}
pub async fn consume_rate_limit_reset_credit_by_id(
&self,
redeem_request_id: &str,
credit_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id))
.await
}
async fn consume_rate_limit_reset_credit_request(
&self,
redeem_request_id: &str,
credit_id: Option<&str>,
) -> Result<ConsumeRateLimitResetCreditResponse> {
let url = self.consume_rate_limit_reset_credit_url();
let req = self
@@ -41,7 +69,10 @@ impl Client {
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
.json(&ConsumeRateLimitResetCreditRequest {
redeem_request_id,
credit_id,
});
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
@@ -53,6 +84,17 @@ impl Client {
}
}
fn rate_limit_reset_credits_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/rate-limit-reset-credits", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/rate-limit-reset-credits", self.base_url)
}
}
}
fn consume_rate_limit_reset_credit_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
@@ -1,5 +1,7 @@
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditDetails;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
@@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
"https://example.test/api/codex/usage"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(),
"https://example.test/api/codex/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi)
.consume_rate_limit_reset_credit_url(),
@@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
.rate_limit_status_url(),
"https://chatgpt.com/backend-api/wham/usage"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.rate_limit_reset_credits_url(),
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.consume_rate_limit_reset_credit_url(),
@@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-123",
credit_id: None,
})
.unwrap(),
serde_json::json!({ "redeem_request_id": "redeem-123" })
);
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-456",
credit_id: Some("credit-123"),
})
.unwrap(),
serde_json::json!({
"redeem_request_id": "redeem-456",
"credit_id": "credit-123",
})
);
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
"plan_type": "plus",
@@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
Some(RateLimitResetCreditsSummary { available_count: 3 })
);
let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"redeem_started_at": null,
"redeemed_at": null,
"profile_image_url": "https://example.test/avatar.png",
"profile_user_id": "@friend",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
}))
.unwrap();
assert_eq!(
details,
RateLimitResetCreditsDetails {
credits: vec![
RateLimitResetCreditDetails {
id: "credit-1".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-17T00:00:00Z".to_string(),
expires_at: Some("2026-07-17T00:00:00Z".to_string()),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCreditDetails {
id: "credit-2".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-18T00:00:00Z".to_string(),
expires_at: None,
title: None,
description: None,
},
],
available_count: 2,
}
);
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
"code": "reset",
"credit": { "id": "ignored-by-cli" },
+2
View File
@@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::RateLimitResetCreditDetails;
pub use types::RateLimitResetCreditsDetails;
pub use types::RateLimitResetCreditsSummary;
pub use types::RateLimitsWithResetCredits;
pub use types::TaskListItem;
+17
View File
@@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditsDetails {
pub credits: Vec<RateLimitResetCreditDetails>,
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditDetails {
pub id: String,
pub reset_type: String,
pub status: String,
pub granted_at: String,
pub expires_at: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
+3
View File
@@ -3,6 +3,7 @@ mod remote_session;
mod runtime;
mod service;
mod session_runtime;
mod v8_init;
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
@@ -12,3 +13,5 @@ pub use remote_session::ProcessOwnedCodeModeSessionProvider;
pub use service::InProcessCodeModeSession;
pub use service::InProcessCodeModeSessionProvider;
pub use service::NoopCodeModeSessionDelegate;
pub use v8_init::V8JitMode;
pub use v8_init::initialize_v8;
+2 -18
View File
@@ -7,7 +7,6 @@ mod value;
use std::collections::HashMap;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use std::sync::OnceLock;
use std::sync::mpsc as std_mpsc;
use std::thread;
@@ -21,6 +20,7 @@ use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use crate::TaskFailureHandler;
use crate::v8_init::ensure_v8_initialized;
const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
@@ -84,7 +84,7 @@ pub(crate) fn spawn_runtime(
),
String,
> {
initialize_v8()?;
ensure_v8_initialized()?;
let (command_tx, command_rx) = std_mpsc::channel();
let (control_tx, control_rx) = std_mpsc::channel();
@@ -165,22 +165,6 @@ pub(super) enum CompletionState {
},
}
fn initialize_v8() -> Result<(), String> {
static PLATFORM: OnceLock<Result<v8::SharedRef<v8::Platform>, String>> = OnceLock::new();
match PLATFORM.get_or_init(|| {
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform.clone());
v8::V8::initialize();
Ok(platform)
}) {
Ok(_) => Ok(()),
Err(error_text) => Err(error_text.clone()),
}
}
fn run_runtime(
config: RuntimeConfig,
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
+65
View File
@@ -0,0 +1,65 @@
use std::sync::OnceLock;
/// Controls whether V8 may generate executable code at runtime.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum V8JitMode {
#[default]
Enabled,
Disabled,
}
struct V8Initialization {
_platform: v8::SharedRef<v8::Platform>,
jit_mode: V8JitMode,
}
static V8_INITIALIZATION: OnceLock<Result<V8Initialization, String>> = OnceLock::new();
/// Initializes the process-wide V8 platform with the requested JIT mode.
///
/// Call this before executing any code-mode cells when JIT must be disabled.
/// V8 cannot change JIT mode after initialization, so a later call requesting
/// a different mode returns an error. Code mode initializes V8 with JIT enabled
/// by default when this function has not been called explicitly.
pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> {
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) {
Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()),
Ok(initialization) => Err(format!(
"V8 was already initialized with JIT {}",
initialization.jit_mode.description()
)),
Err(error_text) => Err(error_text.clone()),
}
}
pub(crate) fn ensure_v8_initialized() -> Result<(), String> {
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) {
Ok(_) => Ok(()),
Err(error_text) => Err(error_text.clone()),
}
}
fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result<V8Initialization, String> {
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
match jit_mode {
V8JitMode::Enabled => {}
V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"),
}
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform.clone());
v8::V8::initialize();
Ok(V8Initialization {
_platform: platform,
jit_mode,
})
}
impl V8JitMode {
fn description(self) -> &'static str {
match self {
Self::Enabled => "enabled",
Self::Disabled => "disabled",
}
}
}
+41
View File
@@ -0,0 +1,41 @@
use codex_code_mode::ExecuteRequest;
use codex_code_mode::InProcessCodeModeSession;
use codex_code_mode::RuntimeResponse;
use codex_code_mode::V8JitMode;
use codex_code_mode::initialize_v8;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn code_mode_runs_with_jit_disabled() {
initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT");
let service = InProcessCodeModeSession::new();
let started = service
.execute(ExecuteRequest {
tool_call_id: "call_1".to_string(),
enabled_tools: Vec::new(),
source: "21 * 2;".to_string(),
yield_time_ms: None,
max_output_tokens: None,
})
.await
.expect("start code-mode cell");
let cell_id = started.cell_id.clone();
let response = started
.initial_response()
.await
.expect("execute code-mode cell");
assert_eq!(
response,
RuntimeResponse::Result {
cell_id,
content_items: Vec::new(),
error_text: None,
}
);
assert_eq!(
initialize_v8(V8JitMode::Enabled),
Err("V8 was already initialized with JIT disabled".to_string())
);
}
-2
View File
@@ -101,7 +101,6 @@ pub enum ResponseEvent {
delta: String,
},
ReasoningSummaryDelta {
item_id: String,
delta: String,
summary_index: i64,
},
@@ -110,7 +109,6 @@ pub enum ResponseEvent {
content_index: i64,
},
ReasoningSummaryPartAdded {
item_id: String,
summary_index: i64,
},
RateLimits(RateLimitSnapshot),
@@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter {
}
impl RealtimeWebsocketEvents {
pub async fn take_transcript_tail(&self) -> Vec<RealtimeTranscriptEntry> {
let mut active_transcript = self.active_transcript.lock().await;
let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec();
active_transcript.last_handoff_entry_count = active_transcript.entries.len();
tail
}
pub async fn next_event(&self) -> Result<Option<RealtimeEvent>, ApiError> {
if self.is_closed.load(Ordering::SeqCst) {
return Ok(None);
@@ -962,6 +969,56 @@ mod tests {
);
}
#[tokio::test]
async fn takes_only_transcript_after_last_handoff_once() {
let (_tx_message, rx_message) = async_channel::unbounded();
let events = RealtimeWebsocketEvents {
rx_message,
active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())),
event_parser: RealtimeEventParser::V1,
is_closed: Arc::new(AtomicBool::new(false)),
};
assert_eq!(events.take_transcript_tail().await, vec![]);
let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: "already handed off".to_string(),
});
events.update_active_transcript(&mut covered).await;
let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![],
});
events.update_active_transcript(&mut handoff).await;
assert_eq!(
handoff,
RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![RealtimeTranscriptEntry {
role: "user".to_string(),
text: "already handed off".to_string(),
}],
})
);
let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta {
delta: "tail".to_string(),
});
events.update_active_transcript(&mut tail).await;
assert_eq!(
events.take_transcript_tail().await,
vec![RealtimeTranscriptEntry {
role: "assistant".to_string(),
text: "tail".to_string(),
}]
);
assert_eq!(events.take_transcript_tail().await, vec![]);
}
#[test]
fn parse_input_transcript_delta_event() {
let payload = json!({
+2 -44
View File
@@ -351,11 +351,8 @@ pub fn process_responses_event(
}
}
"response.reasoning_summary_text.delta" => {
if let (Some(item_id), Some(delta), Some(summary_index)) =
(event.item_id, event.delta, event.summary_index)
{
if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) {
return Ok(Some(ResponseEvent::ReasoningSummaryDelta {
item_id,
delta,
summary_index,
}));
@@ -447,9 +444,8 @@ pub fn process_responses_event(
}
}
"response.reasoning_summary_part.added" => {
if let (Some(item_id), Some(summary_index)) = (event.item_id, event.summary_index) {
if let Some(summary_index) = event.summary_index {
return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded {
item_id,
summary_index,
}));
}
@@ -800,44 +796,6 @@ mod tests {
}
}
#[tokio::test]
async fn preserves_reasoning_summary_item_ids() {
let events = run_sse(vec![
json!({
"type": "response.reasoning_summary_part.added",
"item_id": "reasoning-1",
"summary_index": 0
}),
json!({
"type": "response.reasoning_summary_text.delta",
"item_id": "reasoning-1",
"summary_index": 0,
"delta": "Checking"
}),
json!({
"type": "response.completed",
"response": { "id": "resp1" }
}),
])
.await;
assert_matches!(
&events[0],
ResponseEvent::ReasoningSummaryPartAdded {
item_id,
summary_index: 0,
} if item_id == "reasoning-1"
);
assert_matches!(
&events[1],
ResponseEvent::ReasoningSummaryDelta {
item_id,
delta,
summary_index: 0,
} if item_id == "reasoning-1" && delta == "Checking"
);
}
#[tokio::test]
async fn error_when_missing_completed() {
let item1 = json!({
@@ -142,6 +142,7 @@ impl McpConnectionManager {
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
elicitation_router: ElicitationRequestRouter,
) -> Self {
let mut required_servers = mcp_servers
@@ -157,6 +158,7 @@ impl McpConnectionManager {
approval_policy.value(),
initial_permission_profile,
elicitation_reviewer,
elicitation_lifecycle,
elicitation_router,
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
@@ -360,6 +362,7 @@ impl McpConnectionManager {
approval_policy.value(),
permission_profile.clone(),
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
),
startup_cancellation_token: CancellationToken::new(),
@@ -2,6 +2,7 @@ use super::*;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheContext;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationLifecycle;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter;
use crate::elicitation::elicitation_is_rejected_by_policy;
@@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
#[tokio::test]
async fn shared_elicitation_router_targets_the_exact_pending_request() {
struct Registration(Arc<AtomicUsize>);
impl Drop for Registration {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
let router = ElicitationRequestRouter::default();
let outstanding = Arc::new(AtomicUsize::new(0));
let lifecycle = ElicitationLifecycle::new({
let outstanding = outstanding.clone();
move || {
outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Registration(outstanding.clone())
}
});
let manager_a = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle.clone()),
router.clone(),
);
let manager_b = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle),
router,
);
let (tx_event, rx_event) = async_channel::bounded(2);
@@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
else {
panic!("expected elicitation request");
};
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2);
let (
codex_protocol::mcp::RequestId::String(request_a_id),
codex_protocol::mcp::RequestId::String(request_b_id),
@@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
.expect("request B response"),
response_b
);
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
@@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
ToolPluginProvenance::default(),
/*auth*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
ElicitationRequestRouter::default(),
)
.await;
+33
View File
@@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync {
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
#[derive(Clone)]
pub struct ElicitationLifecycle {
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
}
impl ElicitationLifecycle {
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
where
T: Send + Sync + 'static,
{
Self {
register: Arc::new(move || Box::new(register())),
}
}
fn start(&self) -> ActiveElicitation {
ActiveElicitation {
_registration: (self.register)(),
}
}
}
struct ActiveElicitation {
_registration: Box<dyn Send + Sync>,
}
/// Routes model-visible elicitation response tokens to their exact pending responders.
///
/// One router is shared by every MCP runtime created for a thread. The public response token is
@@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager {
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
auto_deny: Arc<StdMutex<bool>>,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
}
impl ElicitationRequestManager {
@@ -93,6 +121,7 @@ impl ElicitationRequestManager {
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
router: ElicitationRequestRouter,
) -> Self {
Self {
@@ -101,6 +130,7 @@ impl ElicitationRequestManager {
permission_profile: Arc::new(StdMutex::new(permission_profile)),
auto_deny: Arc::new(StdMutex::new(false)),
reviewer,
lifecycle,
}
}
@@ -140,6 +170,7 @@ impl ElicitationRequestManager {
let permission_profile = self.permission_profile.clone();
let auto_deny = self.auto_deny.clone();
let reviewer = self.reviewer.clone();
let lifecycle = self.lifecycle.clone();
Box::new(move |id, elicitation| {
let router = router.clone();
let tx_event = tx_event.clone();
@@ -148,6 +179,7 @@ impl ElicitationRequestManager {
let permission_profile = permission_profile.clone();
let auto_deny = auto_deny.clone();
let reviewer = reviewer.clone();
let lifecycle = lifecycle.clone();
async move {
let auto_deny = auto_deny
.lock()
@@ -249,6 +281,7 @@ impl ElicitationRequestManager {
},
};
let (tx, rx) = oneshot::channel();
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
{
let mut lock = router.requests.lock().await;
lock.insert((server_name.clone(), routed_request_id), tx);
+1
View File
@@ -1,5 +1,6 @@
pub use connection_manager::McpConnectionManager;
pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationLifecycle;
pub use elicitation::ElicitationRequestRouter;
pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
+2
View File
@@ -339,6 +339,7 @@ pub async fn read_mcp_resource(
tool_plugin_provenance(config),
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
tool_plugin_provenance,
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -143,6 +143,12 @@ pub struct EnvironmentSkillLoadOutcome {
}
/// Discovers skills without converting environment-owned paths to host paths.
#[tracing::instrument(
name = "skills.environment.load",
level = "info",
skip_all,
fields(skill_count = tracing::field::Empty)
)]
pub async fn load_environment_skills_from_root(
file_system: &dyn ExecutorFileSystem,
root: &PathUri,
@@ -235,6 +241,7 @@ pub async fn load_environment_skills_from_root(
warnings: vec![format!("failed to walk skills root {root}: {error:#}")],
},
};
tracing::Span::current().record("skill_count", discovery.skills.len());
outcome.warnings.extend(discovery.warnings);
if discovery.skills.is_empty() {
return outcome;
+4 -1
View File
@@ -5,7 +5,10 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render())
connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
.then(|| AppsInstructions.render())
}
#[cfg(test)]
+23 -24
View File
@@ -1,5 +1,6 @@
use crate::agent::AgentStatus;
use crate::config::ConstraintResult;
use crate::elicitation::ElicitationRegistration;
use crate::session::Codex;
use crate::session::SessionSettingsUpdate;
use crate::session::SteerInputError;
@@ -162,7 +163,13 @@ pub struct CodexThread {
pub(crate) session_source: SessionSource,
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
out_of_band_elicitation_count: Mutex<u64>,
out_of_band_elicitations: Mutex<OutOfBandElicitations>,
}
#[derive(Default)]
struct OutOfBandElicitations {
count: i64,
registration: Option<ElicitationRegistration>,
}
#[derive(Debug, Eq, PartialEq)]
@@ -187,7 +194,7 @@ impl CodexThread {
session_source,
session_configured,
rollout_path,
out_of_band_elicitation_count: Mutex::new(0),
out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()),
}
}
@@ -655,38 +662,30 @@ impl CodexThread {
self.codex.enabled(feature)
}
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
let mut guard = self.out_of_band_elicitation_count.lock().await;
let was_zero = *guard == 0;
*guard = guard.checked_add(1).ok_or_else(|| {
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
let mut elicitations = self.out_of_band_elicitations.lock().await;
let incremented = elicitations.count.checked_add(1).ok_or_else(|| {
CodexErr::Fatal("out-of-band elicitation count overflowed".to_string())
})?;
if was_zero {
self.codex
.session
.set_out_of_band_elicitation_pause_state(/*paused*/ true);
if elicitations.count == 0 {
elicitations.registration = Some(self.codex.session.services.elicitations.register());
}
Ok(*guard)
elicitations.count = incremented;
Ok(incremented)
}
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
let mut guard = self.out_of_band_elicitation_count.lock().await;
if *guard == 0 {
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
let mut elicitations = self.out_of_band_elicitations.lock().await;
if elicitations.count == 0 {
return Err(CodexErr::InvalidRequest(
"out-of-band elicitation count is already zero".to_string(),
));
}
*guard -= 1;
let now_zero = *guard == 0;
if now_zero {
self.codex
.session
.set_out_of_band_elicitation_pause_state(/*paused*/ false);
elicitations.count -= 1;
if elicitations.count == 0 {
elicitations.registration = None;
}
Ok(*guard)
Ok(elicitations.count)
}
}
+1 -13
View File
@@ -80,19 +80,6 @@ pub async fn list_accessible_connectors_from_mcp_tools(
)
}
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
mcp_connection_manager: &McpConnectionManager,
config: &Config,
) -> Vec<AppInfo> {
with_app_enabled_state(
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
config,
)
.into_iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.collect()
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
@@ -283,6 +270,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
)
.await;
@@ -1,4 +1,3 @@
use crate::connectors::AppInfo;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
@@ -8,15 +7,6 @@ use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AppsInstructions;
impl AppsInstructions {
pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option<Self> {
connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
.then_some(Self)
}
}
impl ContextualUserFragment for AppsInstructions {
fn role(&self) -> &'static str {
"developer"
@@ -0,0 +1,55 @@
use super::PreviousSectionState;
use super::WorldStateSection;
use crate::context::AppsInstructions;
use crate::context::ContextualUserFragment;
/// Whether generic Apps usage guidance should be visible to the model.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct AppsInstructionsState {
available: bool,
}
impl AppsInstructionsState {
pub(crate) fn new(available: bool) -> Self {
Self { available }
}
}
impl WorldStateSection for AppsInstructionsState {
const ID: &'static str = "apps_instructions";
type Snapshot = bool;
fn snapshot(&self) -> Self::Snapshot {
self.available
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && AppsInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
true
}
fn matches_retained_fragment(role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
if !self.available
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|| matches!(previous, PreviousSectionState::Unknown)
{
return None;
}
Some(Box::new(AppsInstructions))
}
}
#[cfg(test)]
#[path = "apps_instructions_tests.rs"]
mod tests;
@@ -0,0 +1,69 @@
use super::*;
use crate::context::ContextualUserFragment;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
fn render(state: AppsInstructionsState, previous: PreviousSectionState<'_, bool>) -> Vec<String> {
state
.render_diff(previous)
.into_iter()
.map(|fragment| fragment.render())
.collect()
}
#[test]
fn renders_only_when_apps_become_available() {
let unavailable = AppsInstructionsState::new(/*available*/ false);
let available = AppsInstructionsState::new(/*available*/ true);
let false_snapshot = false;
let true_snapshot = true;
assert_eq!(
render(unavailable, PreviousSectionState::Absent),
Vec::<String>::new()
);
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
assert_eq!(
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
1
);
assert_eq!(
render(available, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
assert_eq!(
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
Vec::<String>::new()
);
}
#[test]
fn legacy_guidance_is_not_injected_again() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
let legacy: ResponseItem = ContextualUserFragment::into(AppsInstructions);
assert!(
world_state
.render_history_diff(/*previous*/ None, &[legacy])
.is_empty()
);
}
#[test]
fn persisted_guidance_is_restored_only_when_missing_from_history() {
let mut world_state = super::super::WorldState::default();
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
let snapshot = world_state.snapshot();
let retained: ResponseItem = ContextualUserFragment::into(AppsInstructions);
assert_eq!(
world_state.render_history_diff(Some(&snapshot), &[]).len(),
1
);
assert!(
world_state
.render_history_diff(Some(&snapshot), &[retained])
.is_empty()
);
}
@@ -1,4 +1,5 @@
mod agents_md;
mod apps_instructions;
mod environment;
mod plugins_instructions;
@@ -17,6 +18,7 @@ use std::collections::BTreeMap;
use std::fmt;
pub(crate) use agents_md::AgentsMdState;
pub(crate) use apps_instructions::AppsInstructionsState;
pub(crate) use environment::EnvironmentsState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
@@ -20,6 +20,7 @@ use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
@@ -1022,6 +1023,9 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
developer_msg(&format!(
"{APPS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_APPS_INSTRUCTIONS"
)),
developer_msg(&format!(
"{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS"
)),
+100
View File
@@ -0,0 +1,100 @@
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::watch;
/// Coordinates user elicitations that pause tool-result delivery for a session.
///
/// Registrations are counted so concurrent elicitations keep the session paused until all of them
/// finish. Consumers can subscribe to pause timeout progress or wait before returning an already
/// captured result.
#[derive(Clone)]
pub(crate) struct ElicitationService {
inner: Arc<Inner>,
}
struct Inner {
state: Mutex<State>,
paused: watch::Sender<bool>,
}
#[derive(Default)]
struct State {
outstanding: i64,
}
pub(crate) struct ElicitationRegistration {
service: ElicitationService,
}
impl ElicitationService {
pub(crate) fn new() -> Self {
let (paused, _paused_rx) = watch::channel(false);
Self {
inner: Arc::new(Inner {
state: Mutex::new(State::default()),
paused,
}),
}
}
pub(crate) fn register(&self) -> ElicitationRegistration {
self.increment();
ElicitationRegistration {
service: self.clone(),
}
}
fn increment(&self) {
let mut state = self
.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let was_clear = state.outstanding == 0;
assert_ne!(
state.outstanding,
i64::MAX,
"outstanding elicitation count overflowed"
);
state.outstanding += 1;
if was_clear {
self.inner.paused.send_replace(true);
}
}
pub(crate) fn subscribe(&self) -> watch::Receiver<bool> {
self.inner.paused.subscribe()
}
pub(crate) async fn wait_until_clear(&self) {
let mut paused = self.subscribe();
let _ = paused.wait_for(|paused| !*paused).await;
}
fn decrement(&self) {
let mut state = self
.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(
state.outstanding > 0,
"elicitation registration count underflowed"
);
state.outstanding -= 1;
if state.outstanding == 0 {
self.inner.paused.send_replace(false);
}
}
}
impl Drop for ElicitationRegistration {
fn drop(&mut self) {
self.service.decrement();
}
}
#[cfg(test)]
#[path = "elicitation_tests.rs"]
mod tests;
+19
View File
@@ -0,0 +1,19 @@
use super::*;
#[tokio::test]
async fn wait_until_clear_waits_for_every_registration() {
let service = ElicitationService::new();
let first = service.register();
let second = service.register();
let waiting = tokio::spawn({
let service = service.clone();
async move { service.wait_until_clear().await }
});
drop(first);
tokio::task::yield_now().await;
assert!(!waiting.is_finished());
drop(second);
waiting.await.expect("elicitation waiter should complete");
}
+2
View File
@@ -14,6 +14,7 @@ use codex_protocol::models::is_image_close_tag_text;
use codex_protocol::models::is_image_open_tag_text;
use codex_protocol::models::is_local_image_close_tag_text;
use codex_protocol::models::is_local_image_open_tag_text;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG;
use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG;
@@ -32,6 +33,7 @@ use crate::web_search::web_search_action_detail;
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<permissions instructions>",
"<model_switch>",
APPS_INSTRUCTIONS_OPEN_TAG,
COLLABORATION_MODE_OPEN_TAG,
MULTI_AGENT_MODE_OPEN_TAG,
PLUGINS_INSTRUCTIONS_OPEN_TAG,
+1
View File
@@ -40,6 +40,7 @@ pub mod connectors;
pub mod context;
mod context_manager;
mod current_time;
mod elicitation;
mod environment_selection;
pub mod exec;
pub mod exec_env;
+1
View File
@@ -1439,6 +1439,7 @@ async fn host_owned_codex_apps_manager(
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
codex_mcp::ElicitationRequestRouter::default(),
)
.await;
@@ -161,6 +161,7 @@ fn denied_network_policy_message_requires_deny_decision() {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
execution_id: None,
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
@@ -178,6 +179,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
execution_id: None,
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
+69 -19
View File
@@ -48,6 +48,7 @@ use codex_protocol::protocol::RealtimeConversationSdpEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RealtimeVoicesList;
use http::HeaderMap;
@@ -59,6 +60,7 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::error;
use tracing::info;
@@ -80,6 +82,7 @@ const REALTIME_V2_STEER_ACKNOWLEDGEMENT: &str =
"This was sent to steer the previous background agent task.";
const REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX: &str =
"Conversation already has an active response in progress:";
const REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION: &str = "The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RealtimeConversationEnd {
@@ -89,7 +92,7 @@ enum RealtimeConversationEnd {
}
enum RealtimeFanoutTaskStop {
Abort,
Await,
Detach,
}
@@ -203,6 +206,9 @@ struct RealtimeInputTask {
handoff_state: RealtimeHandoffState,
session_kind: RealtimeSessionKind,
event_parser: RealtimeEventParser,
flush_transcript_tail_on_session_end: bool,
transcript_tail_tx: Sender<String>,
stop_token: CancellationToken,
}
struct RealtimeInputChannels {
@@ -242,12 +248,14 @@ struct ConversationState {
input_task: JoinHandle<()>,
fanout_task: Option<JoinHandle<()>>,
realtime_active: Arc<AtomicBool>,
stop_token: CancellationToken,
}
struct RealtimeStart {
api_provider: ApiProvider,
extra_headers: Option<HeaderMap>,
client_managed_handoffs: bool,
flush_transcript_tail_on_session_end: bool,
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_prefix: Option<String>,
@@ -260,6 +268,7 @@ struct RealtimeStart {
struct RealtimeStartOutput {
realtime_active: Arc<AtomicBool>,
events_rx: Receiver<RealtimeEvent>,
transcript_tail_rx: Receiver<String>,
sdp: Option<String>,
}
@@ -294,7 +303,7 @@ impl RealtimeConversationManager {
guard.take()
};
if let Some(state) = previous_state {
stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await;
stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
}
self.start_inner(start).await
@@ -305,6 +314,7 @@ impl RealtimeConversationManager {
api_provider,
extra_headers,
client_managed_handoffs,
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_prefix,
@@ -327,8 +337,10 @@ impl RealtimeConversationManager {
async_channel::bounded::<RealtimeOutbound>(HANDOFF_OUT_QUEUE_CAPACITY);
let (events_tx, events_rx) =
async_channel::bounded::<RealtimeEvent>(OUTPUT_EVENTS_QUEUE_CAPACITY);
let (transcript_tail_tx, transcript_tail_rx) = async_channel::bounded::<String>(1);
let realtime_active = Arc::new(AtomicBool::new(true));
let stop_token = CancellationToken::new();
let handoff = RealtimeHandoffState::new(
handoff_output_tx,
client_managed_handoffs,
@@ -364,6 +376,9 @@ impl RealtimeConversationManager {
session_kind,
event_parser,
realtime_active: Arc::clone(&realtime_active),
flush_transcript_tail_on_session_end,
transcript_tail_tx,
stop_token: stop_token.clone(),
});
(task, Some(call.sdp))
} else {
@@ -385,6 +400,9 @@ impl RealtimeConversationManager {
handoff_state: handoff.clone(),
session_kind,
event_parser,
flush_transcript_tail_on_session_end,
transcript_tail_tx,
stop_token: stop_token.clone(),
});
(task, None)
};
@@ -398,10 +416,12 @@ impl RealtimeConversationManager {
input_task: task,
fanout_task: None,
realtime_active: Arc::clone(&realtime_active),
stop_token,
});
Ok(RealtimeStartOutput {
realtime_active,
events_rx,
transcript_tail_rx,
sdp,
})
}
@@ -652,7 +672,7 @@ impl RealtimeConversationManager {
};
if let Some(state) = state {
stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await;
stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
}
Ok(())
}
@@ -663,13 +683,12 @@ async fn stop_conversation_state(
fanout_task_stop: RealtimeFanoutTaskStop,
) {
state.realtime_active.store(false, Ordering::Relaxed);
state.input_task.abort();
state.stop_token.cancel();
let _ = state.input_task.await;
if let Some(fanout_task) = state.fanout_task.take() {
match fanout_task_stop {
RealtimeFanoutTaskStop::Abort => {
fanout_task.abort();
RealtimeFanoutTaskStop::Await => {
let _ = fanout_task.await;
}
RealtimeFanoutTaskStop::Detach => {}
@@ -716,6 +735,7 @@ struct PreparedRealtimeConversationStart {
api_provider: ApiProvider,
extra_headers: Option<HeaderMap>,
client_managed_handoffs: bool,
flush_transcript_tail_on_session_end: bool,
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_prefix: Option<String>,
@@ -800,6 +820,7 @@ async fn prepare_realtime_start(
api_provider,
extra_headers,
client_managed_handoffs: params.client_managed_handoffs,
flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end,
codex_responses_as_items: params.codex_responses_as_items,
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_prefix: params.codex_response_handoff_prefix,
@@ -966,6 +987,7 @@ async fn handle_start_inner(
api_provider,
extra_headers,
client_managed_handoffs,
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_prefix,
@@ -984,6 +1006,7 @@ async fn handle_start_inner(
api_provider,
extra_headers,
client_managed_handoffs,
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_prefix,
@@ -1008,6 +1031,7 @@ async fn handle_start_inner(
let RealtimeStartOutput {
realtime_active,
events_rx,
transcript_tail_rx,
sdp,
} = start_output;
if let Some(sdp) = sdp {
@@ -1027,10 +1051,8 @@ async fn handle_start_inner(
msg,
};
let mut end = RealtimeConversationEnd::TransportClosed;
// Drain already-parsed events so a queued handoff is routed before the final tail.
while let Ok(event) = events_rx.recv().await {
if !fanout_realtime_active.load(Ordering::Relaxed) {
break;
}
match &event {
RealtimeEvent::AudioOut(_) => {}
_ => {
@@ -1054,9 +1076,6 @@ async fn handle_start_inner(
let sess_for_routed_text = Arc::clone(&sess_clone);
sess_for_routed_text.route_realtime_text_input(text).await;
}
if !fanout_realtime_active.load(Ordering::Relaxed) {
break;
}
sess_clone
.send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
RealtimeConversationRealtimeEvent {
@@ -1065,6 +1084,9 @@ async fn handle_start_inner(
)))
.await;
}
if let Ok(text) = transcript_tail_rx.recv().await {
sess_clone.route_realtime_text_input(text).await;
}
if fanout_realtime_active.swap(false, Ordering::Relaxed) {
match end {
RealtimeConversationEnd::TransportClosed => {
@@ -1103,8 +1125,11 @@ pub(crate) async fn handle_audio(
}
fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option<String> {
let active_transcript = handoff
.active_transcript
realtime_transcript_delta(&handoff.active_transcript)
}
fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option<String> {
let active_transcript = active_transcript
.iter()
.map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text))
.collect::<Vec<_>>()
@@ -1254,6 +1279,9 @@ struct RealtimeWebrtcSidebandInputTask {
session_kind: RealtimeSessionKind,
event_parser: RealtimeEventParser,
realtime_active: Arc<AtomicBool>,
flush_transcript_tail_on_session_end: bool,
transcript_tail_tx: Sender<String>,
stop_token: CancellationToken,
}
fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> JoinHandle<()> {
@@ -1268,6 +1296,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
session_kind,
event_parser,
realtime_active,
flush_transcript_tail_on_session_end,
transcript_tail_tx,
stop_token,
} = input;
tokio::spawn(async move {
@@ -1275,15 +1306,15 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
return;
}
let connection = match client
.connect_webrtc_sideband(
let connection = match tokio::select! {
connection = client.connect_webrtc_sideband(
session_config,
&call_id,
sideband_headers,
default_headers(),
)
.await
{
) => connection,
_ = stop_token.cancelled() => return,
} {
Ok(connection) => connection,
Err(err) => {
if realtime_active.load(Ordering::Relaxed) {
@@ -1311,6 +1342,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
handoff_state,
session_kind,
event_parser,
flush_transcript_tail_on_session_end,
transcript_tail_tx,
stop_token,
})
.await;
})
@@ -1327,6 +1361,9 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
handoff_state,
session_kind,
event_parser,
flush_transcript_tail_on_session_end,
transcript_tail_tx,
stop_token,
} = input;
let mut output_audio_state: Option<OutputAudioState> = None;
@@ -1334,6 +1371,7 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
loop {
let result = tokio::select! {
_ = stop_token.cancelled() => break,
// Text input that should be sent into realtime.
text = text_rx.recv() => {
handle_text_input(
@@ -1378,6 +1416,18 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
break;
}
}
if flush_transcript_tail_on_session_end
&& let Some(transcript_delta) =
realtime_transcript_delta(&events.take_transcript_tail().await)
{
let _ = transcript_tail_tx
.send(wrap_realtime_delegation_input(
REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION,
Some(&transcript_delta),
))
.await;
}
}
async fn handle_text_input(
+1 -1
View File
@@ -589,8 +589,8 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await {
startup_prewarm.abort().await;
}
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
let _ = sess.conversation.shutdown().await;
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
sess.services
.unified_exec_manager
.terminate_all_processes()
+7
View File
@@ -195,6 +195,11 @@ impl Session {
Arc::new(GuardianMcpElicitationReviewer::new(self))
}
pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle {
let elicitations = self.services.elicitations.clone();
codex_mcp::ElicitationLifecycle::new(move || elicitations.register())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
@@ -222,6 +227,7 @@ impl Session {
};
}
let _elicitation = self.services.elicitations.register();
let (tx_response, rx_response) = oneshot::channel();
let prev_entry = {
let mut active = self.active_turn.lock().await;
@@ -377,6 +383,7 @@ impl Session {
tool_plugin_provenance,
auth.as_ref(),
elicitation_reviewer,
Some(self.mcp_elicitation_lifecycle()),
current_runtime.manager().elicitation_router(),
)
.await;
+3 -21
View File
@@ -21,9 +21,7 @@ use crate::build_available_skills;
use crate::compact;
use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::connectors;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::AppsInstructions;
use crate::context::AvailableSkillsInstructions;
use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
@@ -1154,12 +1152,8 @@ impl Session {
state.session_configuration.codex_home().clone()
}
pub(crate) fn subscribe_out_of_band_elicitation_pause_state(&self) -> watch::Receiver<bool> {
self.out_of_band_elicitation_paused.subscribe()
}
pub(crate) fn set_out_of_band_elicitation_pause_state(&self, paused: bool) {
self.out_of_band_elicitation_paused.send_replace(paused);
pub(crate) fn subscribe_elicitation_pause_state(&self) -> watch::Receiver<bool> {
self.services.elicitations.subscribe()
}
pub(crate) fn get_tx_event(&self) -> Sender<Event> {
@@ -2846,6 +2840,7 @@ impl Session {
/// This may refresh filesystem-derived state. Normal turns should call it only from
/// `run_turn` and pass the result down; standalone request or history boundaries may capture
/// their own step.
#[tracing::instrument(name = "step_context.capture", level = "info", skip_all)]
pub(crate) async fn capture_step_context(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
@@ -3251,19 +3246,6 @@ impl Session {
.push(PersonalitySpecInstructions::new(personality_message).render());
}
}
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
let accessible_and_enabled_connectors =
connectors::list_accessible_and_enabled_connectors_from_manager(
mcp.manager(),
&turn_context.config,
)
.await;
if let Some(apps_instructions) =
AppsInstructions::from_connectors(&accessible_and_enabled_connectors)
{
developer_sections.push(apps_instructions.render());
}
}
if turn_context.config.include_skill_instructions {
let available_skills = build_available_skills(
turn_context.turn_skills.snapshot.outcome(),
+2 -5
View File
@@ -30,7 +30,6 @@ pub(crate) struct Session {
pub(crate) installation_id: String,
pub(super) tx_event: Sender<Event>,
pub(super) agent_status: watch::Sender<AgentStatus>,
pub(super) out_of_band_elicitation_paused: watch::Sender<bool>,
pub(super) state: Mutex<SessionState>,
/// Serializes rebuild/apply cycles for the running proxy; each cycle
/// rebuilds from the current SessionState while holding this lock.
@@ -1059,6 +1058,7 @@ impl Session {
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
),
elicitations: crate::elicitation::ElicitationService::new(),
shell_zsh_path: config.zsh_path.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client,
@@ -1127,15 +1127,11 @@ impl Session {
tool_search_handler_cache: Default::default(),
turn_environments: Arc::clone(&turn_environments),
};
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
watch::channel(false);
let sess = Arc::new(Session {
thread_id,
installation_id,
tx_event: tx_event.clone(),
agent_status,
out_of_band_elicitation_paused,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
features: config.features.clone(),
@@ -1221,6 +1217,7 @@ impl Session {
tool_plugin_provenance,
auth,
Some(sess.mcp_elicitation_reviewer()),
Some(sess.mcp_elicitation_lifecycle()),
codex_mcp::ElicitationRequestRouter::default(),
)
.instrument(info_span!(
+18 -2
View File
@@ -1051,6 +1051,22 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
) -> futures::future::BoxFuture<'a, ReviewDecision> {
Box::pin(async { ReviewDecision::Approved })
}
fn approval_action(
&self,
_req: &(),
ctx: &crate::tools::sandboxing::ApprovalCtx<'_>,
) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> {
Ok(crate::tools::sandboxing::ApprovalAction::Shell {
id: ctx.call_id.to_string(),
command: Vec::new(),
#[allow(deprecated)]
cwd: ctx.turn.cwd.clone(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
})
}
}
impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime {
@@ -5396,6 +5412,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
),
elicitations: crate::elicitation::ElicitationService::new(),
shell_zsh_path: None,
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client: AnalyticsEventsClient::new(
@@ -5506,7 +5523,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
tx_event,
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
features: config.features.clone(),
@@ -7522,6 +7538,7 @@ where
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
),
elicitations: crate::elicitation::ElicitationService::new(),
shell_zsh_path: None,
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client: AnalyticsEventsClient::new(
@@ -7632,7 +7649,6 @@ where
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
tx_event,
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
features: config.features.clone(),
+20 -56
View File
@@ -1928,7 +1928,6 @@ async fn try_run_sampling_request(
let mut needs_follow_up = false;
let mut last_agent_message: Option<String> = None;
let mut active_item: Option<TurnItem> = None;
let mut streamed_items = HashMap::<String, TurnItem>::new();
let mut active_tool_argument_diff_consumer: Option<(
String,
Box<dyn ToolArgumentDiffConsumer>,
@@ -1987,44 +1986,18 @@ async fn try_run_sampling_request(
match event {
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
let completes_active_tool_call = active_tool_argument_diff_consumer
.as_ref()
.is_some_and(|(active_call_id, _)| {
matches!(
&item,
ResponseItem::CustomToolCall { call_id, .. }
if call_id == active_call_id
)
});
if completes_active_tool_call
&& let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
&& let Ok(Some(event)) = consumer.finish()
{
sess.send_event(&turn_context, event).await;
}
let completed_item_id = item.id().map(str::to_owned);
let is_tool_call = matches!(
&item,
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::CustomToolCall { .. }
);
if completed_item_id.is_none() && !is_tool_call {
warn!("dropping non-tool output_item.done event without item ID");
continue;
}
let completes_active_item = match (&completed_item_id, active_item.as_ref()) {
(Some(completed_item_id), Some(active)) => completed_item_id == &active.id(),
_ => false,
let previously_active_item = active_item.take();
let previously_streamed_item = if active_item_is_streaming_to_client {
previously_active_item
} else {
None
};
let previously_streamed_item = completed_item_id
.as_deref()
.and_then(|item_id| streamed_items.remove(item_id));
if completes_active_item {
active_item = None;
active_item_is_streaming_to_client = false;
}
active_item_is_streaming_to_client = false;
if let Some(previous) = previously_streamed_item.as_ref()
&& matches!(previous, TurnItem::AgentMessage(_))
{
@@ -2179,7 +2152,6 @@ async fn try_run_sampling_request(
)
.await;
}
streamed_items.insert(turn_item.id(), turn_item.clone());
}
active_item = Some(turn_item);
active_item_is_streaming_to_client = stream_item_to_client;
@@ -2316,47 +2288,39 @@ async fn try_run_sampling_request(
}
}
ResponseEvent::ReasoningSummaryDelta {
item_id,
delta,
summary_index,
} => {
if defer_streamed_turn_items_for_contributors {
continue;
}
if streamed_items.contains_key(&item_id) {
if let Some(active) = active_item.as_ref() {
if !active_item_is_streaming_to_client {
continue;
}
let event = ReasoningContentDeltaEvent {
thread_id: sess.thread_id.to_string(),
turn_id: turn_context.sub_id.clone(),
item_id,
item_id: active.id(),
delta,
summary_index,
};
sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event))
.await;
} else {
error_or_panic(format!(
"ReasoningSummaryDelta without streamed item {item_id}"
));
error_or_panic("ReasoningSummaryDelta without active item".to_string());
}
}
ResponseEvent::ReasoningSummaryPartAdded {
item_id,
summary_index,
} => {
if defer_streamed_turn_items_for_contributors {
continue;
}
if streamed_items.contains_key(&item_id) {
ResponseEvent::ReasoningSummaryPartAdded { summary_index } => {
if let Some(active) = active_item.as_ref() {
if !active_item_is_streaming_to_client {
continue;
}
let event =
EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {
item_id,
item_id: active.id(),
summary_index,
});
sess.send_event(&turn_context, event).await;
} else {
error_or_panic(format!(
"ReasoningSummaryPartAdded without streamed item {item_id}"
));
error_or_panic("ReasoningSummaryPartAdded without active item".to_string());
}
}
ResponseEvent::ReasoningContentDelta {
+16
View File
@@ -1,12 +1,15 @@
use super::session::Session;
use super::step_context::StepContext;
use crate::connectors;
use crate::context::world_state::AgentsMdState;
use crate::context::world_state::AppsInstructionsState;
use crate::context::world_state::EnvironmentsState;
use crate::context::world_state::PluginsInstructionsState;
use crate::context::world_state::WorldState;
use codex_extension_api::WorldStateContributionInput;
impl Session {
#[tracing::instrument(name = "world_state.build", level = "info", skip_all)]
pub(crate) async fn build_world_state_for_step(
&self,
step_context: &StepContext,
@@ -36,6 +39,19 @@ impl Session {
.with_subagents(environment_subagents),
);
}
let apps_available =
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
let tools = step_context.mcp.manager().list_all_tools().await;
connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&tools),
&turn_context.config,
)
.into_iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
} else {
false
};
world_state.add_section(AppsInstructionsState::new(apps_available));
world_state.add_section(PluginsInstructionsState::new(
step_context.mcp.plugins_available(),
));
+2
View File
@@ -10,6 +10,7 @@ use crate::client::ModelClient;
use crate::config::NetworkProxyAuditMetadata;
use crate::config::StartedNetworkProxy;
use crate::current_time::TimeProvider;
use crate::elicitation::ElicitationService;
use crate::environment_selection::ThreadEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::guardian::GuardianRejection;
@@ -55,6 +56,7 @@ pub(crate) struct SessionServices {
pub(crate) mcp_projection_lock: Mutex<()>,
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
pub(crate) elicitations: ElicitationService,
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) shell_zsh_path: Option<PathBuf>,
#[cfg_attr(not(unix), allow(dead_code))]
@@ -85,6 +85,7 @@ impl CodeModeExecuteHandler {
.code_mode_service
.finish_cell_dispatch(&cell_id);
}
exec.session.services.elicitations.wait_until_clear().await;
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
.await
.map_err(FunctionCallError::RespondToModel)
@@ -120,6 +120,7 @@ impl CodeModeWaitHandler {
.code_mode_service
.finish_cell_dispatch(runtime_cell_id);
}
exec.session.services.elicitations.wait_until_clear().await;
handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at)
.await
.map(boxed_tool_output)
+116 -33
View File
@@ -27,9 +27,11 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::WarningEvent;
use codex_sandboxing::SandboxType;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Notify;
@@ -59,6 +61,7 @@ pub(crate) struct DeferredNetworkApproval {
registration_id: String,
cancellation_token: CancellationToken,
finish_outcome: Arc<OnceCell<Option<NetworkApprovalOutcome>>>,
_execution_proxy: Option<NetworkProxy>,
}
impl DeferredNetworkApproval {
@@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval {
registration_id: Option<String>,
mode: NetworkApprovalMode,
cancellation_token: CancellationToken,
execution_proxy: NetworkProxy,
}
impl ActiveNetworkApproval {
@@ -100,11 +104,16 @@ impl ActiveNetworkApproval {
self.cancellation_token.clone()
}
pub(crate) fn execution_proxy(&self) -> &NetworkProxy {
&self.execution_proxy
}
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
let ActiveNetworkApproval {
registration_id,
mode,
cancellation_token,
execution_proxy,
} = self;
match (mode, registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
@@ -112,6 +121,7 @@ impl ActiveNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
_ => None,
@@ -241,6 +251,11 @@ enum ActiveNetworkApprovalAttribution {
Ambiguous,
}
struct NetworkRequestAttribution {
owner_call: Option<Arc<ActiveNetworkApprovalCall>>,
environment_id: Option<String>,
}
#[derive(Default)]
struct NetworkApprovalCallState {
active_calls: IndexMap<String, Arc<ActiveNetworkApprovalCall>>,
@@ -305,9 +320,8 @@ impl NetworkApprovalService {
async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {
let calls = self.calls.lock().await;
// Blocked proxy requests are not attributed to a specific tool call. Only pick an owner
// when there is exactly one candidate; with concurrent calls, canceling one would be a guess.
// TODO: Carry blocked-request attribution so concurrent active calls can be handled safely.
// Shared proxy requests can still arrive without an execution ID. Only pick an owner when
// there is exactly one candidate; with concurrent calls, canceling one would be a guess.
if calls.active_calls.len() == 1 {
return calls.active_calls.values().next().cloned();
}
@@ -315,6 +329,18 @@ impl NetworkApprovalService {
None
}
async fn resolve_active_call_by_execution_id(
&self,
execution_id: &str,
) -> Option<Arc<ActiveNetworkApprovalCall>> {
self.calls
.lock()
.await
.active_calls
.get(execution_id)
.cloned()
}
async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution {
let calls = self.calls.lock().await;
match calls.active_calls.len() {
@@ -327,6 +353,54 @@ impl NetworkApprovalService {
}
}
async fn resolve_request_attribution(
&self,
request: &NetworkPolicyRequest,
) -> Option<NetworkRequestAttribution> {
if let Some(execution_id) = request.execution_id.as_deref() {
let call = self
.resolve_active_call_by_execution_id(execution_id)
.await?;
let environment_id = request
.environment_id
.clone()
.unwrap_or_else(|| call.environment_id.clone());
return (call.environment_id == environment_id).then_some(NetworkRequestAttribution {
owner_call: Some(call),
environment_id: Some(environment_id),
});
}
if let Some(environment_id) = request.environment_id.clone() {
let owner_call = match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::Single(call) => {
(call.environment_id == environment_id).then_some(call)
}
ActiveNetworkApprovalAttribution::None
| ActiveNetworkApprovalAttribution::Ambiguous => None,
};
return Some(NetworkRequestAttribution {
owner_call,
environment_id: Some(environment_id),
});
}
match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::None => Some(NetworkRequestAttribution {
owner_call: None,
environment_id: None,
}),
ActiveNetworkApprovalAttribution::Single(call) => {
let environment_id = call.environment_id.clone();
Some(NetworkRequestAttribution {
owner_call: Some(call),
environment_id: Some(environment_id),
})
}
ActiveNetworkApprovalAttribution::Ambiguous => None,
}
}
async fn get_or_create_pending_approval(
&self,
key: HostApprovalKey,
@@ -393,8 +467,12 @@ impl NetworkApprovalService {
return;
};
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))
.await;
let outcome = NetworkApprovalOutcome::DeniedByPolicy(message);
if let Some(execution_id) = blocked.execution_id.as_deref() {
self.record_call_outcome(execution_id, outcome).await;
} else {
self.record_outcome_for_single_active_call(outcome).await;
}
}
async fn active_turn_context(
@@ -431,28 +509,13 @@ impl NetworkApprovalService {
NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp,
NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,
};
let (owner_call, active_environment_id) =
if let Some(environment_id) = request.environment_id.clone() {
let owner_call = match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::Single(call) => {
(call.environment_id == environment_id).then_some(call)
}
ActiveNetworkApprovalAttribution::None
| ActiveNetworkApprovalAttribution::Ambiguous => None,
};
(owner_call, Some(environment_id))
} else {
match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::None => (None, None),
ActiveNetworkApprovalAttribution::Single(call) => {
let environment_id = call.environment_id.clone();
(Some(call), Some(environment_id))
}
ActiveNetworkApprovalAttribution::Ambiguous => {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
}
};
let Some(NetworkRequestAttribution {
owner_call,
environment_id: active_environment_id,
}) = self.resolve_request_attribution(&request).await
else {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
let turn_context = Self::active_turn_context(session.as_ref()).await;
let Some(environment_id) = active_environment_id.or_else(|| {
turn_context
@@ -788,20 +851,39 @@ pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
managed_network_active: bool,
selected_sandbox: SandboxType,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
let NetworkApprovalSpec {
network,
mode,
trigger,
command,
environment_id,
} = spec?;
if !managed_network_active || network.is_none() {
return None;
} = match spec {
Some(spec) => spec,
None => return Ok(None),
};
let Some(network) = network else {
return Ok(None);
};
if !managed_network_active {
return Ok(None);
}
let registration_id = Uuid::new_v4().to_string();
let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp {
let attribution_token = Uuid::new_v4().to_string();
network
.for_execution(&environment_id, &registration_id, attribution_token)
.map_err(|err| {
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
format!("failed to create execution-scoped network proxy: {err}"),
)))
})?
} else {
network.clone()
};
let cancellation_token = CancellationToken::new();
session
.services
@@ -816,11 +898,12 @@ pub(crate) async fn begin_network_approval(
)
.await;
Some(ActiveNetworkApproval {
Ok(Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
mode,
cancellation_token,
})
execution_proxy,
}))
}
pub(crate) async fn finish_immediate_network_approval(
@@ -285,6 +285,12 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
})
}
fn denied_blocked_request_for_execution(host: &str, execution_id: &str) -> BlockedRequest {
let mut blocked = denied_blocked_request(host);
blocked.execution_id = Some(execution_id.to_string());
blocked
}
async fn register_call_with_default_shell_trigger(
service: &NetworkApprovalService,
registration_id: &str,
@@ -429,6 +435,7 @@ async fn deferred_finish_reuses_denial_result_after_first_consumer() {
registration_id: "registration-1".to_string(),
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: None,
};
service
.record_call_outcome(
@@ -481,3 +488,27 @@ async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests(
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(service.take_call_outcome("registration-2").await, None);
}
#[tokio::test]
async fn attributed_blocked_request_targets_one_of_multiple_active_calls() {
let service = NetworkApprovalService::default();
let first = register_call_with_default_shell_trigger(&service, "registration-1").await;
let second = register_call_with_default_shell_trigger(&service, "registration-2").await;
service
.record_blocked_request(denied_blocked_request_for_execution(
"example.com",
"registration-2",
))
.await;
assert!(!first.is_cancelled());
assert!(second.is_cancelled());
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(
service.take_call_outcome("registration-2").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
))
);
}
+33 -3
View File
@@ -9,6 +9,7 @@ caching).
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::network_policy_decision::network_approval_context_from_payload;
@@ -68,13 +69,18 @@ impl ToolOrchestrator {
where
T: ToolRuntime<Rq, Out>,
{
let network_approval = begin_network_approval(
let network_approval = match begin_network_approval(
&tool_ctx.session,
&tool_ctx.turn.sub_id,
managed_network_active,
attempt.sandbox,
tool.network_approval_spec(req, tool_ctx),
)
.await;
.await
{
Ok(network_approval) => network_approval,
Err(err) => return (Err(err), None),
};
let attempt_tool_ctx = ToolCtx {
session: tool_ctx.session.clone(),
@@ -98,6 +104,9 @@ impl ToolOrchestrator {
network_denial_cancellation_token: network_approval
.as_ref()
.map(ActiveNetworkApproval::cancellation_token),
network_proxy: network_approval
.as_ref()
.map(ActiveNetworkApproval::execution_proxy),
};
let run_result = tool
.run(req, &attempt_with_network_approval, &attempt_tool_ctx)
@@ -274,6 +283,7 @@ impl ToolOrchestrator {
.permissions
.windows_sandbox_private_desktop,
network_denial_cancellation_token: None,
network_proxy: None,
};
let initial_attempt_start = Instant::now();
@@ -456,6 +466,7 @@ impl ToolOrchestrator {
.permissions
.windows_sandbox_private_desktop,
network_denial_cancellation_token: None,
network_proxy: None,
};
// Second attempt.
@@ -563,7 +574,26 @@ impl ToolOrchestrator {
} else {
ToolDecisionSource::User
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let decision = if let Some(review_id) = approval_ctx.guardian_review_id.clone() {
match tool.approval_action(req, &approval_ctx) {
Ok(action) => {
review_approval_request(
approval_ctx.session,
approval_ctx.turn,
review_id,
action,
approval_ctx.retry_reason.clone(),
)
.await
}
Err(err) => {
tracing::error!(%err, "failed to build guardian approval action");
ReviewDecision::Abort
}
}
} else {
tool.start_approval_async(req, approval_ctx).await
};
let tool_name = flat_tool_name(&tool_ctx.tool_name);
otel.tool_decision(
tool_name.as_ref(),
+11 -19
View File
@@ -4,11 +4,10 @@
//! selected turn environment filesystem for both local and remote turns, with
//! sandboxing enforced by the explicit filesystem sandbox context.
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
@@ -77,7 +76,7 @@ impl ApplyPatchRuntime {
fn build_guardian_review_request(
req: &ApplyPatchRequest,
call_id: &str,
) -> std::io::Result<GuardianApprovalRequest> {
) -> std::io::Result<ApprovalAction> {
// TODO(anp): Remove this conversion once the guardian API supports PathUri.
let cwd = req.action.cwd.to_abs_path()?;
let files = req
@@ -85,7 +84,7 @@ impl ApplyPatchRuntime {
.iter()
.map(PathUri::to_abs_path)
.collect::<std::io::Result<Vec<_>>>()?;
Ok(GuardianApprovalRequest::ApplyPatch {
Ok(ApprovalAction::ApplyPatch {
id: call_id.to_string(),
cwd,
files,
@@ -152,22 +151,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
let retry_reason = ctx.retry_reason.clone();
let approval_keys = self.approval_keys(req);
let changes = req.changes.clone();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
let action = match ApplyPatchRuntime::build_guardian_review_request(
req,
ctx.call_id,
) {
Ok(action) => action,
Err(err) => {
tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native");
return ReviewDecision::Abort;
}
};
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
}
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
@@ -201,6 +185,14 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
})
}
fn approval_action(
&self,
req: &ApplyPatchRequest,
ctx: &ApprovalCtx<'_>,
) -> std::io::Result<ApprovalAction> {
ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id)
}
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {
AskForApproval::Never => false,
@@ -80,7 +80,7 @@ async fn guardian_review_request_includes_patch_context() {
assert_eq!(
guardian_request,
GuardianApprovalRequest::ApplyPatch {
ApprovalAction::ApplyPatch {
id: "call-1".to_string(),
cwd: expected_cwd,
files: vec![path],
@@ -232,6 +232,7 @@ async fn file_system_sandbox_context_uses_active_attempt() {
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
windows_sandbox_private_desktop: true,
network_denial_cancellation_token: None,
network_proxy: None,
};
let sandbox = ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt)
@@ -300,6 +301,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
network_denial_cancellation_token: None,
network_proxy: None,
};
assert_eq!(
@@ -118,6 +118,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow::
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
network_denial_cancellation_token: None,
network_proxy: None,
};
let exec_request = attempt
+20 -22
View File
@@ -10,9 +10,7 @@ pub(crate) mod zsh_fork_backend;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
@@ -29,6 +27,7 @@ use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sand
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
@@ -145,30 +144,14 @@ impl Approvable<ShellRequest> for ShellRuntime {
let command = req.command.clone();
let cwd = req.cwd.clone();
let environment_id = Some(req.turn_environment.environment_id.clone());
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let reason = ctx
.retry_reason
.clone()
.or_else(|| req.justification.clone());
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
GuardianApprovalRequest::Shell {
id: call_id,
command,
cwd: cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
},
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "shell", keys, move || async move {
let available_decisions = None;
session
@@ -193,6 +176,21 @@ impl Approvable<ShellRequest> for ShellRuntime {
})
}
fn approval_action(
&self,
req: &ShellRequest,
ctx: &ApprovalCtx<'_>,
) -> std::io::Result<ApprovalAction> {
Ok(ApprovalAction::Shell {
id: ctx.call_id.to_string(),
command: req.command.clone(),
cwd: req.cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
})
}
fn exec_approval_requirement(&self, req: &ShellRequest) -> Option<ExecApprovalRequirement> {
Some(req.exec_approval_requirement.clone())
}
@@ -7,9 +7,7 @@ the process manager to spawn PTYs once an ExecRequest is prepared.
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
@@ -26,6 +24,7 @@ use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
@@ -179,9 +178,10 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let call_id = ctx.call_id.to_string();
let command = req.command.clone();
let environment_id = Some(req.turn_environment.environment_id.clone());
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let guardian_review_id = ctx.guardian_review_id.clone();
let reason = ctx
.retry_reason
.clone()
.or_else(|| req.justification.clone());
Box::pin(async move {
let native_cwd = match req.cwd.to_abs_path() {
Ok(c) => c,
@@ -192,24 +192,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
return ReviewDecision::Abort;
}
};
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
GuardianApprovalRequest::ExecCommand {
id: call_id,
command,
cwd: native_cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
},
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "unified_exec", keys, || async move {
let available_decisions = None;
session
@@ -234,6 +216,22 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
})
}
fn approval_action(
&self,
req: &UnifiedExecRequest,
ctx: &ApprovalCtx<'_>,
) -> std::io::Result<ApprovalAction> {
Ok(ApprovalAction::ExecCommand {
id: ctx.call_id.to_string(),
command: req.command.clone(),
cwd: req.cwd.to_abs_path()?,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
})
}
fn exec_approval_requirement(
&self,
req: &UnifiedExecRequest,
@@ -320,10 +318,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let managed_network = managed_network_for_sandbox_permissions(
let managed_network = attempt.network_proxy(managed_network_for_sandbox_permissions(
req.network.as_ref(),
launch_sandbox_permissions,
);
));
let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
let (env, managed_network_context) = match managed_network {
Some(network) => {
+14
View File
@@ -133,6 +133,8 @@ pub(crate) struct ApprovalCtx<'a> {
pub network_approval_context: Option<NetworkApprovalContext>,
}
pub(crate) type ApprovalAction = crate::guardian::GuardianApprovalRequest;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionRequestPayload {
pub tool_name: HookToolName,
@@ -368,6 +370,8 @@ pub(crate) trait Approvable<Req> {
req: &'a Req,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision>;
fn approval_action(&self, req: &Req, ctx: &ApprovalCtx<'_>) -> std::io::Result<ApprovalAction>;
}
pub(crate) trait Sandboxable {
@@ -423,9 +427,17 @@ pub(crate) struct SandboxAttempt<'a> {
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub windows_sandbox_private_desktop: bool,
pub network_denial_cancellation_token: Option<CancellationToken>,
pub(crate) network_proxy: Option<&'a NetworkProxy>,
}
impl<'a> SandboxAttempt<'a> {
pub(crate) fn network_proxy<'b>(
&'b self,
fallback: Option<&'b NetworkProxy>,
) -> Option<&'b NetworkProxy> {
fallback.map(|fallback| self.network_proxy.unwrap_or(fallback))
}
pub fn env_for(
&self,
command: SandboxCommand,
@@ -433,6 +445,7 @@ impl<'a> SandboxAttempt<'a> {
network: Option<&NetworkProxy>,
environment_id: Option<&str>,
) -> Result<crate::sandboxing::ExecRequest, CodexErr> {
let network = self.network_proxy(network);
let request = self
.manager
.transform(SandboxTransformRequest {
@@ -465,6 +478,7 @@ impl<'a> SandboxAttempt<'a> {
network: Option<&NetworkProxy>,
environment_id: Option<&str>,
) -> Result<crate::sandboxing::ExecRequest, CodexErr> {
let network = self.network_proxy(network);
let managed_network = command.managed_network.clone();
let exec_server_permissions = effective_permission_profile(
self.exec_server_permissions,
@@ -227,6 +227,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
network_denial_cancellation_token: None,
network_proxy: None,
};
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
+3 -4
View File
@@ -160,7 +160,7 @@ async fn exec_command_with_tty(
&output_closed,
&output_closed_notify,
&cancellation_token,
Some(session.subscribe_out_of_band_elicitation_pause_state()),
Some(session.subscribe_elicitation_pause_state()),
deadline,
)
.await;
@@ -515,12 +515,11 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> {
skip_if_sandbox!(Ok(()));
let (session, turn) = test_session_and_turn().await;
session.set_out_of_band_elicitation_pause_state(/*paused*/ true);
let elicitation = session.services.elicitations.register();
let paused_session = Arc::clone(&session);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
paused_session.set_out_of_band_elicitation_pause_state(/*paused*/ false);
drop(elicitation);
});
let started = tokio::time::Instant::now();
@@ -493,11 +493,7 @@ impl UnifiedExecProcessManager {
&output_closed,
&output_closed_notify,
&cancellation_token,
Some(
context
.session
.subscribe_out_of_band_elicitation_pause_state(),
),
Some(context.session.subscribe_elicitation_pause_state()),
deadline,
)
.await;
@@ -839,7 +835,7 @@ impl UnifiedExecProcessManager {
let pause_state = entry
.session
.upgrade()
.map(|session| session.subscribe_out_of_band_elicitation_pause_state());
.map(|session| session.subscribe_elicitation_pause_state());
let session = entry.session.upgrade();
Ok(PreparedProcessHandles {
+2 -11
View File
@@ -784,20 +784,11 @@ pub fn ev_reasoning_item_added(id: &str, summary: &[&str]) -> Value {
})
}
pub fn ev_reasoning_summary_part_added(item_id: &str, summary_index: i64) -> Value {
serde_json::json!({
"type": "response.reasoning_summary_part.added",
"item_id": item_id,
"summary_index": summary_index,
})
}
pub fn ev_reasoning_summary_text_delta(item_id: &str, summary_index: i64, delta: &str) -> Value {
pub fn ev_reasoning_summary_text_delta(delta: &str) -> Value {
serde_json::json!({
"type": "response.reasoning_summary_text.delta",
"item_id": item_id,
"delta": delta,
"summary_index": summary_index,
"summary_index": 0,
})
}
@@ -1198,7 +1198,6 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()>
"call_id": call_id,
"delta": "*** Add File: streamed.txt\n+hello",
}),
ev_assistant_message("unrelated-message", ""),
json!({
"type": "response.custom_tool_call_input.delta",
"call_id": call_id,
+70
View File
@@ -521,6 +521,76 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke
Ok(())
}
#[tokio::test]
async fn code_mode_exec_holds_captured_result_during_elicitation() -> Result<()> {
let server = responses::start_mock_server().await;
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_config(|config| {
let _ = config.features.enable(Feature::CodeMode);
});
let test = builder.build(&server).await?;
let first_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call("call-1", "exec", "text('captured');"),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
assert_eq!(
test.codex.increment_out_of_band_elicitation_count().await?,
1
);
assert_eq!(
test.codex.increment_out_of_band_elicitation_count().await?,
2
);
let release_elicitation = async {
tokio::time::timeout(Duration::from_secs(5), async {
while first_mock.requests().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("initial response request should arrive");
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
second_mock.requests().is_empty(),
"captured exec result should not return during an elicitation"
);
assert_eq!(
test.codex.decrement_out_of_band_elicitation_count().await?,
1
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
second_mock.requests().is_empty(),
"captured exec result should wait for every elicitation"
);
assert_eq!(
test.codex.decrement_out_of_band_elicitation_count().await?,
0
);
Ok::<(), anyhow::Error>(())
};
tokio::try_join!(test.submit_turn("run code mode"), release_elicitation)?;
second_mock.single_request();
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_only_restricts_prompt_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
+1 -1
View File
@@ -204,7 +204,7 @@ async fn codex_delegate_ignores_legacy_deltas() {
let sse_stream = sse(vec![
ev_response_created("resp-1"),
ev_reasoning_item_added("reason-1", &["initial"]),
ev_reasoning_summary_text_delta("reason-1", /*summary_index*/ 0, "think-1"),
ev_reasoning_summary_text_delta("think-1"),
ev_completed("resp-1"),
]);
@@ -209,6 +209,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -20,6 +20,7 @@ use core_test_support::responses::start_mock_server;
use core_test_support::responses::start_websocket_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
@@ -110,6 +111,121 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_denial_rejects_tool_call_with_rationale() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set sandbox policy");
});
let test = builder.build_with_auto_env(&server).await?;
let output_file = test.cwd.path().join("guardian-denied.txt");
let command = format!("printf should-not-run > {}", output_file.display());
let tool_args = json!({
"cmd": command,
"yield_time_ms": 1_000_u64,
"sandbox_permissions": SandboxPermissions::RequireEscalated,
"justification": "Exercise Guardian denial routing.",
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parent-tool-denied"),
ev_function_call(
"exec-call-denied",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_completed("resp-parent-tool-denied"),
]),
sse(vec![
ev_response_created("resp-guardian-denied"),
ev_assistant_message(
"msg-guardian-denied",
&json!({
"risk_level": "high",
"user_authorization": "low",
"outcome": "deny",
"rationale": "The requested write has unacceptable test risk.",
})
.to_string(),
),
ev_completed("resp-guardian-denied"),
]),
sse(vec![
ev_response_created("resp-parent-after-denial"),
ev_assistant_message("msg-parent-after-denial", "denied"),
ev_completed("resp-parent-after-denial"),
]),
],
)
.await;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "run a command that Guardian should deny".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
sandbox_policy: Some(sandbox_policy),
..Default::default()
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = responses.requests();
let guardian_request = requests
.iter()
.find(|request| request.body_contains_text("Exercise Guardian denial routing."))
.expect("expected Guardian review request");
assert!(guardian_request.body_contains_text(&command));
let tool_output = requests
.iter()
.find_map(|request| request.function_call_output_text("exec-call-denied"))
.expect("expected rejected tool output to be returned to the parent model");
assert!(
tool_output.contains("The requested write has unacceptable test risk."),
"Guardian rationale missing from rejected tool output: {tool_output}"
);
assert!(
!output_file.exists(),
"Guardian-denied command unexpectedly executed"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> {
skip_if_no_network!(Ok(()));
+24 -88
View File
@@ -20,20 +20,17 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_image_generation_call;
use core_test_support::responses::ev_message_item_added;
use core_test_support::responses::ev_output_text_delta;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::responses::ev_reasoning_item_added;
use core_test_support::responses::ev_reasoning_summary_part_added;
use core_test_support::responses::ev_reasoning_summary_text_delta;
use core_test_support::responses::ev_reasoning_text_delta;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_web_search_call_added_partial;
use core_test_support::responses::ev_web_search_call_done;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -1088,45 +1085,21 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interleaved_reasoning_summary_events_keep_reasoning_item_metadata() -> anyhow::Result<()> {
async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex { codex, .. } = test_codex().build(&server).await?;
mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_reasoning_item_added("reasoning-1", &[""]),
ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 0),
ev_reasoning_summary_text_delta(
"reasoning-1",
/*summary_index*/ 0,
"step one",
),
ev_reasoning_item("", &["ignored"], &[]),
ev_function_call("call-1", "unknown_tool", "{}"),
ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 1),
ev_reasoning_summary_text_delta(
"reasoning-1",
/*summary_index*/ 1,
"step two",
),
ev_reasoning_item("reasoning-1", &["step one", "step two"], &[]),
ev_completed("resp-1"),
]),
sse(vec![
ev_message_item_added("message-1", ""),
ev_output_text_delta("Done"),
ev_assistant_message("message-1", "Done"),
ev_completed("resp-2"),
]),
],
)
.await;
let stream = sse(vec![
ev_response_created("resp-1"),
ev_reasoning_item_added("reasoning-1", &[""]),
ev_reasoning_summary_text_delta("step one"),
ev_reasoning_item("reasoning-1", &["step one"], &[]),
ev_completed("resp-1"),
]);
mount_sse_once(&server, stream).await;
codex
.submit(Op::UserInput {
@@ -1141,59 +1114,22 @@ async fn interleaved_reasoning_summary_events_keep_reasoning_item_metadata() ->
})
.await?;
let mut reasoning_started = Vec::new();
let mut reasoning_completed = Vec::new();
let mut messages_started = Vec::new();
let mut messages_completed = Vec::new();
let mut summary_deltas = Vec::new();
let mut summary_sections = Vec::new();
loop {
match wait_for_event(&codex, |_| true).await {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::Reasoning(item),
..
}) => reasoning_started.push(item.id),
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::Reasoning(item),
..
}) => reasoning_completed.push(item.id),
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::AgentMessage(item),
..
}) => messages_started.push(item.id),
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::AgentMessage(item),
..
}) => messages_completed.push(item.id),
EventMsg::ReasoningContentDelta(event) => {
summary_deltas.push((event.item_id, event.delta, event.summary_index));
}
EventMsg::AgentReasoningSectionBreak(event) => {
summary_sections.push((event.item_id, event.summary_index));
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
let reasoning_item = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::Reasoning(item),
..
}) => Some(item.clone()),
_ => None,
})
.await;
assert_eq!(reasoning_started, vec!["reasoning-1"]);
assert_eq!(reasoning_completed, vec!["reasoning-1"]);
assert_eq!(messages_started, vec!["message-1"]);
assert_eq!(messages_completed, vec!["message-1"]);
assert_eq!(
summary_deltas,
vec![
("reasoning-1".to_string(), "step one".to_string(), 0),
("reasoning-1".to_string(), "step two".to_string(), 1),
]
);
assert_eq!(
summary_sections,
vec![
("reasoning-1".to_string(), 0),
("reasoning-1".to_string(), 1)
]
);
let delta_event = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ReasoningContentDelta(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(delta_event.item_id, reasoning_item.id);
assert_eq!(delta_event.delta, "step one");
Ok(())
}
@@ -1,22 +1,32 @@
use anyhow::Result;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::apps_enabled_builder;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::namespace_child_tool;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_mcp_server;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -86,6 +96,136 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let (apps_server, startup_control) =
AppsTestServer::mount_with_startup_control(&server).await?;
// Initial context is rendered twice before the first request, so keep both reads unavailable.
startup_control.fail_next_initialize_attempts(/*attempts*/ 2);
let call_id = "pause-for-apps";
let response = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
"request_user_input",
&json!({
"questions": [{
"id": "continue",
"header": "Continue",
"question": "Continue after Apps recovers?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the test."
}, {
"label": "No",
"description": "Stop the test."
}]
}]
})
.to_string(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder =
apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| {
config
.features
.enable(Feature::DeferredExecutor)
.expect("test config should allow feature update");
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let mcp_runtime = test.codex.current_mcp_runtime().await;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "use an app after it recovers".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
let initial_requests = response.requests();
assert_eq!(initial_requests.len(), 1);
let initial_request = &initial_requests[0];
assert_eq!(
initial_request
.message_input_texts("developer")
.iter()
.filter(|text| text.contains("<apps_instructions>"))
.count(),
0
);
tokio::time::timeout(Duration::from_secs(3), async {
loop {
if !mcp_runtime.manager().list_all_tools().await.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("Apps MCP should recover while the turn is paused");
test.codex
.submit(Op::UserInputAnswer {
id: request.turn_id,
response: RequestUserInputResponse {
answers: HashMap::from([(
"continue".to_string(),
RequestUserInputAnswer {
answers: vec!["Yes (Recommended)".to_string()],
},
)]),
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = response.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[1]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains("<apps_instructions>"))
.count(),
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup_failures()
-> Result<()> {
+281 -4
View File
@@ -28,6 +28,7 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -56,6 +57,181 @@ use tempfile::TempDir;
const NETWORK_TEST_HOST: &str = "codex-network-test.invalid";
const NETWORK_TEST_TARGET: &str = "http://codex-network-test.invalid:80";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg_attr(
not(target_os = "linux"),
ignore = "requires the trusted Linux proxy bridge"
)]
async fn guardian_receives_exact_triggers_for_concurrent_network_requests() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture");
skip_if_host_windows!(Ok(()));
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let test = managed_network_unified_exec_test(&server).await?;
let barrier_dir = TempDir::new_in(test.cwd.path())?;
let first_marker = barrier_dir.path().join("first");
let second_marker = barrier_dir.path().join("second");
let network_command = |marker: &PathBuf, peer_marker: &PathBuf, host: &str| {
format!(
"touch '{}' && while [ ! -e '{}' ]; do sleep 0.01; done && python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler()).open('http://{host}', timeout=10).read()\"",
marker.display(),
peer_marker.display(),
)
};
let first_command = network_command(&first_marker, &second_marker, "1.1.1.1");
let second_command = network_command(&second_marker, &first_marker, "8.8.8.8");
mount_sse_once_match(
&server,
|request: &wiremock::Request| {
!is_guardian_request(request)
&& request_body_contains(request, "run both network requests")
&& !request_body_contains(request, "exec-network-first")
},
sse(vec![
ev_response_created("resp-network-concurrent"),
ev_function_call(
"exec-network-first",
"exec_command",
&serde_json::to_string(&network_exec_args(&first_command))?,
),
ev_function_call(
"exec-network-second",
"exec_command",
&serde_json::to_string(&network_exec_args(&second_command))?,
),
ev_completed("resp-network-concurrent"),
]),
)
.await;
let first_guardian = mount_sse_once_match(
&server,
|request: &wiremock::Request| guardian_request_is_for(request, "exec-network-first"),
sse(vec![
ev_response_created("resp-network-guardian-1"),
ev_assistant_message("msg-network-guardian-1", r#"{"outcome":"deny"}"#),
ev_completed("resp-network-guardian-1"),
]),
)
.await;
let second_guardian = mount_sse_once_match(
&server,
|request: &wiremock::Request| guardian_request_is_for(request, "exec-network-second"),
sse(vec![
ev_response_created("resp-network-guardian-2"),
ev_assistant_message("msg-network-guardian-2", r#"{"outcome":"deny"}"#),
ev_completed("resp-network-guardian-2"),
]),
)
.await;
mount_sse_once_match(
&server,
|request: &wiremock::Request| {
!is_guardian_request(request)
&& request_body_contains(request, "exec-network-first")
&& request_body_contains(request, "exec-network-second")
},
sse(vec![
ev_response_created("resp-network-done"),
ev_assistant_message("msg-network-done", "done"),
ev_completed("resp-network-done"),
]),
)
.await;
submit_managed_network_turn(
&test,
"run both network requests",
vec![local(test.config.cwd.clone())],
ApprovalsReviewer::AutoReview,
AskForApproval::OnRequest,
)
.await?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let actual_triggers = loop {
let mut actual_triggers = guardian_network_triggers(&[&first_guardian, &second_guardian])?;
actual_triggers.sort_unstable();
actual_triggers.dedup();
if actual_triggers.len() == 2 {
break actual_triggers;
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!("timed out waiting for both Guardian network reviews");
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
wait_for_turn_complete(&test).await;
assert_eq!(
actual_triggers,
vec![
("exec-network-first".to_string(), first_command),
("exec-network-second".to_string(), second_command),
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg_attr(
not(target_os = "linux"),
ignore = "requires the trusted Linux proxy bridge"
)]
async fn guardian_receives_exact_trigger_for_single_network_request() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture");
skip_if_host_windows!(Ok(()));
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let test = managed_network_unified_exec_test(&server).await?;
let command = "python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://1.1.1.1', timeout=10).read().decode(errors='replace'))\"".to_string();
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-network-single"),
ev_function_call(
"exec-network-single",
"exec_command",
&serde_json::to_string(&network_exec_args(&command))?,
),
ev_completed("resp-network-single"),
]),
sse(vec![
ev_response_created("resp-network-guardian"),
ev_assistant_message("msg-network-guardian", r#"{"outcome":"deny"}"#),
ev_completed("resp-network-guardian"),
]),
sse(vec![
ev_response_created("resp-network-done"),
ev_assistant_message("msg-network-done", "done"),
ev_completed("resp-network-done"),
]),
],
)
.await;
submit_managed_network_turn(
&test,
"run one network request",
vec![local(test.config.cwd.clone())],
ApprovalsReviewer::AutoReview,
AskForApproval::OnRequest,
)
.await?;
wait_for_turn_complete(&test).await;
assert_eq!(
guardian_network_triggers(&[&responses])?,
vec![("exec-network-single".to_string(), command)]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture");
@@ -99,6 +275,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() ->
&test,
"fetch from the local environment",
environments.clone(),
ApprovalsReviewer::User,
AskForApproval::UnlessTrusted,
)
.await?;
let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?;
@@ -122,6 +300,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() ->
&test,
"fetch from the remote environment",
environments.clone(),
ApprovalsReviewer::User,
AskForApproval::UnlessTrusted,
)
.await?;
let approval = expect_network_approval(&test, REMOTE_ENVIRONMENT_ID).await?;
@@ -225,12 +405,20 @@ async fn mount_exec_network_turn(
}
fn network_fetch_args(environment_id: &str) -> Value {
let command = format!(
"python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""
);
let mut args = network_exec_args(&command);
args["environment_id"] = json!(environment_id);
args
}
fn network_exec_args(command: &str) -> Value {
json!({
"shell": "/bin/sh",
"cmd": format!("python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""),
"cmd": command,
"login": false,
"yield_time_ms": 1_000,
"environment_id": environment_id,
})
}
@@ -238,6 +426,8 @@ async fn submit_managed_network_turn(
test: &TestCodex,
prompt: &str,
environments: Vec<TurnEnvironmentSelection>,
approvals_reviewer: ApprovalsReviewer,
approval_policy: AskForApproval,
) -> Result<()> {
let permission_profile = PermissionProfile::workspace_write_with(
&[],
@@ -261,8 +451,8 @@ async fn submit_managed_network_turn(
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(turn_environment_selections),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(approvals_reviewer),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
@@ -281,6 +471,93 @@ async fn submit_managed_network_turn(
Ok(())
}
fn decoded_request_body(request: &wiremock::Request) -> Option<Vec<u8>> {
let is_zstd = request
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
if is_zstd {
zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok()
} else {
Some(request.body.clone())
}
}
fn request_body_contains(request: &wiremock::Request, text: &str) -> bool {
decoded_request_body(request)
.and_then(|body| String::from_utf8(body).ok())
.is_some_and(|body| body.contains(text))
}
fn is_guardian_request(request: &wiremock::Request) -> bool {
decoded_request_body(request)
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
.is_some_and(|body| {
body.pointer("/client_metadata/x-openai-subagent")
.and_then(Value::as_str)
== Some("guardian")
})
}
fn guardian_request_is_for(request: &wiremock::Request, call_id: &str) -> bool {
decoded_request_body(request)
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
.filter(|body| {
body.pointer("/client_metadata/x-openai-subagent")
.and_then(Value::as_str)
== Some("guardian")
})
.and_then(|body| {
body.get("input")
.and_then(Value::as_array)
.and_then(|input| {
input
.iter()
.rev()
.find(|item| item.get("role").and_then(Value::as_str) == Some("user"))
})
.cloned()
})
.is_some_and(|latest_user_message| latest_user_message.to_string().contains(call_id))
}
fn guardian_network_triggers(responses: &[&ResponseMock]) -> Result<Vec<(String, String)>> {
responses
.iter()
.flat_map(|responses| responses.requests())
.filter(|request| {
request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
})
.map(|request| {
let user_texts = request.message_input_texts("user");
let action: Value = serde_json::from_str(
user_texts
.iter()
.find(|text| text.contains("\"tool\": \"network_access\""))
.context("expected network access JSON in Guardian request")?
.trim(),
)?;
Ok((
action
.pointer("/trigger/callId")
.and_then(Value::as_str)
.context("expected exact trigger call id")?
.to_string(),
action
.pointer("/trigger/command/2")
.and_then(Value::as_str)
.context("expected exact trigger command")?
.to_string(),
))
})
.collect()
}
async fn expect_network_approval(
test: &TestCodex,
expected_environment_id: &str,
+1 -1
View File
@@ -821,7 +821,7 @@ async fn record_responses_sets_span_fields_for_response_events() {
ev_message_item_added("msg-added", "hi there"),
ev_reasoning_item_added("reasoning-1", &["summary"]),
ev_output_text_delta("delta"),
ev_reasoning_summary_text_delta("reasoning-1", /*summary_index*/ 0, "summary-delta"),
ev_reasoning_summary_text_delta("summary-delta"),
ev_reasoning_text_delta("raw-delta"),
ev_function_call("call-1", "fn", "{\"key\":\"value\"}"),
ev_assistant_message("msg-1", "agent"),
+2 -2
View File
@@ -255,8 +255,8 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
.find("## Plugins")
.expect("expected plugins section in developer message");
assert!(
apps_pos < skills_pos && skills_pos < plugins_pos,
"expected Apps -> Skills -> Plugins order: {developer_messages:?}"
skills_pos < apps_pos && apps_pos < plugins_pos,
"expected Skills -> Apps -> Plugins order: {developer_messages:?}"
);
assert!(
!developer_text.contains("`sample`: inspect sample data"),
@@ -285,6 +285,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -431,6 +432,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -526,6 +528,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -713,6 +716,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -811,6 +815,7 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -871,6 +876,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -941,6 +947,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1035,6 +1042,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join(
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1126,6 +1134,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1219,6 +1228,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1271,27 +1281,46 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_transport_close_emits_closed_event() -> Result<()> {
async fn assert_transport_close_tail_flush(
flush_transcript_tail_on_session_end: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let session_updated = vec![json!({
"type": "session.updated",
"session": { "id": "sess_1", "instructions": "backend prompt" }
})];
let server = start_websocket_server(vec![vec![], vec![session_updated]]).await;
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "ok"),
responses::ev_completed("resp-1"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_1", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "transport tail"
}),
]]])
.await;
let mut builder = test_codex();
let test = builder.build_with_websocket_server(&server).await?;
assert!(
server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.await
);
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1334,8 +1363,27 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> {
})
.await;
assert_eq!(closed.reason.as_deref(), Some("transport_closed"));
if flush_transcript_tail_on_session_end {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while response_mock.requests().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(response_mock.single_request().message_input_texts("user").iter().any(|text| text
== "<realtime_delegation>\n <input>The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.</input>\n <transcript_delta>user: transport tail</transcript_delta>\n</realtime_delegation>"));
} else {
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(response_mock.requests().is_empty());
}
server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_transport_close_tail_flush_is_opt_in() -> Result<()> {
assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ false).await?;
assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ true).await?;
Ok(())
}
@@ -1389,6 +1437,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1440,6 +1489,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1539,6 +1589,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1569,6 +1620,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1670,6 +1722,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1739,6 +1792,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1816,6 +1870,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1886,6 +1941,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1948,6 +2004,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1998,6 +2055,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2049,6 +2107,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2126,6 +2185,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2197,6 +2257,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2261,6 +2322,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2380,6 +2442,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2492,6 +2555,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2556,6 +2620,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2641,6 +2706,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2742,6 +2808,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2845,6 +2912,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2984,6 +3052,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()),
@@ -3140,6 +3209,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3254,6 +3324,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3361,6 +3432,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3426,6 +3498,115 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&api_server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "first ok"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "tail ok"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_tail", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "already handed off"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_tail",
"item_id": "item_tail",
"input_transcript": "already handed off"
}),
json!({
"type": "conversation.output_transcript.delta",
"delta": "remaining answer"
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "remaining question"
}),
],
vec![],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: true,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex.submit(Op::RealtimeConversationClose).await?;
let closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("requested"));
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while response_mock.requests().len() < 2 {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
test.codex.submit(Op::RealtimeConversationClose).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].message_input_texts("user").iter().any(|text| text
== "<realtime_delegation>\n <input>already handed off</input>\n <transcript_delta>user: already handed off</transcript_delta>\n</realtime_delegation>"));
assert!(requests[1].message_input_texts("user").iter().any(|text| text
== "<realtime_delegation>\n <input>The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.</input>\n <transcript_delta>assistant: remaining answer\nuser: remaining question</transcript_delta>\n</realtime_delegation>"));
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -3466,6 +3647,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3593,6 +3775,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3750,6 +3933,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3896,6 +4080,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -4053,6 +4238,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
+10 -1
View File
@@ -1394,7 +1394,16 @@ mod tests {
assert_eq!(session.process_id(), &process_id);
let trace = server.await.expect("server task").expect("trace context");
assert_eq!(trace, expected_trace);
let expected_traceparent = expected_trace
.traceparent
.as_deref()
.expect("parent traceparent");
let traceparent = trace.traceparent.as_deref().expect("request traceparent");
let expected_parts = expected_traceparent.split('-').collect::<Vec<_>>();
let parts = traceparent.split('-').collect::<Vec<_>>();
assert_eq!(parts[1], expected_parts[1]);
assert_ne!(parts[2], expected_parts[2]);
assert_eq!(trace.tracestate, expected_trace.tracestate);
}
async fn accept_websocket(listener: &TcpListener) -> WebSocketStream<TcpStream> {
+20 -1
View File
@@ -342,6 +342,16 @@ impl RpcClient {
drain_pending(&self.pending).await;
}
#[tracing::instrument(
name = "codex.exec_server.request",
level = "info",
skip_all,
fields(
otel.kind = "client",
otel.name = method,
method,
)
)]
pub(crate) async fn call<P, T>(&self, method: &str, params: &P) -> Result<T, RpcCallError>
where
P: Serialize,
@@ -803,6 +813,15 @@ mod tests {
.expect("RPC response");
assert_eq!(response, serde_json::json!({}));
let trace = server.await.expect("server task").expect("trace context");
assert_eq!(trace, expected_trace);
let expected_traceparent = expected_trace
.traceparent
.as_deref()
.expect("parent traceparent");
let traceparent = trace.traceparent.as_deref().expect("request traceparent");
let expected_parts = expected_traceparent.split('-').collect::<Vec<_>>();
let parts = traceparent.split('-').collect::<Vec<_>>();
assert_eq!(parts[1], expected_parts[1]);
assert_ne!(parts[2], expected_parts[2]);
assert_eq!(trace.tracestate, expected_trace.tracestate);
}
}

Some files were not shown because too many files have changed in this diff Show More