diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6ac51d403f..89d27d0ec0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3264,6 +3264,7 @@ dependencies = [ "clap", "codex-core", "codex-install-context", + "codex-network-proxy", "codex-process-hardening", "codex-protocol", "codex-sandboxing", diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index b7a958eff1..4eec8a2348 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -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::*; diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7f7ead662b..2f39f821b6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 53139c82d8..0eedef903a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 961dd14f3e..b428f25258 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json index c9bf023a63..3d9d2c1e98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 11b9712510..a2883a1293 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts index c3cef64f36..f1c5bf351e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts new file mode 100644 index 0000000000..514c1558a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts new file mode 100644 index 0000000000..fa15861b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts index e42dd8a703..46a8eee2e7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts new file mode 100644 index 0000000000..718145bf47 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c405082c4c..c335f144ec 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d8db99d4ff..c4b94d8b3f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 635d001bba..788c8f7aaf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -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>, +} + +#[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, + /// Backend-provided display title for this credit, or `null` when unavailable. + pub title: Option, + /// Backend-provided display description for this credit, or `null` when unavailable. + pub description: Option, +} + +#[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, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 4b8b2056ea..b78f390bec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams { /// them automatically. Defaults to false. #[ts(optional = nullable)] pub client_managed_handoffs: Option, + /// 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, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 7cd03930d8..367a4a64c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -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, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 43e7dbd3eb..a7986753ee 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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) diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f..d8a2bc2276 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -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 { +pub async fn start(mut args: InProcessStartArgs) -> IoResult { + 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?; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0edb57d8a2..fa6b6cd811 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -320,6 +320,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option, 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) { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 375307e804..6ae73784ff 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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(), diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 587325c246..f03351468f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 4691e22204..9592563bec 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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, }) } diff --git a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs index c8a397d3a7..919d30d770 100644 --- a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs +++ b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs @@ -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 { + 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::().ok()) .map(Duration::from_millis) .unwrap_or(request_timeout); - let response = tokio::time::timeout( - request_timeout, - client.consume_rate_limit_reset_credit(¶ms.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(¶ms.idempotency_key, credit_id) + .await + } + None => { + client + .consume_rate_limit_reset_credit(¶ms.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 { + let credits = details + .credits + .into_iter() + .map(rate_limit_reset_credit_from_backend) + .collect::, _>>()?; + Ok(RateLimitResetCreditsSummary { + available_count: details.available_count, + credits: Some(credits), + }) +} + +fn rate_limit_reset_credit_from_backend( + credit: BackendRateLimitResetCreditDetails, +) -> Result { + 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 { + DateTime::parse_from_rfc3339(timestamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}")) +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index b78c53a383..af8525873f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor { pub(super) log_db: Option, pub(super) background_tasks: TaskTracker, pub(super) skills_watcher: Arc, + pub(super) initial_config_warnings: Arc>, } /// Outcome of trying to satisfy a resume request from an already loaded thread. @@ -395,6 +396,7 @@ impl ThreadRequestProcessor { state_db: Option, log_db: Option, skills_watcher: Arc, + initial_config_warnings: Vec, ) -> 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, + initial_config_warnings: Arc>, ) -> 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(¬ification) { + 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 diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bb074e457b..3ff64cd34e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 648d60f3b5..ece66c8405 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -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(¬ification) + { + 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; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index d9d3469936..80fdb03171 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index 86ea09504c..fbb4e6e940 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -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::(&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 diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 58c31c453b..423385cea9 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -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()?; diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 26b46c4317..1d8bd90e6d 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index f717dd0014..f96653b02a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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()?; diff --git a/codex-rs/backend-client/src/client/rate_limit_resets.rs b/codex-rs/backend-client/src/client/rate_limit_resets.rs index b22a4ec91a..bed56ba648 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -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 { + 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 { + 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 { + 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 { 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 => { diff --git a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs index 89313dbe5d..f706be5da9 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs @@ -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" }, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 6cbeaf30b2..06f2cd442a 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -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; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 18ab323986..7fb4346a92 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary { pub available_count: i64, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RateLimitResetCreditsDetails { + pub credits: Vec, + 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, + pub title: Option, + pub description: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct RateLimitsWithResetCredits { pub rate_limits: Vec, diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 653d7b1608..6d332bf0c4 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -3,6 +3,7 @@ mod remote_session; mod runtime; mod service; mod session_runtime; +mod v8_init; pub(crate) type TaskFailureHandler = std::sync::Arc; @@ -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; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 0b553123f7..0d32858724 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -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, 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, diff --git a/codex-rs/code-mode/src/v8_init.rs b/codex-rs/code-mode/src/v8_init.rs new file mode 100644 index 0000000000..0d00d9fad5 --- /dev/null +++ b/codex-rs/code-mode/src/v8_init.rs @@ -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, + jit_mode: V8JitMode, +} + +static V8_INITIALIZATION: OnceLock> = 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 { + 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", + } + } +} diff --git a/codex-rs/code-mode/tests/jit.rs b/codex-rs/code-mode/tests/jit.rs new file mode 100644 index 0000000000..0f8e866c47 --- /dev/null +++ b/codex-rs/code-mode/tests/jit.rs @@ -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()) + ); +} diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 7319d13a79..254a5cb38f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -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), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 499635579f..0ae443d00a 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter { } impl RealtimeWebsocketEvents { + pub async fn take_transcript_tail(&self) -> Vec { + 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, 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!({ diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 626f460dac..17d31a02ee 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -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!({ diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c76e3c3c08..2143e3b577 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -142,6 +142,7 @@ impl McpConnectionManager { tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, + elicitation_lifecycle: Option, 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(), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 1f61ab700e..41fc19a594 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -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); + + 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; diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index e4fcf49d22..b6bb7fa791 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync { pub type ElicitationReviewerHandle = Arc; +/// Holds an owner-provided registration while an MCP elicitation is waiting for a response. +#[derive(Clone)] +pub struct ElicitationLifecycle { + register: Arc Box + Send + Sync>, +} + +impl ElicitationLifecycle { + pub fn new(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, +} + /// 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>, auto_deny: Arc>, reviewer: Option, + lifecycle: Option, } impl ElicitationRequestManager { @@ -93,6 +121,7 @@ impl ElicitationRequestManager { approval_policy: AskForApproval, permission_profile: PermissionProfile, reviewer: Option, + lifecycle: Option, 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); diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index eda7ac2399..77128d7970 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -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; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index ffb797f26e..87ea25dd9f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -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; diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index 049b500585..3d50bafe1f 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -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; diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 2331e13f77..b3d47913f6 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -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 { - AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render()) + connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + .then(|| AppsInstructions.render()) } #[cfg(test)] diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 4a562092f1..c80268e2f9 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -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, - out_of_band_elicitation_count: Mutex, + out_of_band_elicitations: Mutex, +} + +#[derive(Default)] +struct OutOfBandElicitations { + count: i64, + registration: Option, } #[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 { - 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 { + 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 { - 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 { + 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) } } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b45cc841a1..6e643f34d5 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -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 { - 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; diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 761187c794..71d836d4ea 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -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 { - connectors - .iter() - .any(|connector| connector.is_accessible && connector.is_enabled) - .then_some(Self) - } -} - impl ContextualUserFragment for AppsInstructions { fn role(&self) -> &'static str { "developer" diff --git a/codex-rs/core/src/context/world_state/apps_instructions.rs b/codex-rs/core/src/context/world_state/apps_instructions.rs new file mode 100644 index 0000000000..767e1c584e --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions.rs @@ -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> { + 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; diff --git a/codex-rs/core/src/context/world_state/apps_instructions_tests.rs b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs new file mode 100644 index 0000000000..92f6613594 --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs @@ -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 { + 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::::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::::new() + ); + assert_eq!( + render(unavailable, PreviousSectionState::Known(&true_snapshot)), + Vec::::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() + ); +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 5dc22030ba..ccdc976c4c 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -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; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index df5abd4708..679e1a69f7 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -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" )), diff --git a/codex-rs/core/src/elicitation.rs b/codex-rs/core/src/elicitation.rs new file mode 100644 index 0000000000..b29207af46 --- /dev/null +++ b/codex-rs/core/src/elicitation.rs @@ -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, +} + +struct Inner { + state: Mutex, + paused: watch::Sender, +} + +#[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 { + 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; diff --git a/codex-rs/core/src/elicitation_tests.rs b/codex-rs/core/src/elicitation_tests.rs new file mode 100644 index 0000000000..eefe1f5394 --- /dev/null +++ b/codex-rs/core/src/elicitation_tests.rs @@ -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"); +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 5e24b78d05..ef8f0fad5b 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -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] = &[ "", "", + APPS_INSTRUCTIONS_OPEN_TAG, COLLABORATION_MODE_OPEN_TAG, MULTI_AGENT_MODE_OPEN_TAG, PLUGINS_INSTRUCTIONS_OPEN_TAG, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index df8753a386..91ccbd6e63 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 6b642b1cd0..f68d32ff4e 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -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; diff --git a/codex-rs/core/src/network_policy_decision_tests.rs b/codex-rs/core/src/network_policy_decision_tests.rs index 0640041847..8888bbe9c2 100644 --- a/codex-rs/core/src/network_policy_decision_tests.rs +++ b/codex-rs/core/src/network_policy_decision_tests.rs @@ -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), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 0c81133be1..3915c8190d 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -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, + stop_token: CancellationToken, } struct RealtimeInputChannels { @@ -242,12 +248,14 @@ struct ConversationState { input_task: JoinHandle<()>, fanout_task: Option>, realtime_active: Arc, + stop_token: CancellationToken, } struct RealtimeStart { api_provider: ApiProvider, extra_headers: Option, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -260,6 +268,7 @@ struct RealtimeStart { struct RealtimeStartOutput { realtime_active: Arc, events_rx: Receiver, + transcript_tail_rx: Receiver, sdp: Option, } @@ -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::(HANDOFF_OUT_QUEUE_CAPACITY); let (events_tx, events_rx) = async_channel::bounded::(OUTPUT_EVENTS_QUEUE_CAPACITY); + let (transcript_tail_tx, transcript_tail_rx) = async_channel::bounded::(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, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -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 { - let active_transcript = handoff - .active_transcript + realtime_transcript_delta(&handoff.active_transcript) +} + +fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option { + let active_transcript = active_transcript .iter() .map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text)) .collect::>() @@ -1254,6 +1279,9 @@ struct RealtimeWebrtcSidebandInputTask { session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, realtime_active: Arc, + flush_transcript_tail_on_session_end: bool, + transcript_tail_tx: Sender, + 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 = 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( diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9733a9f66a..924750bc34 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -589,8 +589,8 @@ async fn shutdown_session_runtime(sess: &Arc) { 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() diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 4cfb0a36de..3f5e92eca7 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -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; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 39201013e5..b95a61f33a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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 { - 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 { + self.services.elicitations.subscribe() } pub(crate) fn get_tx_event(&self) -> Sender { @@ -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, turn_context: Arc, @@ -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(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index d4f559f904..72774492bb 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -30,7 +30,6 @@ pub(crate) struct Session { pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, - pub(super) out_of_band_elicitation_paused: watch::Sender, pub(super) state: Mutex, /// 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!( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c53aeacce6..a3fb2b3e04 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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 { + 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(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1031c42919..f16b525002 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1928,7 +1928,6 @@ async fn try_run_sampling_request( let mut needs_follow_up = false; let mut last_agent_message: Option = None; let mut active_item: Option = None; - let mut streamed_items = HashMap::::new(); let mut active_tool_argument_diff_consumer: Option<( String, Box, @@ -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 { diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index a78116d457..a0d93cac83 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -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(), )); diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index c4b67a629b..43f411e31b 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -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, pub(crate) unified_exec_manager: UnifiedExecProcessManager, + pub(crate) elicitations: ElicitationService, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) shell_zsh_path: Option, #[cfg_attr(not(unix), allow(dead_code))] diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 2e46c1bd6b..8374ae1ca4 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -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) diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 953391a573..c3c867f3ef 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -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) diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 2b63164b4d..747ae80a6f 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -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>>, + _execution_proxy: Option, } impl DeferredNetworkApproval { @@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval { registration_id: Option, 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 { 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>, + environment_id: Option, +} + #[derive(Default)] struct NetworkApprovalCallState { active_calls: IndexMap>, @@ -305,9 +320,8 @@ impl NetworkApprovalService { async fn resolve_single_active_call(&self) -> Option> { 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> { + 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 { + 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, -) -> Option { +) -> Result, 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, ®istration_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( diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 7a9ba86cef..4fdeb50ed7 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -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() + )) + ); +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index aaa0dfe9d1..f82ad5bb70 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -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, { - 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(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 840a0dd01e..b839b05c44 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -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 { + ) -> std::io::Result { // 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::>>()?; - Ok(GuardianApprovalRequest::ApplyPatch { + Ok(ApprovalAction::ApplyPatch { id: call_id.to_string(), cwd, files, @@ -152,22 +151,7 @@ impl Approvable 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 for ApplyPatchRuntime { }) } + fn approval_action( + &self, + req: &ApplyPatchRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id) + } + fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool { match policy { AskForApproval::Never => false, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index f1c6f43aa3..c70002e62f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -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!( diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 5c2614c9c0..f6ec38f6c2 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -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 diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0d386103db..3ff50f3f51 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -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 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 for ShellRuntime { }) } + fn approval_action( + &self, + req: &ShellRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + 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 { Some(req.exec_approval_requirement.clone()) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5df42cc979..a1b9b33c6d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -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 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 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 for UnifiedExecRuntime<'_> { }) } + fn approval_action( + &self, + req: &UnifiedExecRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + 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 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) => { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 8e9e274f24..571c5ce6df 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -133,6 +133,8 @@ pub(crate) struct ApprovalCtx<'a> { pub network_approval_context: Option, } +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: &'a Req, ctx: ApprovalCtx<'a>, ) -> BoxFuture<'a, ReviewDecision>; + + fn approval_action(&self, req: &Req, ctx: &ApprovalCtx<'_>) -> std::io::Result; } 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, + 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 { + 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 { + let network = self.network_proxy(network); let managed_network = command.managed_network.clone(); let exec_server_permissions = effective_permission_profile( self.exec_server_permissions, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 28b9de9ce1..e52186f772 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -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], diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 54ee788e3b..81403c8e52 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -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(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index ef22918cd2..a895708f79 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -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 { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 45d4cb3d99..9569df1c90 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -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, }) } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 92f583c470..d8142723b4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -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, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 26c041d8f7..9293b506ef 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -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(())); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 7dab8a0688..f5db9856df 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -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"), ]); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d0e69d6569..e22f70b052 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -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, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index c1da262033..383954b816 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -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(())); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index b29696fdad..515d496b8b 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -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(()) } diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 32ecf7cf2a..51ab7842ee 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -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("")) + .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("")) + .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<()> { diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 6b96c0d635..437155f325 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -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, + 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> { + 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::(&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::(&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> { + 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, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 060a9079a9..4a2b2993ad 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -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"), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b3420feabe..aaf65baad0 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -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"), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 1a95223bff..f4262e8e40 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -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 + == "\n 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.\n user: transport tail\n")); + } 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 + == "\n already handed off\n user: already handed off\n")); + assert!(requests[1].message_input_texts("user").iter().any(|text| text + == "\n 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.\n assistant: remaining answer\nuser: remaining question\n")); + + 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, diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 82412a58e6..5590fd3b5e 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -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::>(); + let parts = traceparent.split('-').collect::>(); + 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 { diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 46a0bd14e8..e762fe8b66 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -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(&self, method: &str, params: &P) -> Result 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::>(); + let parts = traceparent.split('-').collect::>(); + assert_eq!(parts[1], expected_parts[1]); + assert_ne!(parts[2], expected_parts[2]); + assert_eq!(trace.tracestate, expected_trace.tracestate); } } diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index 2f1a84c46b..2094f34496 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -66,6 +66,12 @@ impl SkillsThreadState { /// Environment availability only controls whether the root is projected into the current /// step; it never invalidates the cache. There is intentionally no filesystem watcher or /// content-based invalidation because selected environment roots are treated as stable. + #[tracing::instrument( + name = "skills.executor.catalog_snapshot", + level = "info", + skip_all, + fields(root_count = query.executor_roots.len()) + )] pub(crate) async fn executor_catalog_snapshot( &self, providers: &SkillProviders, @@ -157,6 +163,7 @@ impl SkillsThreadState { next_cache } + #[tracing::instrument(name = "skills.executor.catalog_root", level = "info", skip_all)] async fn executor_root_catalog( &self, providers: &SkillProviders, diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index fc7937536c..0f7c68a152 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-install-context = { workspace = true } +codex-network-proxy = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index f171d91745..707cbaafbe 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -1,3 +1,5 @@ +use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +use codex_network_proxy::write_attribution_frame; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; @@ -71,8 +73,12 @@ struct ProxyRoutePlan { } pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { - let env: HashMap = std::env::vars().collect(); - let plan = plan_proxy_routes(&env); + let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect()); + // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or + // executes the user command. + unsafe { + std::env::remove_var(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + } if plan.routes.is_empty() { let message = if plan.has_proxy_config { @@ -100,7 +106,11 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len()); for (endpoint, socket_path) in &socket_by_endpoint { - host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?); + host_bridge_pids.push(spawn_host_bridge( + *endpoint, + socket_path, + attribution_token.as_deref(), + )?); } spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?; @@ -121,6 +131,14 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other) } +fn extract_attribution_token_and_plan( + mut env: HashMap, +) -> (Option, ProxyRoutePlan) { + let attribution_token = env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + let plan = plan_proxy_routes(&env); + (attribution_token, plan) +} + pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?; @@ -438,7 +456,11 @@ fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> { } } -fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result { +fn spawn_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + attribution_token: Option<&str>, +) -> io::Result { let (read_fd, write_fd) = create_ready_pipe()?; let pid = unsafe { libc::fork() }; if pid < 0 { @@ -452,7 +474,7 @@ fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result io::Result io::Result<()> { +fn run_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + ready_fd: libc::c_int, + attribution_token: Option<&str>, +) -> io::Result<()> { harden_bridge_process()?; if uds_path.exists() { std::fs::remove_file(uds_path)?; @@ -482,13 +509,22 @@ fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) ready_file.write_all(&[HOST_BRIDGE_READY])?; drop(ready_file); + let attribution_token = attribution_token.map(str::to_owned); loop { let (unix_stream, _) = listener.accept()?; + let attribution_token = attribution_token.clone(); std::thread::spawn(move || { - let tcp_stream = match TcpStream::connect(endpoint) { + let mut tcp_stream = match TcpStream::connect(endpoint) { Ok(stream) => stream, Err(_) => return, }; + if let Some(attribution_token) = attribution_token + && write_attribution_frame(&mut tcp_stream, &attribution_token).is_err() + { + // The shared ingress must reject unauthenticated connections; do not forward + // application bytes if this bridge cannot prove the exec attribution first. + return; + } let _ = proxy_bidirectional(tcp_stream, unix_stream); }); } @@ -673,12 +709,14 @@ fn close_fd(fd: libc::c_int) -> io::Result<()> { #[cfg(test)] mod tests { + use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use super::PROXY_SOCKET_DIR_PREFIX; use super::ProxyRouteEntry; use super::ProxyRouteSpec; use super::cleanup_proxy_socket_dir; use super::cleanup_stale_proxy_socket_dirs_in; use super::default_proxy_port; + use super::extract_attribution_token_and_plan; use super::is_proxy_env_key; use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; @@ -743,6 +781,35 @@ mod tests { ); } + #[test] + fn attribution_token_is_extracted_before_proxy_route_planning() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + "exec-token".to_string(), + ); + + let (attribution_token, plan) = extract_attribution_token_and_plan(env); + + assert_eq!(attribution_token.as_deref(), Some("exec-token")); + assert_eq!( + plan, + super::ProxyRoutePlan { + routes: vec![super::PlannedProxyRoute { + env_key: "HTTP_PROXY".to_string(), + endpoint: "127.0.0.1:43128" + .parse::() + .expect("valid socket"), + }], + has_proxy_config: true, + } + ); + } + #[test] fn rewrites_proxy_url_to_local_loopback_port() { let rewritten = diff --git a/codex-rs/network-proxy/src/attribution.rs b/codex-rs/network-proxy/src/attribution.rs new file mode 100644 index 0000000000..f526460fd6 --- /dev/null +++ b/codex-rs/network-proxy/src/attribution.rs @@ -0,0 +1,143 @@ +use crate::state::NetworkProxyState; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsMut; +use rama_tcp::TcpStream; +use std::io; +use std::io::Write; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; + +/// Internal handoff from the trusted Linux proxy bridge. +#[doc(hidden)] +pub const PROXY_ATTRIBUTION_TOKEN_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ATTRIBUTION"; + +const ATTRIBUTION_FRAME_MAGIC: &[u8; 8] = b"\0CDXPXY1"; +const MAX_ATTRIBUTION_TOKEN_LEN: usize = 128; +const ATTRIBUTION_FRAME_TIMEOUT: Duration = Duration::from_secs(3); + +pub(crate) struct BindConnectionAttribution { + inner: S, + state: Arc, + environment_id: Option, +} + +impl BindConnectionAttribution { + pub(crate) fn new( + inner: S, + state: Arc, + environment_id: Option, + ) -> Self { + Self { + inner, + state, + environment_id, + } + } +} + +impl Service for BindConnectionAttribution +where + S: Service, + S::Error: Into, +{ + type Output = S::Output; + type Error = BoxError; + + async fn serve(&self, mut stream: TcpStream) -> Result { + let state = match read_attribution_token(&mut stream).await? { + Some(token) => self.state.for_execution_token(&token).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "unknown network proxy attribution token", + ) + })?, + None => self.state.as_ref().clone(), + }; + if let Some(expected_environment_id) = self.environment_id.as_deref() + && state + .environment_id() + .is_some_and(|actual| actual != expected_environment_id) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "network proxy attribution environment mismatch", + ) + .into()); + } + stream.extensions_mut().insert(Arc::new(state)); + self.inner.serve(stream).await.map_err(Into::into) + } +} + +async fn read_attribution_token(stream: &mut TcpStream) -> Result, BoxError> { + let mut marker = [0_u8; 1]; + let read = stream.stream.peek(&mut marker).await?; + if read == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "empty proxy connection").into()); + } + if marker[0] != ATTRIBUTION_FRAME_MAGIC[0] { + return Ok(None); + } + + let token = tokio::time::timeout(ATTRIBUTION_FRAME_TIMEOUT, async { + let mut magic = [0_u8; ATTRIBUTION_FRAME_MAGIC.len()]; + stream.read_exact(&mut magic).await?; + if &magic != ATTRIBUTION_FRAME_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution frame", + )); + } + + let token_len = stream.read_u16().await? as usize; + if token_len == 0 || token_len > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution token length", + )); + } + let mut token = vec![0_u8; token_len]; + stream.read_exact(&mut token).await?; + String::from_utf8(token).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "network proxy attribution token is not UTF-8", + ) + }) + }) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "network proxy attribution frame timed out", + ) + })??; + + Ok(Some(token)) +} + +/// Writes the trusted bridge preface consumed by the shared proxy ingress. +#[doc(hidden)] +pub fn write_attribution_frame(writer: &mut impl Write, token: &str) -> io::Result<()> { + if token.is_empty() || token.len() > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid network proxy attribution token length", + )); + } + let token_len = u16::try_from(token.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "network proxy attribution token is too long", + ) + })?; + writer.write_all(ATTRIBUTION_FRAME_MAGIC)?; + writer.write_all(&token_len.to_be_bytes())?; + writer.write_all(token.as_bytes()) +} + +#[cfg(test)] +#[path = "attribution_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/attribution_tests.rs b/codex-rs/network-proxy/src/attribution_tests.rs new file mode 100644 index 0000000000..e28110263c --- /dev/null +++ b/codex-rs/network-proxy/src/attribution_tests.rs @@ -0,0 +1,63 @@ +use super::BindConnectionAttribution; +use super::write_attribution_frame; +use crate::config::NetworkProxySettings; +use crate::runtime::network_proxy_state_for_policy; +use crate::state::NetworkProxyState; +use pretty_assertions::assert_eq; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::service_fn; +use rama_tcp::TcpStream as RamaTcpStream; +use std::io; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; + +#[test] +fn attribution_frame_has_bounded_binary_prefix() -> io::Result<()> { + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + + assert_eq!(&frame[..8], b"\0CDXPXY1"); + assert_eq!(u16::from_be_bytes([frame[8], frame[9]]), 7); + assert_eq!(&frame[10..], b"token-1"); + Ok(()) +} + +#[tokio::test] +async fn framed_connection_receives_registered_execution_state() -> Result<(), BoxError> { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + state.register_execution("token-1", "local", "execution-1"); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let client = tokio::spawn(async move { + let mut stream = TcpStream::connect(addr).await?; + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + stream.write_all(&frame).await + }); + + let (stream, _) = listener.accept().await?; + let service = BindConnectionAttribution::new( + service_fn(|stream: RamaTcpStream| async move { + let state = stream.extensions().get::>().cloned(); + Ok::<_, io::Error>(state) + }), + state, + Some("local".to_string()), + ); + let actual = service + .serve(RamaTcpStream::new(stream)) + .await? + .expect("connection state"); + client.await??; + + assert_eq!(actual.environment_id(), Some("local")); + assert_eq!(actual.execution_id().as_deref(), Some("execution-1")); + Ok(()) +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 180ef41c51..59cad9c20a 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -39,7 +40,6 @@ use rama_core::error::ErrorExt as _; use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_core::stream::Stream; use rama_http::Body; @@ -159,7 +159,11 @@ async fn run_http_proxy_with_listener( info!("HTTP proxy listening on {addr}"); listener - .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .serve(BindConnectionAttribution::new( + http_service, + state, + environment_id, + )) .await; Ok(()) } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index bfbb92f8af..46ec4e9a1b 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,5 +1,6 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +mod attribution; mod certs; mod config; mod connect_policy; @@ -18,6 +19,8 @@ mod socks5; mod state; mod upstream; +pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +pub use attribution::write_attribution_frame; pub use certs::CUSTOM_CA_ENV_KEYS; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index e5792a07c0..956aa6f682 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -84,6 +84,7 @@ pub struct NetworkPolicyRequest { pub method: Option, pub command: Option, pub exec_policy_hint: Option, + pub execution_id: Option, } pub struct NetworkPolicyRequestArgs { @@ -118,6 +119,7 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, + execution_id: None, } } } @@ -199,6 +201,7 @@ fn emit_non_domain_policy_decision_audit_event( args: BlockDecisionAuditEventArgs<'_>, decision: &'static str, ) { + let execution_id = state.execution_id(); emit_policy_audit_event( state, PolicyAuditEventArgs { @@ -211,6 +214,7 @@ fn emit_non_domain_policy_decision_audit_event( server_port: args.server_port, method: args.method, client_addr: args.client_addr, + execution_id: execution_id.as_deref(), policy_override: false, }, ); @@ -226,6 +230,7 @@ struct PolicyAuditEventArgs<'a> { server_port: u16, method: Option<&'a str>, client_addr: Option<&'a str>, + execution_id: Option<&'a str>, policy_override: bool, } @@ -254,6 +259,7 @@ fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs server.port = args.server_port, http.request.method = args.method.unwrap_or(DEFAULT_METHOD), client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS), + execution.id = args.execution_id, network.policy.override = args.policy_override, ); } @@ -295,12 +301,20 @@ pub(crate) async fn evaluate_host_policy( decider: Option<&Arc>, request: &NetworkPolicyRequest, ) -> Result { + let execution_id = state.execution_id(); let host_decision = state.host_blocked(&request.host, request.port).await?; let (decision, policy_override) = match host_decision { HostBlockDecision::Allowed => (NetworkDecision::Allow, false), HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { if let Some(decider) = decider { - let decider_decision = map_decider_decision(decider.decide(request.clone()).await); + let mut request = request.clone(); + if request.environment_id.is_none() + && let Some(environment_id) = state.environment_id() + { + request.environment_id = Some(environment_id.to_string()); + } + request.execution_id = execution_id.clone(); + let decider_decision = map_decider_decision(decider.decide(request).await); let policy_override = matches!(decider_decision, NetworkDecision::Allow); (decider_decision, policy_override) } else { @@ -355,6 +369,7 @@ pub(crate) async fn evaluate_host_policy( server_port: request.port, method: request.method.as_deref(), client_addr: request.client_addr.as_deref(), + execution_id: execution_id.as_deref(), policy_override, }, ); @@ -679,6 +694,46 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn evaluate_host_policy_emits_execution_id_for_baseline_allow() { + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network + }); + state.register_execution("token-baseline-allow", "local", "execution-baseline-allow"); + let state = state + .for_execution_token("token-baseline-allow") + .expect("expected registered execution"); + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + protocol: NetworkProtocol::Http, + host: "example.com".to_string(), + port: 80, + environment_id: None, + client_addr: None, + method: None, + command: None, + exec_policy_hint: None, + }); + + let (decision, events) = capture_events(|| async { + evaluate_host_policy(&state, /*decider*/ None, &request) + .await + .unwrap() + }) + .await; + assert_eq!(decision, NetworkDecision::Allow); + + let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME) + .expect("expected policy decision audit event"); + assert_eq!(event.field("network.policy.decision"), Some("allow")); + assert_eq!( + event.field("execution.id"), + Some("execution-baseline-allow") + ); + assert_ne!(event.field("execution.id"), Some("token-baseline-allow")); + } + #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() { let state = network_proxy_state_for_policy({ @@ -687,6 +742,10 @@ mod tests { network.set_denied_domains(vec!["blocked.com".to_string()]); network }); + state.register_execution("token-baseline-deny", "local", "execution-baseline-deny"); + let state = state + .for_execution_token("token-baseline-deny") + .expect("expected registered execution"); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), @@ -724,6 +783,8 @@ mod tests { assert_eq!(event.field("network.policy.override"), Some("false")); assert_eq!(event.field("http.request.method"), Some("GET")); assert_eq!(event.field("client.address"), Some("127.0.0.1:1234")); + assert_eq!(event.field("execution.id"), Some("execution-baseline-deny")); + assert_ne!(event.field("execution.id"), Some("token-baseline-deny")); } #[tokio::test(flavor = "current_thread")] diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3bfa348b6..8b26a124fd 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1,3 +1,6 @@ +mod execution_scope; + +use crate::attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use crate::config; use crate::credential_broker::BROKERED_CREDENTIALS_ENV_KEY; use crate::credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; @@ -25,6 +28,8 @@ use std::sync::RwLock; use tokio::task::JoinHandle; use tracing::warn; +use self::execution_scope::ExecutionScope; + #[derive(Debug, Clone, Parser)] #[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] pub struct Args {} @@ -251,6 +256,7 @@ impl NetworkProxyBuilder { reserved_listeners, policy_decider: self.policy_decider, environment_proxies: Arc::new(Mutex::new(HashMap::new())), + execution_scope: None, }) } } @@ -412,6 +418,7 @@ pub struct NetworkProxy { reserved_listeners: Option>, policy_decider: Option>, environment_proxies: Arc>>, + execution_scope: Option>, } impl std::fmt::Debug for NetworkProxy { @@ -467,6 +474,7 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ CREDENTIAL_BROKER_ACTIVE_ENV_KEY, BROKERED_CREDENTIALS_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, + PROXY_ATTRIBUTION_TOKEN_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, "HTTP_PROXY", @@ -740,6 +748,12 @@ impl NetworkProxy { runtime_settings.mitm_ca_trust_bundle.as_ref(), ); self.state.virtualize_child_credentials(&mut env); + if let Some(execution_scope) = self.execution_scope.as_ref() { + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + execution_scope.attribution_token.clone(), + ); + } let mut loopback_ports = [ Some(addrs.http_addr), self.socks_enabled.then_some(addrs.socks_addr), @@ -821,6 +835,13 @@ impl NetworkProxy { } fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + if let Some(execution_scope) = self.execution_scope.as_ref() { + anyhow::ensure!( + execution_scope.environment_id == environment_id, + "execution-scoped network proxy belongs to environment `{}`, not `{environment_id}`", + execution_scope.environment_id + ); + } if self.base_environment_id.as_deref() == Some(environment_id) { return Ok(EnvironmentProxyAddrs { http_addr: self.http_addr, @@ -945,6 +966,10 @@ impl NetworkProxy { } pub async fn run(&self) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "execution-scoped network proxy is already running" + ); let current_cfg = self.state.current_cfg().await?; if !current_cfg.network.enabled { warn!("network.enabled is false; skipping proxy listeners"); @@ -1281,6 +1306,15 @@ mod tests { let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]); let local = proxy.prepare_for_optional_environment(base_env.clone(), Some("local"))?; let remote = proxy.prepare_for_optional_environment(HashMap::new(), Some("remote"))?; + let remote_execution = + proxy.for_execution("remote", "execution-1", "attribution-token".to_string())?; + let err = remote_execution + .prepare_for_optional_environment(HashMap::new(), Some("local")) + .expect_err("remote execution must not reuse the local environment listener"); + assert!( + err.to_string() + .contains("execution-scoped network proxy belongs to environment `remote`") + ); assert_eq!( local.env.get("PRESERVED").map(String::as_str), diff --git a/codex-rs/network-proxy/src/proxy/execution_scope.rs b/codex-rs/network-proxy/src/proxy/execution_scope.rs new file mode 100644 index 0000000000..37068625d4 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy/execution_scope.rs @@ -0,0 +1,38 @@ +use super::*; + +pub(super) struct ExecutionScope { + pub(super) environment_id: String, + pub(super) attribution_token: String, + state: Arc, +} + +impl Drop for ExecutionScope { + fn drop(&mut self) { + self.state.unregister_execution(&self.attribution_token); + } +} + +impl NetworkProxy { + /// Returns a proxy that attributes trusted bridge connections to one execution. + pub fn for_execution( + &self, + environment_id: &str, + execution_id: &str, + attribution_token: String, + ) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "cannot scope an execution-scoped network proxy" + ); + self.state + .register_execution(&attribution_token, environment_id, execution_id); + + let mut proxy = self.clone(); + proxy.execution_scope = Some(Arc::new(ExecutionScope { + environment_id: environment_id.to_string(), + attribution_token, + state: Arc::clone(&self.state), + })); + Ok(proxy) + } +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b486ab057a..a51f1d8f16 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -33,6 +33,7 @@ use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use time::OffsetDateTime; use tokio::net::lookup_host; @@ -96,6 +97,8 @@ pub struct BlockedRequest { pub method: Option, pub mode: Option, pub protocol: String, + #[serde(skip)] + pub execution_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -137,6 +140,7 @@ impl BlockedRequest { method, mode, protocol, + execution_id: None, decision, source, port, @@ -211,6 +215,9 @@ pub struct NetworkProxyState { blocked_request_observer: Arc>>>, credential_broker: CredentialBroker, audit_metadata: NetworkProxyAuditMetadata, + execution_attributions: Arc>>, + environment_id: Option>, + execution_id: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -220,6 +227,12 @@ pub(crate) enum HostMitmRequirement { Always, } +#[derive(Clone)] +struct ExecutionAttribution { + environment_id: String, + execution_id: String, +} + impl std::fmt::Debug for NetworkProxyState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy @@ -236,6 +249,9 @@ impl Clone for NetworkProxyState { blocked_request_observer: self.blocked_request_observer.clone(), credential_broker: self.credential_broker.clone(), audit_metadata: self.audit_metadata.clone(), + execution_attributions: self.execution_attributions.clone(), + environment_id: self.environment_id.clone(), + execution_id: self.execution_id.clone(), } } } @@ -287,9 +303,59 @@ impl NetworkProxyState { reloader, blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), audit_metadata, + execution_attributions: Arc::new(Mutex::new(HashMap::new())), + environment_id: None, + execution_id: None, } } + pub(crate) fn register_execution( + &self, + attribution_token: &str, + environment_id: &str, + execution_id: &str, + ) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + attribution_token.to_string(), + ExecutionAttribution { + environment_id: environment_id.to_string(), + execution_id: execution_id.to_string(), + }, + ); + } + + pub(crate) fn unregister_execution(&self, attribution_token: &str) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(attribution_token); + } + + pub(crate) fn for_execution_token(&self, token: &str) -> Option { + let attribution = self + .execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(token)? + .clone(); + Some(Self { + environment_id: Some(attribution.environment_id.into()), + execution_id: Some(attribution.execution_id.into()), + ..self.clone() + }) + } + + pub(crate) fn environment_id(&self) -> Option<&str> { + self.environment_id.as_deref() + } + + pub(crate) fn execution_id(&self) -> Option { + self.execution_id.as_deref().map(str::to_string) + } + pub async fn set_blocked_request_observer( &self, blocked_request_observer: Option>, @@ -460,8 +526,9 @@ impl NetworkProxyState { } } - pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; + entry.execution_id = self.execution_id(); let blocked_for_observer = entry.clone(); let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); @@ -1306,6 +1373,7 @@ mod tests { method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), + execution_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 9561877043..d1c193198f 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -23,13 +24,11 @@ use crate::state::BlockedRequestArgs; use crate::state::NetworkProxyState; use anyhow::Context as _; use anyhow::Result; -use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; use rama_core::extensions::Extensions; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_net::address::HostWithPort; use rama_net::client::EstablishedClientConnection; @@ -165,11 +164,15 @@ async fn run_socks5_with_listener( })); let socks_acceptor = base.with_udp_associator(udp_relay); listener - .serve(AddInputExtensionLayer::new(state).into_layer(socks_acceptor)) + .serve(BindConnectionAttribution::new( + socks_acceptor, + state, + environment_id, + )) .await; } else { listener - .serve(AddInputExtensionLayer::new(state).into_layer(base)) + .serve(BindConnectionAttribution::new(base, state, environment_id)) .await; } Ok(()) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index c319573446..820c3b774b 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -10,26 +10,12 @@ use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; -use crate::protocol::AgentMessageEvent; -use crate::protocol::AgentReasoningEvent; -use crate::protocol::AgentReasoningRawContentEvent; use crate::protocol::AgentStatus; use crate::protocol::CollabAgentRef; -use crate::protocol::ContextCompactedEvent; -use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; use crate::protocol::FileChange; -use crate::protocol::ImageGenerationEndEvent; -use crate::protocol::McpInvocation; -use crate::protocol::McpToolCallBeginEvent; -use crate::protocol::McpToolCallEndEvent; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; use crate::protocol::PatchApplyStatus; use crate::protocol::SubAgentActivityKind; -use crate::protocol::UserMessageEvent; -use crate::protocol::ViewImageToolCallEvent; -use crate::protocol::WebSearchEndEvent; use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; @@ -387,10 +373,6 @@ impl ContextCompactionItem { id: uuid::Uuid::new_v4().to_string(), } } - - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::ContextCompacted(ContextCompactedEvent {}) - } } impl Default for ContextCompactionItem { @@ -408,20 +390,6 @@ impl UserMessageItem { } } - pub fn as_legacy_event(&self) -> EventMsg { - // Legacy user-message events flatten only text inputs into `message` and - // rebase text element ranges onto that concatenated text. - EventMsg::UserMessage(UserMessageEvent { - client_id: self.client_id.clone(), - message: self.message(), - images: Some(self.image_urls()), - image_details: self.image_details(), - local_images: self.local_image_paths(), - local_image_details: self.local_image_details(), - text_elements: self.text_elements(), - }) - } - pub fn message(&self) -> String { self.content .iter() @@ -609,134 +577,6 @@ impl AgentMessageItem { memory_citation: None, } } - - pub fn as_legacy_events(&self) -> Vec { - self.content - .iter() - .map(|c| match c { - AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent { - message: text.clone(), - phase: self.phase.clone(), - memory_citation: self.memory_citation.clone(), - }), - }) - .collect() - } -} - -impl ReasoningItem { - pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - let mut events = Vec::new(); - for summary in &self.summary_text { - events.push(EventMsg::AgentReasoning(AgentReasoningEvent { - text: summary.clone(), - })); - } - - if show_raw_agent_reasoning { - for entry in &self.raw_content { - events.push(EventMsg::AgentReasoningRawContent( - AgentReasoningRawContentEvent { - text: entry.clone(), - }, - )); - } - } - - events - } -} - -impl WebSearchItem { - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: self.id.clone(), - query: self.query.clone(), - action: self.action.clone(), - }) - } -} - -impl ImageGenerationItem { - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { - call_id: self.id.clone(), - status: self.status.clone(), - revised_prompt: self.revised_prompt.clone(), - result: self.result.clone(), - saved_path: self.saved_path.clone(), - }) - } -} - -impl FileChangeItem { - pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg { - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: self.id.clone(), - turn_id, - auto_approved: self.auto_approved.unwrap_or(false), - changes: self.changes.clone(), - }) - } - - pub fn as_legacy_end_event(&self, turn_id: String) -> Option { - let status = self.status.clone()?; - Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: self.id.clone(), - turn_id, - stdout: self.stdout.clone().unwrap_or_default(), - stderr: self.stderr.clone().unwrap_or_default(), - success: status == PatchApplyStatus::Completed, - changes: self.changes.clone(), - status, - })) - } -} - -impl McpToolCallItem { - pub fn as_legacy_begin_event(&self) -> EventMsg { - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: self.id.clone(), - invocation: McpInvocation { - server: self.server.clone(), - tool: self.tool.clone(), - arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), - }, - connector_id: self.connector_id.clone(), - mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), - link_id: self.link_id.clone(), - app_name: self.app_name.clone(), - template_id: self.template_id.clone(), - action_name: self.action_name.clone(), - plugin_id: self.plugin_id.clone(), - }) - } - - pub fn as_legacy_end_event(&self) -> Option { - let result = match (&self.result, &self.error) { - (Some(result), _) => Ok(result.clone()), - (None, Some(error)) => Err(error.message.clone()), - (None, None) => return None, - }; - - Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: self.id.clone(), - invocation: McpInvocation { - server: self.server.clone(), - tool: self.tool.clone(), - arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), - }, - mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), - connector_id: self.connector_id.clone(), - link_id: self.link_id.clone(), - app_name: self.app_name.clone(), - template_id: self.template_id.clone(), - action_name: self.action_name.clone(), - plugin_id: self.plugin_id.clone(), - duration: self.duration?, - result, - })) - } } impl TurnItem { @@ -760,35 +600,6 @@ impl TurnItem { TurnItem::ContextCompaction(item) => item.id.clone(), } } - - pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match self { - TurnItem::UserMessage(item) => vec![item.as_legacy_event()], - TurnItem::HookPrompt(_) => Vec::new(), - TurnItem::AgentMessage(item) => item.as_legacy_events(), - TurnItem::Plan(_) => Vec::new(), - TurnItem::CommandExecution(_) - | TurnItem::DynamicToolCall(_) - | TurnItem::CollabAgentToolCall(_) => Vec::new(), - TurnItem::SubAgentActivity(_) => Vec::new(), - TurnItem::WebSearch(item) => vec![item.as_legacy_event()], - TurnItem::ImageView(item) => { - vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: item.id.clone(), - path: item.path.clone(), - })] - } - TurnItem::Sleep(_) => Vec::new(), - TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], - TurnItem::FileChange(item) => item - .as_legacy_end_event(String::new()) - .into_iter() - .collect(), - TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(), - TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), - TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], - } - } } #[cfg(test)] diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs new file mode 100644 index 0000000000..f4a511d367 --- /dev/null +++ b/codex-rs/protocol/src/legacy_events.rs @@ -0,0 +1,289 @@ +use crate::items::AgentMessageContent; +use crate::items::AgentMessageItem; +use crate::items::ContextCompactionItem; +use crate::items::FileChangeItem; +use crate::items::ImageGenerationItem; +use crate::items::McpToolCallItem; +use crate::items::ReasoningItem; +use crate::items::TurnItem; +use crate::items::UserMessageItem; +use crate::items::WebSearchItem; +use crate::protocol::AgentMessageContentDeltaEvent; +use crate::protocol::AgentMessageEvent; +use crate::protocol::AgentReasoningEvent; +use crate::protocol::AgentReasoningRawContentEvent; +use crate::protocol::ContextCompactedEvent; +use crate::protocol::EventMsg; +use crate::protocol::ImageGenerationBeginEvent; +use crate::protocol::ImageGenerationEndEvent; +use crate::protocol::ItemCompletedEvent; +use crate::protocol::ItemStartedEvent; +use crate::protocol::McpInvocation; +use crate::protocol::McpToolCallBeginEvent; +use crate::protocol::McpToolCallEndEvent; +use crate::protocol::PatchApplyBeginEvent; +use crate::protocol::PatchApplyEndEvent; +use crate::protocol::PatchApplyStatus; +use crate::protocol::ReasoningContentDeltaEvent; +use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::UserMessageEvent; +use crate::protocol::ViewImageToolCallEvent; +use crate::protocol::WebSearchBeginEvent; +use crate::protocol::WebSearchEndEvent; + +/// Converts canonical item lifecycle events back into the legacy raw event stream used by +/// compatibility consumers that have not migrated to `TurnItem`. +pub trait HasLegacyEvent { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec; +} + +impl ContextCompactionItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::ContextCompacted(ContextCompactedEvent {}) + } +} + +impl UserMessageItem { + pub fn as_legacy_event(&self) -> EventMsg { + // Legacy user-message events flatten only text inputs into `message` and + // rebase text element ranges onto that concatenated text. + EventMsg::UserMessage(UserMessageEvent { + client_id: self.client_id.clone(), + message: self.message(), + images: Some(self.image_urls()), + image_details: self.image_details(), + local_images: self.local_image_paths(), + local_image_details: self.local_image_details(), + text_elements: self.text_elements(), + }) + } +} + +impl AgentMessageItem { + pub fn as_legacy_events(&self) -> Vec { + self.content + .iter() + .map(|c| match c { + AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent { + message: text.clone(), + phase: self.phase.clone(), + memory_citation: self.memory_citation.clone(), + }), + }) + .collect() + } +} + +impl ReasoningItem { + pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + let mut events = Vec::new(); + for summary in &self.summary_text { + events.push(EventMsg::AgentReasoning(AgentReasoningEvent { + text: summary.clone(), + })); + } + + if show_raw_agent_reasoning { + for entry in &self.raw_content { + events.push(EventMsg::AgentReasoningRawContent( + AgentReasoningRawContentEvent { + text: entry.clone(), + }, + )); + } + } + + events + } +} + +impl WebSearchItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: self.id.clone(), + query: self.query.clone(), + action: self.action.clone(), + }) + } +} + +impl ImageGenerationItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: self.id.clone(), + status: self.status.clone(), + revised_prompt: self.revised_prompt.clone(), + result: self.result.clone(), + saved_path: self.saved_path.clone(), + }) + } +} + +impl FileChangeItem { + pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg { + EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: self.id.clone(), + turn_id, + auto_approved: self.auto_approved.unwrap_or(false), + changes: self.changes.clone(), + }) + } + + pub fn as_legacy_end_event(&self, turn_id: String) -> Option { + let status = self.status.clone()?; + Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: self.id.clone(), + turn_id, + stdout: self.stdout.clone().unwrap_or_default(), + stderr: self.stderr.clone().unwrap_or_default(), + success: status == PatchApplyStatus::Completed, + changes: self.changes.clone(), + status, + })) + } +} + +impl McpToolCallItem { + pub fn as_legacy_begin_event(&self) -> EventMsg { + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: self.id.clone(), + invocation: McpInvocation { + server: self.server.clone(), + tool: self.tool.clone(), + arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), + }, + connector_id: self.connector_id.clone(), + mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + link_id: self.link_id.clone(), + app_name: self.app_name.clone(), + template_id: self.template_id.clone(), + action_name: self.action_name.clone(), + plugin_id: self.plugin_id.clone(), + }) + } + + pub fn as_legacy_end_event(&self) -> Option { + let result = match (&self.result, &self.error) { + (Some(result), _) => Ok(result.clone()), + (None, Some(error)) => Err(error.message.clone()), + (None, None) => return None, + }; + + Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: self.id.clone(), + invocation: McpInvocation { + server: self.server.clone(), + tool: self.tool.clone(), + arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), + }, + mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + connector_id: self.connector_id.clone(), + link_id: self.link_id.clone(), + app_name: self.app_name.clone(), + template_id: self.template_id.clone(), + action_name: self.action_name.clone(), + plugin_id: self.plugin_id.clone(), + duration: self.duration?, + result, + })) + } +} + +impl TurnItem { + pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match self { + TurnItem::UserMessage(item) => vec![item.as_legacy_event()], + TurnItem::HookPrompt(_) => Vec::new(), + TurnItem::AgentMessage(item) => item.as_legacy_events(), + TurnItem::Plan(_) => Vec::new(), + TurnItem::CommandExecution(_) + | TurnItem::DynamicToolCall(_) + | TurnItem::CollabAgentToolCall(_) => Vec::new(), + TurnItem::SubAgentActivity(_) => Vec::new(), + TurnItem::WebSearch(item) => vec![item.as_legacy_event()], + TurnItem::ImageView(item) => { + vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent { + call_id: item.id.clone(), + path: item.path.clone(), + })] + } + TurnItem::Sleep(_) => Vec::new(), + TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], + TurnItem::FileChange(item) => item + .as_legacy_end_event(String::new()) + .into_iter() + .collect(), + TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(), + TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), + TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], + } + } +} + +impl HasLegacyEvent for ItemStartedEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + match &self.item { + TurnItem::WebSearch(item) => vec![EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: item.id.clone(), + })], + TurnItem::ImageView(_) => Vec::new(), + TurnItem::ImageGeneration(item) => { + vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: item.id.clone(), + })] + } + TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], + TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], + _ => Vec::new(), + } + } +} + +impl HasLegacyEvent for ItemCompletedEvent { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match &self.item { + TurnItem::FileChange(item) => item + .as_legacy_end_event(self.turn_id.clone()) + .into_iter() + .collect(), + _ => self.item.as_legacy_events(show_raw_agent_reasoning), + } + } +} + +impl HasLegacyEvent for AgentMessageContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for ReasoningContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for ReasoningRawContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for EventMsg { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match self { + EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning), + EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning), + EventMsg::AgentMessageContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + EventMsg::ReasoningContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + EventMsg::ReasoningRawContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + _ => Vec::new(), + } + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index c50e95d37f..636c64ed7b 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -16,6 +16,7 @@ pub mod dynamic_tools; pub mod error; pub mod exec_output; pub mod items; +mod legacy_events; pub mod mcp; pub mod mcp_approval_meta; pub mod memory_citation; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0096915efb..ca509a5a17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -84,6 +84,7 @@ pub use crate::approvals::NetworkApprovalContext; pub use crate::approvals::NetworkApprovalProtocol; pub use crate::approvals::NetworkPolicyAmendment; pub use crate::approvals::NetworkPolicyRuleAction; +pub use crate::legacy_events::HasLegacyEvent; pub use crate::permissions::FileSystemAccessMode; pub use crate::permissions::FileSystemPath; pub use crate::permissions::FileSystemSandboxEntry; @@ -193,6 +194,9 @@ pub struct McpServerRefreshConfig { pub struct ConversationStartParams { /// Whether Codex response handoffs are managed through explicit client append calls. pub client_managed_handoffs: bool, + /// Whether to route any remaining transcript tail through Codex when the session ends. + /// TODO: Remove this rollout knob once transcript-tail flushing is always enabled. + pub flush_transcript_tail_on_session_end: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. pub codex_responses_as_items: bool, /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set. @@ -1794,25 +1798,6 @@ pub struct ItemStartedEvent { pub started_at_ms: i64, } -impl HasLegacyEvent for ItemStartedEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - match &self.item { - TurnItem::WebSearch(item) => vec![EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: item.id.clone(), - })], - TurnItem::ImageView(_) => Vec::new(), - TurnItem::ImageGeneration(item) => { - vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { - call_id: item.id.clone(), - })] - } - TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], - TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], - _ => Vec::new(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ItemCompletedEvent { pub thread_id: ThreadId, @@ -1829,22 +1814,6 @@ const fn default_item_completed_at_ms() -> i64 { 0 } -pub trait HasLegacyEvent { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec; -} - -impl HasLegacyEvent for ItemCompletedEvent { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match &self.item { - TurnItem::FileChange(item) => item - .as_legacy_end_event(self.turn_id.clone()) - .into_iter() - .collect(), - _ => self.item.as_legacy_events(show_raw_agent_reasoning), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct AgentMessageContentDeltaEvent { pub thread_id: String, @@ -1853,12 +1822,6 @@ pub struct AgentMessageContentDeltaEvent { pub delta: String, } -impl HasLegacyEvent for AgentMessageContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct PlanDeltaEvent { pub thread_id: String, @@ -1878,12 +1841,6 @@ pub struct ReasoningContentDeltaEvent { pub summary_index: i64, } -impl HasLegacyEvent for ReasoningContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ReasoningRawContentDeltaEvent { pub thread_id: String, @@ -1895,31 +1852,6 @@ pub struct ReasoningRawContentDeltaEvent { pub content_index: i64, } -impl HasLegacyEvent for ReasoningRawContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - -impl HasLegacyEvent for EventMsg { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match self { - EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning), - EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning), - EventMsg::AgentMessageContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - EventMsg::ReasoningContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - EventMsg::ReasoningRawContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - _ => Vec::new(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, @@ -5386,6 +5318,7 @@ mod tests { let event = serde_json::from_value::(value).unwrap(); assert_eq!(event.completed_at_ms, 0); } + #[test] fn rollback_failed_error_does_not_affect_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9391f4b3e3..9162b2c868 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -798,7 +798,10 @@ pub(super) async fn consume_rate_limit_reset_credit_request( request_handle .request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit { request_id, - params: ConsumeAccountRateLimitResetCreditParams { idempotency_key }, + params: ConsumeAccountRateLimitResetCreditParams { + idempotency_key, + credit_id: None, + }, }) .await .wrap_err("account/rateLimitResetCredit/consume failed in TUI") diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e568436b40..5ba8d88ff5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1826,42 +1826,10 @@ impl ChatComposer { }; let sel_path = sel.to_string_lossy().to_string(); - if Self::is_image_path(&sel_path) { - let path_buf = PathBuf::from(&sel_path); - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.draft.textarea.replace_range(start_idx..end_idx, ""); - self.draft.textarea.set_cursor(start_idx); - - self.attach_image(path_buf); - self.draft.textarea.insert_str(" "); - } - Err(err) => { - tracing::trace!("image dimensions lookup failed: {err}"); - self.insert_selected_path(&sel_path); - } - } - } else { - self.insert_selected_path(&sel_path); + if let Some((token_range, _)) = + self.current_editable_at_token_range_with_options(/*allow_empty*/ false) + { + self.insert_selected_file_path(token_range, &sel_path); } self.popups.active = ActivePopup::None; (InputResult::None, true) @@ -1935,8 +1903,10 @@ impl ChatComposer { }; if close_popup { - if let Some((insert_text, path)) = selected_mention { - self.insert_selected_mention(&insert_text, path.as_deref()); + if let Some((insert_text, path)) = selected_mention + && let Some((token_range, _)) = self.current_mention_token_range() + { + self.insert_selected_mention(token_range, &insert_text, path.as_deref()); } self.popups.active = ActivePopup::None; } @@ -2040,13 +2010,19 @@ impl ChatComposer { }; if close_popup { - if let Some(selected) = selected { + let token_range = self + .current_editable_at_token_range_with_options(/*allow_empty*/ true) + .map(|(range, _)| range); + if let (Some(selected), Some(token_range)) = (selected, token_range) { match selected { MentionV2Selection::File(path) => { - self.insert_selected_file_path(path.to_string_lossy().as_ref()); + self.insert_selected_file_path( + token_range, + path.to_string_lossy().as_ref(), + ); } MentionV2Selection::Tool { insert_text, path } => { - self.insert_selected_mention(&insert_text, path.as_deref()); + self.insert_selected_mention(token_range, &insert_text, path.as_deref()); } } } @@ -2068,42 +2044,25 @@ impl ChatComposer { || lower.ends_with(".webp") } - fn insert_selected_file_path(&mut self, selected_path: &str) { + fn insert_selected_file_path(&mut self, token_range: Range, selected_path: &str) { if Self::is_image_path(selected_path) { let path_buf = PathBuf::from(selected_path); match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.draft.textarea.replace_range(start_idx..end_idx, ""); + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); - self.insert_selected_path(selected_path); + self.insert_selected_path(token_range, selected_path); } } } else { - self.insert_selected_path(selected_path); + self.insert_selected_path(token_range, selected_path); } } @@ -2372,7 +2331,10 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } - fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + fn current_editable_at_token_range_with_options( + &self, + allow_empty: bool, + ) -> Option<(Range, String)> { let (range, token) = Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?; if self @@ -2402,55 +2364,43 @@ impl ChatComposer { return None; } - Some(token) + Some((range, token)) + } + + fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + self.current_editable_at_token_range_with_options(allow_empty) + .map(|(_, token)| token) } fn current_editable_at_token(&self) -> Option { self.current_editable_at_token_with_options(/*allow_empty*/ false) } - fn current_mentions_v2_token(&self) -> Option { + fn current_mentions_v2_token_range(&self) -> Option<(Range, String)> { if !self.mentions_v2_enabled { return None; } - self.current_editable_at_token_with_options(/*allow_empty*/ true) + self.current_editable_at_token_range_with_options(/*allow_empty*/ true) } - fn current_mention_token(&self) -> Option { + fn current_mentions_v2_token(&self) -> Option { + self.current_mentions_v2_token_range() + .map(|(_, token)| token) + } + + fn current_mention_token_range(&self) -> Option<(Range, String)> { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true) + Self::current_prefixed_token_range(&self.draft.textarea, '$', /*allow_empty*/ true) + } + + fn current_mention_token(&self) -> Option { + self.current_mention_token_range().map(|(_, token)| token) } /// Replace the active `@token` (the one under the cursor) with `path`. - /// - /// The algorithm mirrors `current_at_token` so replacement works no matter - /// where the cursor is within the token and regardless of how many - /// `@tokens` exist in the line. - fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - + fn insert_selected_path(&mut self, token_range: Range, path: &str) { // If the path contains whitespace, wrap it in double quotes so the // local prompt arg parser treats it as a single argument. Avoid adding // quotes when the path already contains one to keep behavior simple. @@ -2463,36 +2413,23 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. + let start_idx = token_range.start; self.draft .textarea - .replace_range(start_idx..end_idx, &format!("{inserted} ")); + .replace_range(token_range, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.draft.textarea.set_cursor(new_cursor); } - fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - + fn insert_selected_mention( + &mut self, + token_range: Range, + insert_text: &str, + path: Option<&str>, + ) { // Remove the active token and insert the selected mention as an atomic element. - self.draft.textarea.replace_range(start_idx..end_idx, ""); + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); let id = self.draft.textarea.insert_element(insert_text); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap new file mode 100644 index 0000000000..4b88bc9e1b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: history +--- +• SessionStart hook (stopped) + hook context: This hook context is intentionally long enough to wrap across + several terminal rows while keeping the complete value available in the + … +2 lines (ctrl + t to view transcript) + stop: The hook stopped this turn for an important reason. + This second line must remain visible in full. diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 300978cf4d..f8626dfcc0 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -995,30 +995,34 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - handle_turn_started(&mut chat, "turn-1"); - while rx.try_recv().is_ok() {} - - handle_agent_reasoning_delta(&mut chat, "**Step one**\nFirst summary"); - handle_agent_message_delta(&mut chat, "Final answer"); - complete_assistant_message( - &mut chat, - "msg-1", - "Final answer", - Some(MessagePhase::FinalAnswer), - ); chat.handle_server_notification( - ServerNotification::ReasoningSummaryPartAdded( - codex_app_server_protocol::ReasoningSummaryPartAddedNotification { - thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: "turn-1".to_string(), - item_id: "reasoning-1".to_string(), - summary_index: 1, + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, - ), + }), + /*replay_kind*/ None, + ); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: "Summary only".to_string(), + summary_index: 0, + }), /*replay_kind*/ None, ); - handle_agent_reasoning_delta(&mut chat, "**Step two**\nSecond summary"); - handle_agent_reasoning_final(&mut chat); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { @@ -1027,35 +1031,20 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), - summary: vec![ - "**Step one**\nFirst summary".to_string(), - "**Step two**\nSecond summary".to_string(), - ], + summary: vec!["Summary only".to_string()], content: Vec::new(), }, }), /*replay_kind*/ None, ); - let mut consolidated_answer = None; - let mut inserted_history = String::new(); - while let Ok(event) = rx.try_recv() { - match event { - AppEvent::ConsolidateAgentMessage { source, .. } => { - consolidated_answer = Some(source); - } - AppEvent::InsertHistoryCell(cell) => { - inserted_history.push_str(&lines_to_single_string( - &cell.transcript_lines(/*width*/ 80), - )); - } - _ => {} + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) } - } - - assert_eq!(consolidated_answer.as_deref(), Some("Final answer")); - assert_eq!(inserted_history.matches("First summary").count(), 1); - assert_eq!(inserted_history.matches("Second summary").count(), 1); + other => panic!("expected InsertHistoryCell, got {other:?}"), + }; + assert_eq!(rendered.matches("Summary only").count(), 1); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 156348d42c..d118191390 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3888,6 +3888,41 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash() ); } +#[tokio::test] +async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_hook_completed( + &mut chat, + hook_completed_run( + "session-start:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::SessionStart, + codex_app_server_protocol::HookRunStatus::Stopped, + vec![ + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "This hook context is intentionally long enough to wrap across several terminal rows while keeping the complete value available in the transcript overlay. The main conversation should stay compact even when a hook injects a large block of instructions for the model." + .to_string(), + }, + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Stop, + text: "The hook stopped this turn for an important reason.\nThis second line must remain visible in full." + .to_string(), + }, + ], + ), + ); + + let history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_chatwidget_snapshot!( + "long_hook_context_is_truncated_with_transcript_hint", + history + ); +} + #[tokio::test] async fn session_start_hook_events_render_snapshot() { assert_hook_events_snapshot( diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index c05b4a62ff..22908b1112 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -14,7 +14,10 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -35,7 +38,10 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -63,7 +69,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -76,7 +85,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() { chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None, + }), ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -92,7 +104,10 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -122,7 +137,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); assert_matches!( @@ -139,7 +157,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, vec![snapshot(/*percent*/ 92.0)], - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None, + }), ); assert_eq!(chat.available_rate_limit_reset_credits, None); @@ -185,7 +206,10 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -206,7 +230,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); record_popup(&chat, &mut states); @@ -215,7 +242,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); record_popup(&chat, &mut states); @@ -270,7 +300,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); record_popup(&chat, &mut states); @@ -288,7 +321,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( free_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!( "Free:\n{}", @@ -301,7 +337,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( go_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); @@ -317,7 +356,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( business_request_id, vec![monthly_business_snapshot], - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!( "Business with monthly window:\n{}", @@ -337,7 +379,10 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -353,7 +398,10 @@ async fn rate_limit_reset_confirmation_can_use_reset() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); @@ -393,7 +441,10 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(!finish_reset_consume_outcome( @@ -438,7 +489,10 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); dismiss_popup(&mut chat); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -469,7 +523,10 @@ async fn already_redeemed_is_an_idempotent_success() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) @@ -485,7 +542,10 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(finish_reset_consume_outcome( @@ -522,7 +582,10 @@ async fn account_change_invalidates_pending_reset_requests() { assert!(!chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); } @@ -536,7 +599,10 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.clear_pending_rate_limit_reset_hint(); @@ -559,7 +625,10 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -588,7 +657,10 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -631,7 +703,10 @@ async fn startup_check_shows_available_reset_hint_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); let rendered = lines_to_single_string( &chat @@ -654,7 +729,10 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.usage_history_insertion_blocked()); @@ -685,7 +763,10 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { assert!(!chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -698,7 +779,10 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -716,7 +800,10 @@ async fn startup_check_omits_reset_hint_when_none_are_available() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -731,7 +818,10 @@ async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 882683ad28..bf17277b91 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -12,6 +12,7 @@ use crate::motion::activity_indicator; use crate::render::highlight::highlight_bash_to_lines; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +use crate::ui_consts::TRANSCRIPT_HINT; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; @@ -32,7 +33,6 @@ use unicode_width::UnicodeWidthStr; pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; -const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript"; pub(crate) struct OutputLinesParams { pub(crate) line_limit: usize, diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index c3214aeb16..51191ea103 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -12,11 +12,16 @@ //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; use super::plain_lines; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; use crate::motion::shimmer_text; +use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; +use crate::ui_consts::TRANSCRIPT_HINT; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookOutputEntry; use codex_app_server_protocol::HookOutputEntryKind; @@ -50,6 +55,7 @@ const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600); const HOOK_OUTPUT_INDENT: &str = " "; const HOOK_OUTPUT_BODY_INDENT: &str = " "; +const HOOK_CONTEXT_MAX_DISPLAY_ROWS: usize = 3; #[derive(Debug)] struct HookRunCell { @@ -294,11 +300,9 @@ impl HookCell { run.reveal_running_after_delayed_redraw_for_test(now); } } -} -impl HistoryCell for HookCell { - /// Builds viewport lines while coalescing adjacent visible-running hooks. - fn display_lines(&self, _width: u16) -> Vec> { + /// Builds hook lines for either the bounded main viewport or the full transcript overlay. + fn output_lines(&self, width: u16, render_full_context: bool) -> Vec> { let mut lines = Vec::new(); let mut running_group: Option = None; for run in &self.runs { @@ -313,7 +317,12 @@ impl HistoryCell for HookCell { push_running_hook_group(&mut lines, &group, self.animations_enabled); } push_hook_line_separator(&mut lines); - run.push_display_lines(&mut lines, self.animations_enabled); + run.push_display_lines( + &mut lines, + self.animations_enabled, + width, + render_full_context, + ); continue; }; @@ -338,14 +347,21 @@ impl HistoryCell for HookCell { } lines } +} - /// Hook transcript output matches viewport output. +impl HistoryCell for HookCell { + /// Builds viewport lines while coalescing adjacent visible-running hooks. + fn display_lines(&self, width: u16) -> Vec> { + self.output_lines(width, /*render_full_context*/ false) + } + + /// The transcript overlay preserves complete hook context hidden by the viewport preview. fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) + self.output_lines(width, /*render_full_context*/ true) } fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.output_lines(u16::MAX, /*render_full_context*/ true)) } /// Produces a coarse cache key for transcript overlays while hook animations are active. @@ -420,7 +436,13 @@ impl HookRunCell { } /// Appends the lines for a single, ungrouped hook run. - fn push_display_lines(&self, lines: &mut Vec>, animations_enabled: bool) { + fn push_display_lines( + &self, + lines: &mut Vec>, + animations_enabled: bool, + width: u16, + render_full_context: bool, + ) { let label = hook_event_label(self.event_name); match &self.state { HookRunState::VisibleRunning { start_time, .. } @@ -446,17 +468,10 @@ impl HookRunCell { .into(), ); for entry in entries { - let prefix = hook_output_prefix(entry.kind); - let mut output_lines = entry.text.split('\n'); - if let Some(first_line) = output_lines.next() { - lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into()); - } - for line in output_lines { - if line.is_empty() { - lines.push("".into()); - } else { - lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into()); - } + if !render_full_context && entry.kind == HookOutputEntryKind::Context { + lines.extend(hook_context_preview_lines(&entry.text, width)); + } else { + push_full_hook_output_entry(lines, entry); } } } @@ -465,6 +480,80 @@ impl HookRunCell { } } +fn push_full_hook_output_entry(lines: &mut Vec>, entry: &HookOutputEntry) { + let prefix = hook_output_prefix(entry.kind); + let mut output_lines = entry.text.split('\n'); + if let Some(first_line) = output_lines.next() { + lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into()); + } + for line in output_lines { + if line.is_empty() { + lines.push("".into()); + } else { + lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into()); + } + } +} + +fn hook_context_preview_lines(text: &str, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + let mut wrapped = Vec::new(); + let mut source_lines = text.split('\n'); + let first_line = source_lines.next().unwrap_or_default(); + push_wrapped_hook_context_line( + &mut wrapped, + first_line, + width, + Line::from(format!( + "{HOOK_OUTPUT_INDENT}{}", + hook_output_prefix(HookOutputEntryKind::Context) + )), + ); + for line in source_lines { + if line.is_empty() { + wrapped.push("".into()); + } else { + push_wrapped_hook_context_line( + &mut wrapped, + line, + width, + Line::from(HOOK_OUTPUT_BODY_INDENT), + ); + } + } + + if wrapped.len() <= HOOK_CONTEXT_MAX_DISPLAY_ROWS { + return wrapped; + } + + let retained_rows = HOOK_CONTEXT_MAX_DISPLAY_ROWS - 1; + let omitted_rows = wrapped.len() - retained_rows; + wrapped.truncate(retained_rows); + let hint = vec![ + HOOK_OUTPUT_BODY_INDENT.into(), + format!("… +{omitted_rows} lines ({TRANSCRIPT_HINT})").dim(), + ] + .into(); + wrapped.push(truncate_line_with_ellipsis_if_overflow(hint, width)); + wrapped +} + +fn push_wrapped_hook_context_line( + output: &mut Vec>, + text: &str, + width: usize, + initial_indent: Line<'static>, +) { + let line = Line::from(text.to_string()); + let wrapped = word_wrap_line( + &line, + RtOptions::new(width) + .initial_indent(initial_indent) + .subsequent_indent(Line::from(HOOK_OUTPUT_BODY_INDENT)), + ); + push_owned_lines(&wrapped, output); +} + impl HookRunState { /// Creates the hidden initial state for a live hook run. fn pending(start_time: Instant) -> Self { @@ -760,14 +849,13 @@ mod tests { } #[test] - fn completed_hook_multiline_context_preserves_display_and_raw_lines() { + fn completed_hook_short_multiline_context_preserves_display_transcript_and_raw_lines() { let cell = completed_hook_cell( HookEventName::SessionStart, HookRunStatus::Completed, vec![HookOutputEntry { kind: HookOutputEntryKind::Context, - text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory" - .to_string(), + text: "## Working Memory Recall\n\nSource: Codex compaction".to_string(), }], ); let expected = vec![ @@ -775,13 +863,87 @@ mod tests { " hook context: ## Working Memory Recall".to_string(), "".to_string(), " Source: Codex compaction".to_string(), - " Scope: Durable workspace memory".to_string(), ]; assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected); + assert_eq!(line_texts(&cell.transcript_lines(/*width*/ 80)), expected); assert_eq!(line_texts(&cell.raw_lines()), expected); } + #[test] + fn completed_hook_long_single_line_context_is_truncated_only_in_display() { + let full_context = format!( + "{}tail-marker", + "context words that should wrap across the terminal width ".repeat(8) + ); + let cell = completed_hook_cell( + HookEventName::SessionStart, + HookRunStatus::Completed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: full_context.clone(), + }], + ); + + let display_lines = cell.display_lines(/*width*/ 80); + let display = line_texts(&display_lines); + assert_eq!(display.len(), 4); + assert_eq!( + Paragraph::new(Text::from(display_lines[1..].to_vec())) + .wrap(Wrap { trim: false }) + .line_count(/*width*/ 80), + HOOK_CONTEXT_MAX_DISPLAY_ROWS + ); + assert!( + display + .iter() + .any(|line| line.contains("ctrl + t to view transcript")), + "expected truncated context to advertise the transcript: {display:?}" + ); + assert!(display.iter().all(|line| !line.contains("tail-marker"))); + + let expected_full = vec![ + "• SessionStart hook (completed)".to_string(), + format!(" hook context: {full_context}"), + ]; + assert_eq!( + line_texts(&cell.transcript_lines(/*width*/ 80)), + expected_full + ); + assert_eq!(line_texts(&cell.raw_lines()), expected_full); + } + + #[test] + fn completed_hook_non_context_entries_are_not_truncated() { + for kind in [ + HookOutputEntryKind::Warning, + HookOutputEntryKind::Stop, + HookOutputEntryKind::Feedback, + HookOutputEntryKind::Error, + ] { + let cell = completed_hook_cell( + HookEventName::UserPromptSubmit, + HookRunStatus::Stopped, + vec![HookOutputEntry { + kind, + text: "first\nsecond\nthird\nfourth\nfifth".to_string(), + }], + ); + + let display = line_texts(&cell.display_lines(/*width*/ 20)); + assert!( + display.iter().any(|line| line == " fifth"), + "expected {kind:?} output to remain complete: {display:?}" + ); + assert!( + display + .iter() + .all(|line| !line.contains("ctrl + t to view transcript")), + "did not expect a transcript hint for {kind:?}: {display:?}" + ); + } + } + #[test] fn completed_hook_multiline_warning_prefixes_first_line_only() { let cell = completed_hook_cell( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 96f442a88c..dc8262f7db 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3,7 +3,6 @@ // alternate‑screen mode starts; that file opts‑out locally via `allow`. #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] -use crate::legacy_core::check_execpolicy_for_warnings; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; @@ -13,7 +12,6 @@ use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; -use crate::legacy_core::format_exec_policy_error_with_source; use crate::session_resume::ResolveCwdOutcome; use crate::session_resume::resolve_cwd_for_resume_or_fork; pub use crate::startup_error::LocalStateDbStartupError; @@ -1141,18 +1139,6 @@ pub async fn run_main( .as_table() .is_some_and(|table| table.contains_key("log_dir")); - #[allow(clippy::print_stderr)] - match check_execpolicy_for_warnings(&config.config_layer_stack).await { - Ok(None) => {} - Ok(Some(err)) | Err(err) => { - eprintln!( - "Error loading rules:\n{}", - format_exec_policy_error_with_source(&err) - ); - std::process::exit(1); - } - } - set_default_client_residency_requirement(config.enforce_residency.value()); if let Some(warning) = add_dir_warning_message( diff --git a/codex-rs/tui/src/ui_consts.rs b/codex-rs/tui/src/ui_consts.rs index ebbe4ce366..f72dc64263 100644 --- a/codex-rs/tui/src/ui_consts.rs +++ b/codex-rs/tui/src/ui_consts.rs @@ -9,3 +9,4 @@ /// - User history lines account for this many columns (e.g., "▌ ") when wrapping. pub(crate) const LIVE_PREFIX_COLS: u16 = 2; pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize; +pub(crate) const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";