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