mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge remote-tracking branch 'origin/main' into jif/pr-27179-cleanup
# Conflicts: # codex-rs/tui/src/app_server_session.rs
This commit is contained in:
@@ -226,18 +226,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AppsMcpPathOverrideConfigToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"AskForApproval": {
|
||||
"description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.",
|
||||
"oneOf": [
|
||||
@@ -408,7 +396,23 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_path_override": {
|
||||
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth_elicitation": {
|
||||
"type": "boolean"
|
||||
@@ -572,6 +576,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -617,6 +624,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -836,16 +846,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeatureToml_for_AppsMcpPathOverrideConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AppsMcpPathOverrideConfigToml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FeatureToml_for_CodeModeConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4534,7 +4534,23 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_path_override": {
|
||||
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth_elicitation": {
|
||||
"type": "boolean"
|
||||
@@ -4698,6 +4714,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4743,6 +4762,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5224,4 +5246,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
@@ -1097,6 +1097,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history),
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
|
||||
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
|
||||
@@ -2490,7 +2491,7 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() {
|
||||
async fn list_agent_subtree_thread_ids_finds_live_descendants_of_unloaded_root() {
|
||||
let (_home, config) = test_config().await;
|
||||
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
@@ -2535,6 +2536,8 @@ async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_d
|
||||
.await
|
||||
.expect("grandchild spawn should succeed");
|
||||
|
||||
manager.remove_thread(&parent_thread_id).await;
|
||||
|
||||
let mut subtree_thread_ids = manager
|
||||
.list_agent_subtree_thread_ids(parent_thread_id)
|
||||
.await
|
||||
|
||||
+33
-37
@@ -28,7 +28,6 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_api::ApiError;
|
||||
@@ -172,7 +171,6 @@ pub(crate) struct CompactConversationRequestSettings {
|
||||
struct ModelClientState {
|
||||
session_id: SessionId,
|
||||
thread_id: ThreadId,
|
||||
window_generation: AtomicU64,
|
||||
installation_id: String,
|
||||
provider: SharedModelProvider,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
@@ -345,7 +343,6 @@ impl ModelClient {
|
||||
state: Arc::new(ModelClientState {
|
||||
session_id,
|
||||
thread_id,
|
||||
window_generation: AtomicU64::new(0),
|
||||
installation_id,
|
||||
provider: model_provider,
|
||||
auth_env_telemetry,
|
||||
@@ -394,24 +391,6 @@ impl ModelClient {
|
||||
self.state.provider.auth_manager()
|
||||
}
|
||||
|
||||
pub(crate) fn set_window_generation(&self, window_generation: u64) {
|
||||
self.state
|
||||
.window_generation
|
||||
.store(window_generation, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn advance_window_generation(&self) {
|
||||
self.state.window_generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn current_window_id(&self) -> String {
|
||||
let thread_id = self.state.thread_id;
|
||||
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
|
||||
format!("{thread_id}:{window_generation}")
|
||||
}
|
||||
|
||||
fn take_cached_websocket_session(&self) -> WebsocketSession {
|
||||
let mut cached_websocket_session = self
|
||||
.state
|
||||
@@ -457,6 +436,7 @@ impl ModelClient {
|
||||
///
|
||||
/// The model selection and telemetry context are passed explicitly to keep `ModelClient`
|
||||
/// session-scoped.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn compact_conversation_history(
|
||||
&self,
|
||||
prompt: &Prompt,
|
||||
@@ -464,6 +444,7 @@ impl ModelClient {
|
||||
settings: CompactConversationRequestSettings,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
compaction_trace: &CompactionTraceContext,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> Result<Vec<ResponseItem>> {
|
||||
if prompt.input.is_empty() {
|
||||
@@ -488,6 +469,7 @@ impl ModelClient {
|
||||
settings.effort,
|
||||
settings.summary,
|
||||
settings.service_tier,
|
||||
window_id,
|
||||
)?;
|
||||
let ResponsesApiRequest {
|
||||
model,
|
||||
@@ -522,7 +504,7 @@ impl ModelClient {
|
||||
/*turn_state*/ None,
|
||||
parse_turn_metadata_header(turn_metadata_header).as_ref(),
|
||||
));
|
||||
extra_headers.extend(self.build_responses_identity_headers());
|
||||
extra_headers.extend(self.build_responses_identity_headers(Some(window_id)));
|
||||
extra_headers.extend(build_session_headers(
|
||||
Some(self.state.session_id.to_string()),
|
||||
Some(self.state.thread_id.to_string()),
|
||||
@@ -644,14 +626,16 @@ impl ModelClient {
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
|
||||
fn build_responses_identity_headers(&self, window_id: Option<&str>) -> ApiHeaderMap {
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id)
|
||||
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) {
|
||||
if let Some(window_id) = window_id
|
||||
&& let Ok(val) = HeaderValue::from_str(window_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
@@ -659,6 +643,7 @@ impl ModelClient {
|
||||
|
||||
fn build_ws_client_metadata(
|
||||
&self,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
use_responses_lite: bool,
|
||||
) -> HashMap<String, String> {
|
||||
@@ -667,10 +652,7 @@ impl ModelClient {
|
||||
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
|
||||
self.state.installation_id.clone(),
|
||||
);
|
||||
client_metadata.insert(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
);
|
||||
client_metadata.insert(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string());
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
|
||||
}
|
||||
@@ -752,6 +734,7 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_responses_request(
|
||||
&self,
|
||||
provider: &codex_api::Provider,
|
||||
@@ -760,9 +743,10 @@ impl ModelClient {
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
summary: ReasoningSummaryConfig,
|
||||
service_tier: Option<String>,
|
||||
window_id: &str,
|
||||
) -> Result<ResponsesApiRequest> {
|
||||
let instructions = &prompt.base_instructions.text;
|
||||
let input = prompt.get_formatted_input();
|
||||
let input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
|
||||
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
let reasoning = Self::build_reasoning(model_info, effort, summary);
|
||||
let include = if reasoning.is_some() {
|
||||
@@ -807,10 +791,7 @@ impl ModelClient {
|
||||
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
|
||||
self.state.installation_id.clone(),
|
||||
),
|
||||
(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
),
|
||||
(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string()),
|
||||
])),
|
||||
};
|
||||
Ok(request)
|
||||
@@ -955,7 +936,7 @@ impl ModelClient {
|
||||
headers.insert("x-client-request-id", header_value);
|
||||
}
|
||||
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
|
||||
headers.extend(self.build_responses_identity_headers());
|
||||
headers.extend(self.build_responses_identity_headers(/*window_id*/ None));
|
||||
if let Some(header_value) = self.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
@@ -998,6 +979,7 @@ impl ModelClientSession {
|
||||
/// regardless of transport choice.
|
||||
async fn build_responses_options(
|
||||
&self,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
compression: Compression,
|
||||
use_responses_lite: bool,
|
||||
@@ -1015,7 +997,10 @@ impl ModelClientSession {
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
);
|
||||
headers.extend(self.client.build_responses_identity_headers());
|
||||
headers.extend(
|
||||
self.client
|
||||
.build_responses_identity_headers(Some(window_id)),
|
||||
);
|
||||
if let Some(header_value) = self.client.generate_attestation_header_for().await {
|
||||
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
|
||||
}
|
||||
@@ -1115,7 +1100,6 @@ impl ModelClientSession {
|
||||
pub async fn preconnect_websocket(
|
||||
&mut self,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
_model_info: &ModelInfo,
|
||||
) -> std::result::Result<(), ApiError> {
|
||||
if !self.client.responses_websocket_enabled() {
|
||||
return Ok(());
|
||||
@@ -1257,6 +1241,7 @@ impl ModelClientSession {
|
||||
)]
|
||||
async fn stream_responses_api(
|
||||
&self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1288,6 +1273,7 @@ impl ModelClientSession {
|
||||
let compression = self.responses_request_compression(client_setup.auth.as_ref());
|
||||
let mut options = self
|
||||
.build_responses_options(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
compression,
|
||||
model_info.use_responses_lite,
|
||||
@@ -1301,6 +1287,7 @@ impl ModelClientSession {
|
||||
effort.clone(),
|
||||
summary,
|
||||
service_tier.clone(),
|
||||
window_id,
|
||||
)?;
|
||||
let inference_trace_attempt = inference_trace.start_attempt();
|
||||
inference_trace_attempt.add_request_headers(&mut options.extra_headers);
|
||||
@@ -1374,6 +1361,7 @@ impl ModelClientSession {
|
||||
)]
|
||||
async fn stream_responses_websocket(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1402,6 +1390,7 @@ impl ModelClientSession {
|
||||
|
||||
let options = self
|
||||
.build_responses_options(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
compression,
|
||||
model_info.use_responses_lite,
|
||||
@@ -1414,10 +1403,12 @@ impl ModelClientSession {
|
||||
effort.clone(),
|
||||
summary,
|
||||
service_tier.clone(),
|
||||
window_id,
|
||||
)?;
|
||||
let mut ws_payload = ResponseCreateWsRequest {
|
||||
client_metadata: response_create_client_metadata(
|
||||
Some(self.client.build_ws_client_metadata(
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
model_info.use_responses_lite,
|
||||
)),
|
||||
@@ -1553,6 +1544,7 @@ impl ModelClientSession {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn prewarm_websocket(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1571,6 +1563,7 @@ impl ModelClientSession {
|
||||
let disabled_trace = InferenceTraceContext::disabled();
|
||||
match self
|
||||
.stream_responses_websocket(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1614,6 +1607,7 @@ impl ModelClientSession {
|
||||
/// branches.
|
||||
pub async fn stream(
|
||||
&mut self,
|
||||
window_id: &str,
|
||||
prompt: &Prompt,
|
||||
model_info: &ModelInfo,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -1630,6 +1624,7 @@ impl ModelClientSession {
|
||||
let request_trace = current_span_w3c_trace_context();
|
||||
match self
|
||||
.stream_responses_websocket(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1651,6 +1646,7 @@ impl ModelClientSession {
|
||||
}
|
||||
|
||||
self.stream_responses_api(
|
||||
window_id,
|
||||
prompt,
|
||||
model_info,
|
||||
session_telemetry,
|
||||
@@ -1989,7 +1985,7 @@ impl AuthRequestTelemetryContext {
|
||||
let auth_telemetry = auth_header_telemetry(api_auth);
|
||||
Self {
|
||||
auth_mode: auth_mode.map(|mode| match mode {
|
||||
AuthMode::ApiKey => "ApiKey",
|
||||
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
|
||||
@@ -2,6 +2,8 @@ pub use codex_api::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_tools::ToolSpec;
|
||||
@@ -71,6 +73,56 @@ impl Prompt {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input_for_request(
|
||||
&self,
|
||||
use_responses_lite: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut input = self.get_formatted_input();
|
||||
if use_responses_lite {
|
||||
strip_image_details(&mut input);
|
||||
}
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_image_details(items: &mut [ResponseItem]) {
|
||||
for item in items {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content {
|
||||
if let ContentItem::InputImage { detail, .. } = content_item {
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content) = output.content_items_mut() {
|
||||
for content_item in content {
|
||||
if let FunctionCallOutputContentItem::InputImage { detail, .. } =
|
||||
content_item
|
||||
{
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
|
||||
@@ -3,10 +3,95 @@ use codex_api::ResponsesApiRequest;
|
||||
use codex_api::TextControls;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn prompt_with_image_outputs() -> Prompt {
|
||||
Prompt {
|
||||
input: vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_lite_request_copies_strip_image_details() {
|
||||
let prompt = prompt_with_image_outputs();
|
||||
let original = prompt.input.clone();
|
||||
|
||||
let stripped = prompt.get_formatted_input_for_request(/*use_responses_lite*/ true);
|
||||
|
||||
assert_eq!(
|
||||
stripped,
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(prompt.input, original);
|
||||
assert_eq!(
|
||||
prompt.get_formatted_input_for_request(/*use_responses_lite*/ false),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_verbosity_when_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
|
||||
@@ -291,13 +291,13 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
|
||||
Some(parent_thread_id),
|
||||
);
|
||||
|
||||
client.advance_window_generation();
|
||||
|
||||
let thread_id = client.state.thread_id;
|
||||
let window_id = format!("{thread_id}:1");
|
||||
let client_metadata = client.build_ws_client_metadata(
|
||||
&window_id,
|
||||
Some(r#"{"turn_id":"turn-123"}"#),
|
||||
/*use_responses_lite*/ false,
|
||||
);
|
||||
let thread_id = client.state.thread_id;
|
||||
assert_eq!(
|
||||
client_metadata,
|
||||
std::collections::HashMap::from([
|
||||
|
||||
@@ -452,7 +452,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
|
||||
let (parent_session, parent_ctx, _rx_events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([(
|
||||
@@ -464,21 +464,6 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
},
|
||||
)])));
|
||||
let cancel_token = CancellationToken::new();
|
||||
let manager = Arc::clone(&parent_session.services.mcp_connection_manager);
|
||||
let (manager_locked_tx, manager_locked_rx) = std::sync::mpsc::sync_channel(0);
|
||||
let (release_manager_tx, release_manager_rx) = std::sync::mpsc::sync_channel(0);
|
||||
let manager_lock = tokio::task::spawn_blocking(move || {
|
||||
let _manager_guard = manager.blocking_write();
|
||||
manager_locked_tx
|
||||
.send(())
|
||||
.expect("manager lock receiver should remain open");
|
||||
release_manager_rx
|
||||
.recv()
|
||||
.expect("manager lock release sender should remain open");
|
||||
});
|
||||
manager_locked_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("manager write lock should be acquired");
|
||||
|
||||
let event = RequestUserInputEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
@@ -498,24 +483,7 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
|
||||
&pending_mcp_invocations,
|
||||
&event,
|
||||
&cancel_token,
|
||||
);
|
||||
tokio::pin!(response);
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), &mut response)
|
||||
.await
|
||||
.is_err(),
|
||||
"manual reviewer should wait for MCP metadata"
|
||||
);
|
||||
release_manager_tx
|
||||
.send(())
|
||||
.expect("manager lock holder should remain open");
|
||||
manager_lock
|
||||
.await
|
||||
.expect("manager lock task should not panic");
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(1), response)
|
||||
.await
|
||||
.expect("manual reviewer should finish after MCP metadata lookup"),
|
||||
None
|
||||
);
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response, None);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,14 @@ pub struct CodexThread {
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct BackgroundTerminalInfo {
|
||||
pub item_id: String,
|
||||
pub process_id: String,
|
||||
pub command: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a thread
|
||||
/// (formerly called a conversation) in Codex.
|
||||
impl CodexThread {
|
||||
@@ -396,6 +404,17 @@ impl CodexThread {
|
||||
self.codex.agent_status().await
|
||||
}
|
||||
|
||||
pub async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo> {
|
||||
self.codex.session.list_background_terminals().await
|
||||
}
|
||||
|
||||
pub async fn terminate_background_terminal(&self, process_id: i32) -> bool {
|
||||
self.codex
|
||||
.session
|
||||
.terminate_background_terminal(process_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
|
||||
self.codex.agent_status.clone()
|
||||
}
|
||||
|
||||
@@ -143,17 +143,17 @@ async fn run_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_compact_task_inner_impl(
|
||||
@@ -165,7 +165,7 @@ async fn run_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
@@ -173,7 +173,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -184,7 +184,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -228,7 +228,7 @@ async fn run_compact_task_inner_impl(
|
||||
personality: turn_context.personality,
|
||||
..Default::default()
|
||||
};
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_compaction(&window_id, compaction_metadata);
|
||||
@@ -236,6 +236,7 @@ async fn run_compact_task_inner_impl(
|
||||
&sess,
|
||||
turn_context.as_ref(),
|
||||
&mut client_session,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
&prompt,
|
||||
)
|
||||
@@ -293,6 +294,7 @@ async fn run_compact_task_inner_impl(
|
||||
let user_messages = collect_user_messages(history_items);
|
||||
|
||||
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
|
||||
let window_id = sess.advance_auto_compact_window_id().await;
|
||||
|
||||
if matches!(
|
||||
initial_context_injection,
|
||||
@@ -309,6 +311,7 @@ async fn run_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: summary_text.clone(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: Some(window_id),
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
@@ -340,6 +343,7 @@ pub(crate) struct CompactionAnalyticsDetails {
|
||||
pub(crate) active_context_tokens_before: Option<i64>,
|
||||
pub(crate) retained_image_count: Option<usize>,
|
||||
pub(crate) compaction_summary_tokens: Option<i64>,
|
||||
pub(crate) cached_input_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
impl CompactionAnalyticsAttempt {
|
||||
@@ -369,13 +373,14 @@ impl CompactionAnalyticsAttempt {
|
||||
self,
|
||||
sess: &Session,
|
||||
status: CompactionStatus,
|
||||
error: Option<String>,
|
||||
codex_error: Option<&CodexErr>,
|
||||
details: CompactionAnalyticsDetails,
|
||||
) {
|
||||
let CompactionAnalyticsDetails {
|
||||
active_context_tokens_before,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
} = details;
|
||||
let active_context_tokens_before =
|
||||
active_context_tokens_before.unwrap_or(self.active_context_tokens_before);
|
||||
@@ -391,11 +396,14 @@ impl CompactionAnalyticsAttempt {
|
||||
phase: self.phase,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status,
|
||||
error,
|
||||
codex_error_kind: codex_error.map(Into::into),
|
||||
codex_error_http_status_code: codex_error
|
||||
.and_then(CodexErr::http_status_code_value),
|
||||
active_context_tokens_before,
|
||||
active_context_tokens_after,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
started_at: self.started_at,
|
||||
completed_at: now_unix_seconds(),
|
||||
duration_ms: Some(
|
||||
@@ -579,11 +587,13 @@ async fn drain_to_completed(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
client_session: &mut ModelClientSession,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
prompt: &Prompt,
|
||||
) -> CodexResult<()> {
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
window_id,
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
|
||||
@@ -113,17 +113,17 @@ async fn run_remote_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
@@ -135,18 +135,18 @@ async fn run_remote_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error, analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error.clone(), analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
@@ -224,7 +224,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
};
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_compaction(&window_id, compaction_metadata);
|
||||
@@ -245,9 +245,11 @@ async fn run_remote_compact_task_inner_impl(
|
||||
},
|
||||
&turn_context.session_telemetry,
|
||||
&compaction_trace,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let new_window_id = sess.advance_auto_compact_window_id().await;
|
||||
new_history = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -263,6 +265,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: Some(new_window_id),
|
||||
};
|
||||
// Install is the semantic boundary where the compact endpoint's output becomes live
|
||||
// thread history. Keep it distinct from the later inference request so the reducer can
|
||||
|
||||
@@ -127,17 +127,17 @@ async fn run_remote_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
@@ -150,18 +150,18 @@ async fn run_remote_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error, analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error.clone(), analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
@@ -240,7 +240,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
output_schema_strict: true,
|
||||
};
|
||||
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_compaction(&window_id, compaction_metadata);
|
||||
@@ -264,6 +264,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
turn_context,
|
||||
client_session,
|
||||
&prompt,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await;
|
||||
@@ -280,10 +281,12 @@ async fn run_remote_compact_task_inner_impl(
|
||||
if let Some(token_usage) = token_usage {
|
||||
analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
|
||||
analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens);
|
||||
analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens);
|
||||
}
|
||||
let (compacted_history, retained_images) =
|
||||
build_v2_compacted_history(&prompt_input, compaction_output);
|
||||
analytics_details.retained_image_count = Some(retained_images);
|
||||
let new_window_id = sess.advance_auto_compact_window_id().await;
|
||||
let new_history = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -299,6 +302,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: Some(new_window_id),
|
||||
};
|
||||
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
|
||||
input_history: &trace_input_history,
|
||||
@@ -323,6 +327,7 @@ async fn run_remote_compaction_request_v2(
|
||||
turn_context: &TurnContext,
|
||||
client_session: &mut ModelClientSession,
|
||||
prompt: &Prompt,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> CodexResult<RemoteCompactionV2Output> {
|
||||
let max_retries = turn_context
|
||||
@@ -334,6 +339,7 @@ async fn run_remote_compaction_request_v2(
|
||||
loop {
|
||||
let result = match client_session
|
||||
.stream(
|
||||
window_id,
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
|
||||
@@ -483,6 +483,7 @@ async fn process_compacted_history_reinjects_model_switch_message() {
|
||||
}];
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: "previous-regular-model".to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -5500,14 +5500,9 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
|
||||
config.apps_mcp_product_sku = Some("tpp".to_string());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
assert!(mcp_config.apps_enabled);
|
||||
assert_eq!(
|
||||
mcp_config.apps_mcp_path_override.as_deref(),
|
||||
Some("/custom/mcp")
|
||||
);
|
||||
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
|
||||
|
||||
let _ = config.features.disable(Feature::Apps);
|
||||
@@ -8862,84 +8857,6 @@ allow_login_shell = false
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[features.apps_mcp_path_override]
|
||||
path = "/custom/mcp"
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.apps_mcp_path_override.as_deref(),
|
||||
Some("/custom/mcp")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[features]
|
||||
apps_mcp_path_override = true
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
|
||||
assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let toml = r#"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[features.apps_mcp_path_override]
|
||||
enabled = true
|
||||
path = "/custom/mcp"
|
||||
"#;
|
||||
let cfg: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.apps_mcp_path_override.as_deref(),
|
||||
Some("/custom/mcp")
|
||||
);
|
||||
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -9914,10 +9831,7 @@ enabled = true
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
|
||||
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
|
||||
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
|
||||
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
|
||||
assert_eq!(config.multi_agent_v2, MultiAgentV2Config::default());
|
||||
assert_eq!(
|
||||
(
|
||||
config.agent_max_threads,
|
||||
@@ -9925,36 +9839,32 @@ enabled = true
|
||||
),
|
||||
(None, Some(3))
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() {
|
||||
let config_toml = toml::from_str(
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 17
|
||||
"#,
|
||||
)
|
||||
.expect("multi-agent v2 config should parse");
|
||||
|
||||
let config = resolve_multi_agent_v2_config(&config_toml);
|
||||
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
|
||||
assert!(
|
||||
[
|
||||
config.root_agent_usage_hint_text,
|
||||
config.subagent_usage_hint_text,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|hint| hint.is_some_and(|hint| hint.ends_with(concurrency_guidance)))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -58,7 +58,6 @@ use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_plugins::PluginsConfigInput;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::AppsMcpPathOverrideConfigToml;
|
||||
use codex_features::CodeModeConfigToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureConfigSource;
|
||||
@@ -234,6 +233,13 @@ Payload:
|
||||
```
|
||||
You may also see them addressed as to=/root/..., which indicates your identity is /root/...
|
||||
"#;
|
||||
|
||||
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
|
||||
format!(
|
||||
"{usage_hint_text}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0;
|
||||
pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 =
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
|
||||
@@ -931,9 +937,6 @@ pub struct Config {
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
|
||||
/// Optional path override for the host-owned apps MCP server.
|
||||
pub apps_mcp_path_override: Option<String>,
|
||||
|
||||
/// Optional product SKU forwarded to the host-owned apps MCP server.
|
||||
pub apps_mcp_product_sku: Option<String>,
|
||||
|
||||
@@ -1059,22 +1062,23 @@ pub struct MultiAgentV2Config {
|
||||
pub non_code_mode_only: bool,
|
||||
}
|
||||
|
||||
impl Default for MultiAgentV2Config {
|
||||
fn default() -> Self {
|
||||
impl MultiAgentV2Config {
|
||||
fn defaults_for_max_concurrency(max_concurrent_threads_per_session: usize) -> Self {
|
||||
Self {
|
||||
max_concurrent_threads_per_session:
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
|
||||
max_concurrent_threads_per_session,
|
||||
min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS,
|
||||
max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS,
|
||||
default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS,
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
subagent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: true,
|
||||
non_code_mode_only: true,
|
||||
@@ -1082,6 +1086,14 @@ impl Default for MultiAgentV2Config {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MultiAgentV2Config {
|
||||
fn default() -> Self {
|
||||
Self::defaults_for_max_concurrency(
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TerminalResizeReflowMaxRows {
|
||||
/// Use the runtime terminal detector to choose a scrollback-sized cap.
|
||||
@@ -1415,7 +1427,6 @@ impl Config {
|
||||
|
||||
McpConfig {
|
||||
chatgpt_base_url: self.chatgpt_base_url.clone(),
|
||||
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
|
||||
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
|
||||
codex_home: self.codex_home.to_path_buf(),
|
||||
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
|
||||
@@ -1428,7 +1439,6 @@ impl Config {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: self.features.use_legacy_landlock(),
|
||||
apps_enabled: self.features.enabled(Feature::Apps),
|
||||
legacy_apps_mcp_loader_enabled: true,
|
||||
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
|
||||
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
|
||||
ElicitationCapability {
|
||||
@@ -2319,11 +2329,11 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
|
||||
|
||||
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
|
||||
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
|
||||
let default = MultiAgentV2Config::default();
|
||||
|
||||
let max_concurrent_threads_per_session = base
|
||||
.and_then(|config| config.max_concurrent_threads_per_session)
|
||||
.unwrap_or(default.max_concurrent_threads_per_session);
|
||||
.unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION);
|
||||
let default =
|
||||
MultiAgentV2Config::defaults_for_max_concurrency(max_concurrent_threads_per_session);
|
||||
let min_wait_timeout_ms = base
|
||||
.and_then(|config| config.min_wait_timeout_ms)
|
||||
.unwrap_or(default.min_wait_timeout_ms);
|
||||
@@ -2413,15 +2423,6 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
|
||||
}
|
||||
}
|
||||
|
||||
fn apps_mcp_path_override_toml_config(
|
||||
features: Option<&FeaturesToml>,
|
||||
) -> Option<&AppsMcpPathOverrideConfigToml> {
|
||||
match features?.apps_mcp_path_override.as_ref()? {
|
||||
FeatureToml::Enabled(_) => None,
|
||||
FeatureToml::Config(config) => Some(config),
|
||||
}
|
||||
}
|
||||
|
||||
fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> {
|
||||
match features?.network_proxy.as_ref()? {
|
||||
FeatureToml::Enabled(_) => None,
|
||||
@@ -3043,14 +3044,6 @@ impl Config {
|
||||
resolve_experimental_request_user_input_enabled(&cfg);
|
||||
let code_mode = resolve_code_mode_config(&cfg);
|
||||
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
|
||||
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
|
||||
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
|
||||
base.and_then(|config| config.path.as_ref())
|
||||
.cloned()
|
||||
.or_else(|| Some("/ps/mcp".to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
|
||||
|
||||
let agent_roles =
|
||||
@@ -3553,7 +3546,6 @@ impl Config {
|
||||
chatgpt_base_url: cfg
|
||||
.chatgpt_base_url
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
apps_mcp_path_override,
|
||||
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
|
||||
realtime_audio: cfg
|
||||
.audio
|
||||
|
||||
@@ -125,6 +125,9 @@ fn config_lock_for_comparison(
|
||||
) -> ConfigLockfileToml {
|
||||
let mut lockfile = lockfile.clone();
|
||||
clear_config_lock_debug_controls(&mut lockfile.config);
|
||||
if let Some(features) = lockfile.config.features.as_mut() {
|
||||
features.clear_removed_compatibility_entries();
|
||||
}
|
||||
if options.allow_codex_version_mismatch {
|
||||
lockfile.codex_version.clear();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -285,13 +286,15 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mcp_connection_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries,
|
||||
&config.permissions.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
cancel_token.clone(),
|
||||
PermissionProfile::default(),
|
||||
// Connector discovery is threadless. Use an actually configured env if
|
||||
// one exists, but do not reintroduce the old hidden-local fallback.
|
||||
|
||||
@@ -5,12 +5,23 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
/// Model-context fragment describing the skills available to Codex.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AvailableSkillsInstructions {
|
||||
pub struct AvailableSkillsInstructions {
|
||||
skill_root_lines: Vec<String>,
|
||||
skill_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl AvailableSkillsInstructions {
|
||||
/// Creates a skills context fragment from pre-rendered catalog lines.
|
||||
pub fn from_skill_lines(skill_lines: Vec<String>) -> Self {
|
||||
Self {
|
||||
skill_root_lines: Vec::new(),
|
||||
skill_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
fn from(available_skills: AvailableSkills) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -178,6 +178,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: "gpt-5".to_string(),
|
||||
comp_hash: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
multi_agent_version: None,
|
||||
|
||||
@@ -23,6 +23,7 @@ mod realtime_end_instructions;
|
||||
mod realtime_start_instructions;
|
||||
mod realtime_start_with_instructions;
|
||||
mod subagent_notification;
|
||||
mod token_budget_context;
|
||||
mod turn_aborted;
|
||||
mod user_instructions;
|
||||
mod user_shell_command;
|
||||
@@ -30,7 +31,7 @@ mod user_shell_command;
|
||||
pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
|
||||
pub(crate) use apps_instructions::AppsInstructions;
|
||||
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
|
||||
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
|
||||
pub use available_skills_instructions::AvailableSkillsInstructions;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
|
||||
pub use codex_context_fragments::ContextualUserFragment;
|
||||
@@ -60,6 +61,8 @@ pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
|
||||
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
|
||||
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
|
||||
pub(crate) use subagent_notification::SubagentNotification;
|
||||
pub(crate) use token_budget_context::TokenBudgetContext;
|
||||
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
|
||||
pub(crate) use turn_aborted::TurnAborted;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
pub(crate) use user_shell_command::UserShellCommand;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TokenBudgetContext {
|
||||
window_id: u64,
|
||||
tokens_left: i64,
|
||||
}
|
||||
|
||||
impl TokenBudgetContext {
|
||||
pub(crate) fn new(window_id: u64, tokens_left: i64) -> Self {
|
||||
Self {
|
||||
window_id,
|
||||
tokens_left,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for TokenBudgetContext {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<token_budget>\n", "\n</token_budget>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
let window_id = self.window_id;
|
||||
let tokens_left = self.tokens_left;
|
||||
format!(
|
||||
"Current context window {window_id}.\nYou have {tokens_left} tokens left in this context window."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TokenBudgetRemainingContext {
|
||||
tokens_left: Option<i64>,
|
||||
}
|
||||
|
||||
impl TokenBudgetRemainingContext {
|
||||
pub(crate) fn new(tokens_left: i64) -> Self {
|
||||
Self {
|
||||
tokens_left: Some(tokens_left),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unknown() -> Self {
|
||||
Self { tokens_left: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for TokenBudgetRemainingContext {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<token_budget>\n", "\n</token_budget>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
match self.tokens_left {
|
||||
Some(tokens_left) => {
|
||||
format!("You have {tokens_left} tokens left in this context window.")
|
||||
}
|
||||
None => "You have unknown tokens left in this context window.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ fn reference_context_item() -> TurnContextItem {
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: "gpt-test".to_string(),
|
||||
comp_hash: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
multi_agent_version: None,
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::models::is_local_image_close_tag_text;
|
||||
use codex_protocol::models::is_local_image_open_tag_text;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
@@ -29,7 +30,9 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
|
||||
"<model_switch>",
|
||||
COLLABORATION_MODE_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
SKILLS_INSTRUCTIONS_OPEN_TAG,
|
||||
"<personality_spec>",
|
||||
"<token_budget>",
|
||||
];
|
||||
|
||||
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::has_non_contextual_dev_message_content;
|
||||
use super::is_contextual_dev_message_content;
|
||||
use super::parse_turn_item;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::InternalContextSource;
|
||||
@@ -13,9 +15,30 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn recognizes_skills_instructions_as_contextual_developer_content() {
|
||||
assert!(is_contextual_dev_message_content(&[
|
||||
ContentItem::InputText {
|
||||
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n## Skills"),
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_token_budget_as_contextual_developer_content() {
|
||||
let content = vec![ContentItem::InputText {
|
||||
text: "<token_budget>\nYou have 710 tokens left in this context window.\n</token_budget>"
|
||||
.to_string(),
|
||||
}];
|
||||
|
||||
assert!(is_contextual_dev_message_content(&content));
|
||||
assert!(!has_non_contextual_dev_message_content(&content));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_message_with_text_and_two_images() {
|
||||
let img1 = "https://example.com/one.png".to_string();
|
||||
|
||||
@@ -168,7 +168,7 @@ use prompt::render_guardian_transcript_entries;
|
||||
#[cfg(test)]
|
||||
use review::GuardianReviewOutcome;
|
||||
#[cfg(test)]
|
||||
use review::run_guardian_review_session as run_guardian_review_session_for_test;
|
||||
use review::run_guardian_review_session_with_retry as run_guardian_review_session_for_test;
|
||||
#[cfg(test)]
|
||||
use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_analytics::GuardianReviewTrackContext;
|
||||
use codex_analytics::GuardianReviewedAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
|
||||
use codex_protocol::protocol::GuardianAssessmentEvent;
|
||||
@@ -19,11 +20,14 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep_until;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use crate::util::backoff;
|
||||
|
||||
use super::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
|
||||
use super::GUARDIAN_REVIEW_TIMEOUT;
|
||||
@@ -58,6 +62,8 @@ const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
|
||||
"You may retry once, or ask the user for guidance or explicit approval.",
|
||||
);
|
||||
|
||||
const GUARDIAN_REVIEW_MAX_ATTEMPTS: i64 = 3;
|
||||
|
||||
pub(crate) fn new_guardian_review_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
@@ -95,9 +101,16 @@ pub(super) enum GuardianReviewOutcome {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum GuardianReviewError {
|
||||
PromptBuild { message: String },
|
||||
Session { message: String },
|
||||
Parse { message: String },
|
||||
PromptBuild {
|
||||
message: String,
|
||||
},
|
||||
Session {
|
||||
message: String,
|
||||
error_info: Option<CodexErrorInfo>,
|
||||
},
|
||||
Parse {
|
||||
message: String,
|
||||
},
|
||||
Timeout,
|
||||
Cancelled,
|
||||
}
|
||||
@@ -112,6 +125,14 @@ impl GuardianReviewError {
|
||||
fn session(err: anyhow::Error) -> Self {
|
||||
Self::Session {
|
||||
message: err.to_string(),
|
||||
error_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_with_error_info(err: anyhow::Error, error_info: CodexErrorInfo) -> Self {
|
||||
Self::Session {
|
||||
message: err.to_string(),
|
||||
error_info: Some(error_info),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,13 +356,14 @@ async fn run_guardian_review(
|
||||
|
||||
let schema = guardian_output_schema();
|
||||
let terminal_action = action_summary.clone();
|
||||
let (outcome, analytics_result) = Box::pin(run_guardian_review_session(
|
||||
let (outcome, analytics_result) = Box::pin(run_guardian_review_session_with_retry(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
request,
|
||||
retry_reason.clone(),
|
||||
schema,
|
||||
external_cancel,
|
||||
GUARDIAN_REVIEW_MAX_ATTEMPTS,
|
||||
))
|
||||
.await;
|
||||
|
||||
@@ -464,7 +486,7 @@ async fn run_guardian_review(
|
||||
| GuardianReviewError::Parse { .. } => {
|
||||
let message = match &error {
|
||||
GuardianReviewError::PromptBuild { message }
|
||||
| GuardianReviewError::Session { message }
|
||||
| GuardianReviewError::Session { message, .. }
|
||||
| GuardianReviewError::Parse { message } => message,
|
||||
GuardianReviewError::Timeout | GuardianReviewError::Cancelled => {
|
||||
"guardian review failed"
|
||||
@@ -657,13 +679,14 @@ pub(crate) fn spawn_approval_request_review(
|
||||
/// context. It may still reuse the parent's managed-network allowlist for
|
||||
/// read-only checks, but it intentionally runs without inherited exec-policy
|
||||
/// rules.
|
||||
pub(super) async fn run_guardian_review_session(
|
||||
async fn run_guardian_review_session_before_deadline(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
request: GuardianApprovalRequest,
|
||||
retry_reason: Option<String>,
|
||||
schema: serde_json::Value,
|
||||
external_cancel: Option<CancellationToken>,
|
||||
deadline: Instant,
|
||||
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
|
||||
let network_proxy = session.services.network_proxy.load_full();
|
||||
let live_network_config = match network_proxy.as_ref() {
|
||||
@@ -753,6 +776,7 @@ pub(super) async fn run_guardian_review_session(
|
||||
reasoning_summary: turn.reasoning_summary,
|
||||
personality: turn.personality,
|
||||
external_cancel,
|
||||
deadline,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -787,10 +811,16 @@ pub(super) async fn run_guardian_review_session(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)),
|
||||
session_analytics_result,
|
||||
),
|
||||
GuardianReviewSessionOutcome::SessionFailed(err) => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session(err)),
|
||||
session_analytics_result,
|
||||
),
|
||||
GuardianReviewSessionOutcome::SessionFailed { error, error_info } => {
|
||||
let error = match error_info {
|
||||
Some(error_info) => GuardianReviewError::session_with_error_info(error, error_info),
|
||||
None => GuardianReviewError::session(error),
|
||||
};
|
||||
(
|
||||
GuardianReviewOutcome::Error(error),
|
||||
session_analytics_result,
|
||||
)
|
||||
}
|
||||
GuardianReviewSessionOutcome::TimedOut => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
|
||||
session_analytics_result,
|
||||
@@ -802,9 +832,85 @@ pub(super) async fn run_guardian_review_session(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_guardian_review_session_with_retry(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
request: GuardianApprovalRequest,
|
||||
retry_reason: Option<String>,
|
||||
schema: serde_json::Value,
|
||||
external_cancel: Option<CancellationToken>,
|
||||
max_attempts: i64,
|
||||
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
|
||||
assert!(max_attempts > 0, "guardian review must run at least once");
|
||||
let deadline = Instant::now() + GUARDIAN_REVIEW_TIMEOUT;
|
||||
let mut attempt_count = 1;
|
||||
loop {
|
||||
let (outcome, mut analytics_result) = run_guardian_review_session_before_deadline(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
request.clone(),
|
||||
retry_reason.clone(),
|
||||
schema.clone(),
|
||||
external_cancel.clone(),
|
||||
deadline,
|
||||
)
|
||||
.await;
|
||||
analytics_result.attempt_count = attempt_count;
|
||||
if attempt_count >= max_attempts || !should_retry_guardian_review(&outcome) {
|
||||
return (outcome, analytics_result);
|
||||
}
|
||||
if let Some(error) =
|
||||
wait_before_guardian_retry(attempt_count, deadline, external_cancel.as_ref()).await
|
||||
{
|
||||
return (GuardianReviewOutcome::Error(error), analytics_result);
|
||||
}
|
||||
attempt_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_before_guardian_retry(
|
||||
attempt_count: i64,
|
||||
deadline: Instant,
|
||||
external_cancel: Option<&CancellationToken>,
|
||||
) -> Option<GuardianReviewError> {
|
||||
let retry_delay = backoff(attempt_count as u64);
|
||||
let retry_at = (Instant::now() + retry_delay).min(deadline);
|
||||
tokio::select! {
|
||||
_ = sleep_until(retry_at) => {
|
||||
(Instant::now() >= deadline).then_some(GuardianReviewError::Timeout)
|
||||
}
|
||||
_ = async {
|
||||
if let Some(cancel_token) = external_cancel {
|
||||
cancel_token.cancelled().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => Some(GuardianReviewError::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
|
||||
matches!(
|
||||
outcome,
|
||||
GuardianReviewOutcome::Error(
|
||||
GuardianReviewError::Session {
|
||||
error_info: Some(
|
||||
CodexErrorInfo::ServerOverloaded
|
||||
| CodexErrorInfo::HttpConnectionFailed { .. }
|
||||
| CodexErrorInfo::ResponseStreamConnectionFailed { .. }
|
||||
| CodexErrorInfo::InternalServerError
|
||||
| CodexErrorInfo::ResponseStreamDisconnected { .. }
|
||||
),
|
||||
..
|
||||
} | GuardianReviewError::Parse { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn guardian_review_error_reason_distinguishes_error_kinds() {
|
||||
@@ -812,6 +918,10 @@ mod review_tests {
|
||||
let prompt_error = GuardianReviewError::prompt_build(anyhow::anyhow!("bad prompt/config"));
|
||||
let session_error =
|
||||
GuardianReviewError::session(anyhow::anyhow!("guardian runtime failed"));
|
||||
let structured_session_error = GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("temporary guardian failure"),
|
||||
CodexErrorInfo::ServerOverloaded,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
parse_error.failure_reason(),
|
||||
@@ -825,5 +935,109 @@ mod review_tests {
|
||||
session_error.failure_reason(),
|
||||
GuardianReviewFailureReason::SessionError
|
||||
));
|
||||
assert!(matches!(
|
||||
structured_session_error.failure_reason(),
|
||||
GuardianReviewFailureReason::SessionError
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
|
||||
let assessment = GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale: "deny".to_string(),
|
||||
};
|
||||
let transient_error_info = [
|
||||
CodexErrorInfo::ServerOverloaded,
|
||||
CodexErrorInfo::HttpConnectionFailed {
|
||||
http_status_code: Some(502),
|
||||
},
|
||||
CodexErrorInfo::ResponseStreamConnectionFailed {
|
||||
http_status_code: Some(503),
|
||||
},
|
||||
CodexErrorInfo::InternalServerError,
|
||||
CodexErrorInfo::ResponseStreamDisconnected {
|
||||
http_status_code: None,
|
||||
},
|
||||
];
|
||||
let mut outcomes = transient_error_info
|
||||
.into_iter()
|
||||
.map(|error_info| {
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("transient session"),
|
||||
error_info,
|
||||
)),
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
outcomes.extend([
|
||||
(GuardianReviewOutcome::Completed(assessment), false),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(anyhow::anyhow!(
|
||||
"prompt"
|
||||
))),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session(anyhow::anyhow!(
|
||||
"session"
|
||||
))),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("bad request"),
|
||||
CodexErrorInfo::BadRequest,
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::parse(anyhow::anyhow!("parse"))),
|
||||
true,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
|
||||
for (outcome, expected) in outcomes {
|
||||
assert_eq!(should_retry_guardian_review(&outcome), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_retry_wait_honors_cancellation() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
|
||||
let error = wait_before_guardian_retry(
|
||||
/*attempt_count*/ 1,
|
||||
Instant::now() + Duration::from_secs(/*secs*/ 1),
|
||||
Some(&cancel_token),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(error, Some(GuardianReviewError::Cancelled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_retry_wait_honors_deadline() {
|
||||
let error = wait_before_guardian_retry(
|
||||
/*attempt_count*/ 1,
|
||||
Instant::now(),
|
||||
/*external_cancel*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(error, Some(GuardianReviewError::Timeout)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
@@ -46,7 +48,6 @@ use codex_features::Feature;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::GUARDIAN_REVIEW_TIMEOUT;
|
||||
use super::GUARDIAN_REVIEWER_NAME;
|
||||
use super::GuardianApprovalRequest;
|
||||
use super::prompt::GuardianPromptMode;
|
||||
@@ -60,7 +61,10 @@ const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub(crate) enum GuardianReviewSessionOutcome {
|
||||
Completed(anyhow::Result<Option<String>>),
|
||||
PromptBuildFailed(anyhow::Error),
|
||||
SessionFailed(anyhow::Error),
|
||||
SessionFailed {
|
||||
error: anyhow::Error,
|
||||
error_info: Option<CodexErrorInfo>,
|
||||
},
|
||||
TimedOut,
|
||||
Aborted,
|
||||
}
|
||||
@@ -77,6 +81,7 @@ pub(crate) struct GuardianReviewSessionParams {
|
||||
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
||||
pub(crate) personality: Option<Personality>,
|
||||
pub(crate) external_cancel: Option<CancellationToken>,
|
||||
pub(crate) deadline: tokio::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -312,7 +317,7 @@ impl GuardianReviewSessionManager {
|
||||
&self,
|
||||
params: GuardianReviewSessionParams,
|
||||
) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) {
|
||||
let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT;
|
||||
let deadline = params.deadline;
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config);
|
||||
let mut stale_trunk_to_shutdown = None;
|
||||
let mut spawned_trunk = false;
|
||||
@@ -364,7 +369,9 @@ impl GuardianReviewSessionManager {
|
||||
|
||||
state.trunk.as_ref().cloned()
|
||||
}
|
||||
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
|
||||
Err(outcome) => {
|
||||
return (outcome, GuardianReviewAnalyticsResult::without_session());
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(review_session) = stale_trunk_to_shutdown {
|
||||
@@ -560,7 +567,9 @@ impl GuardianReviewSessionManager {
|
||||
GuardianReviewAnalyticsResult::without_session(),
|
||||
);
|
||||
}
|
||||
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
|
||||
Err(outcome) => {
|
||||
return (outcome, GuardianReviewAnalyticsResult::without_session());
|
||||
}
|
||||
};
|
||||
self.register_active_ephemeral(Arc::clone(&review_session))
|
||||
.await;
|
||||
@@ -764,7 +773,10 @@ async fn run_review_on_session(
|
||||
Ok(Ok(child_turn_id)) => child_turn_id,
|
||||
Ok(Err(err)) => {
|
||||
return (
|
||||
GuardianReviewSessionOutcome::SessionFailed(err.into()),
|
||||
GuardianReviewSessionOutcome::SessionFailed {
|
||||
error: err.into(),
|
||||
error_info: None,
|
||||
},
|
||||
false,
|
||||
analytics_result,
|
||||
);
|
||||
@@ -825,7 +837,7 @@ async fn wait_for_guardian_review(
|
||||
) -> (GuardianReviewSessionOutcome, bool, bool) {
|
||||
let timeout = tokio::time::sleep_until(deadline);
|
||||
tokio::pin!(timeout);
|
||||
let mut last_error_message: Option<String> = None;
|
||||
let mut last_error: Option<ErrorEvent> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -862,10 +874,13 @@ async fn wait_for_guardian_review(
|
||||
.time_to_first_token_ms
|
||||
.and_then(|ms| u64::try_from(ms).ok());
|
||||
if turn_complete.last_agent_message.is_none()
|
||||
&& let Some(error_message) = last_error_message
|
||||
&& let Some(error) = last_error
|
||||
{
|
||||
return (
|
||||
GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))),
|
||||
GuardianReviewSessionOutcome::SessionFailed {
|
||||
error: anyhow!(error.message),
|
||||
error_info: error.codex_error_info,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
@@ -877,7 +892,7 @@ async fn wait_for_guardian_review(
|
||||
);
|
||||
}
|
||||
EventMsg::Error(error) => {
|
||||
last_error_message = Some(error.message);
|
||||
last_error = Some(error);
|
||||
}
|
||||
EventMsg::TurnAborted(_) => {
|
||||
return (GuardianReviewSessionOutcome::Aborted, true, false);
|
||||
@@ -920,6 +935,8 @@ pub(crate) fn build_guardian_review_session_config(
|
||||
let mut guardian_config = parent_config.clone();
|
||||
guardian_config.model = Some(active_model.to_string());
|
||||
guardian_config.model_reasoning_effort = reasoning_effort;
|
||||
guardian_config.model_provider.request_max_retries = Some(1);
|
||||
guardian_config.model_provider.stream_max_retries = Some(1);
|
||||
guardian_config.include_skill_instructions = false;
|
||||
guardian_config.base_instructions = Some(
|
||||
parent_config
|
||||
@@ -1148,6 +1165,7 @@ mod tests {
|
||||
reasoning_summary,
|
||||
personality,
|
||||
external_cancel: None,
|
||||
deadline: tokio::time::Instant::now() + Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1473,6 +1491,29 @@ mod tests {
|
||||
assert!(keep_review_session);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_review_removes_trunk_when_event_stream_is_broken() {
|
||||
let (mut review_session, tx_event, _rx_sub) = test_review_session().await;
|
||||
let params = test_review_params().await;
|
||||
review_session.reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config);
|
||||
let manager = GuardianReviewSessionManager {
|
||||
state: Arc::new(Mutex::new(GuardianReviewSessionState {
|
||||
trunk: Some(Arc::new(review_session)),
|
||||
ephemeral_reviews: Vec::new(),
|
||||
})),
|
||||
};
|
||||
drop(tx_event);
|
||||
|
||||
let (outcome, _) = manager.run_review(params).await;
|
||||
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
GuardianReviewSessionOutcome::Completed(Err(_))
|
||||
));
|
||||
assert!(manager.state.lock().await.trunk.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_guardian_review_ignores_prior_turn_completion() {
|
||||
let (review_session, tx_event, _rx_sub) = test_review_session().await;
|
||||
@@ -1545,6 +1586,47 @@ mod tests {
|
||||
assert!(capture_token_usage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_guardian_review_preserves_structured_session_error() {
|
||||
let (review_session, tx_event, _rx_sub) = test_review_session().await;
|
||||
tx_event
|
||||
.send(Event {
|
||||
id: "current-turn".to_string(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "temporary failure".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("queue guardian error");
|
||||
tx_event
|
||||
.send(turn_complete_event(
|
||||
"current-turn",
|
||||
/*last_agent_message*/ None,
|
||||
Some(42),
|
||||
))
|
||||
.await
|
||||
.expect("queue current turn completion");
|
||||
|
||||
let mut analytics_result = GuardianReviewAnalyticsResult::without_session();
|
||||
let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review(
|
||||
&review_session,
|
||||
"current-turn",
|
||||
tokio::time::Instant::now() + Duration::from_secs(1),
|
||||
/*external_cancel*/ None,
|
||||
&mut analytics_result,
|
||||
)
|
||||
.await;
|
||||
|
||||
let GuardianReviewSessionOutcome::SessionFailed { error, error_info } = outcome else {
|
||||
panic!("expected structured session failure");
|
||||
};
|
||||
assert_eq!(error.to_string(), "temporary failure");
|
||||
assert_eq!(error_info, Some(CodexErrorInfo::ServerOverloaded));
|
||||
assert!(keep_review_session);
|
||||
assert!(capture_token_usage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_guardian_review_ignores_prior_turn_aborts() {
|
||||
let (review_session, tx_event, _rx_sub) = test_review_session().await;
|
||||
|
||||
@@ -55,10 +55,11 @@ use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_response_once;
|
||||
use core_test_support::responses::mount_response_sequence;
|
||||
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::sse_failed;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
@@ -172,6 +173,51 @@ async fn guardian_test_session_and_turn(
|
||||
guardian_test_session_and_turn_with_base_url(server.uri().as_str()).await
|
||||
}
|
||||
|
||||
async fn guardian_test_session_turn_and_rx(
|
||||
server: &wiremock::MockServer,
|
||||
) -> (
|
||||
Arc<Session>,
|
||||
Arc<TurnContext>,
|
||||
async_channel::Receiver<Event>,
|
||||
) {
|
||||
let (mut session, mut turn, rx) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut session)
|
||||
.expect("session should be uniquely owned")
|
||||
.thread_id = fixed_guardian_parent_session_id();
|
||||
let mut config = (*turn.config).clone();
|
||||
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||
config.user_instructions = None;
|
||||
let config = Arc::new(config);
|
||||
let models_manager = test_support::models_manager_with_provider(
|
||||
config.codex_home.to_path_buf(),
|
||||
Arc::clone(&session.services.auth_manager),
|
||||
config.model_provider.clone(),
|
||||
);
|
||||
Arc::get_mut(&mut session)
|
||||
.expect("session should be uniquely owned")
|
||||
.services
|
||||
.models_manager = models_manager;
|
||||
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
|
||||
turn_mut.config = Arc::clone(&config);
|
||||
turn_mut.provider =
|
||||
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
|
||||
turn_mut.user_instructions = None;
|
||||
|
||||
(session, turn, rx)
|
||||
}
|
||||
|
||||
fn guardian_shell_request(id: &str) -> GuardianApprovalRequest {
|
||||
GuardianApprovalRequest::Shell {
|
||||
id: id.to_string(),
|
||||
command: vec!["git".to_string(), "push".to_string()],
|
||||
cwd: test_path_buf("/repo/codex-rs/core").abs(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn guardian_test_session_and_turn_with_base_url(
|
||||
base_url: &str,
|
||||
) -> (Arc<Session>, Arc<TurnContext>) {
|
||||
@@ -1343,6 +1389,7 @@ async fn guardian_request_model_for_auto_review_override(
|
||||
Some("Sandbox denied outbound git push to github.com.".to_string()),
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
let (GuardianReviewOutcome::Completed(_), _) = outcome else {
|
||||
@@ -1454,6 +1501,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
|
||||
Some("Sandbox denied outbound git push to github.com.".to_string()),
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
let (GuardianReviewOutcome::Completed(assessment), metadata) = outcome else {
|
||||
@@ -1632,6 +1680,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
|
||||
Some("First retry reason".to_string()),
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
@@ -1676,6 +1725,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
|
||||
Some("Second retry reason".to_string()),
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
session
|
||||
@@ -1716,6 +1766,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
|
||||
Some("Third retry reason".to_string()),
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1907,6 +1958,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
|
||||
/*retry_reason*/ None,
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
|
||||
@@ -1949,6 +2001,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
|
||||
/*retry_reason*/ None,
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 1,
|
||||
)
|
||||
.await;
|
||||
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
|
||||
@@ -1978,15 +2031,17 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
let server = start_mock_server().await;
|
||||
let error_message =
|
||||
"Item 'rs_test' of type 'reasoning' was provided without its required following item.";
|
||||
let _request_log = mount_response_once(
|
||||
let request_log = mount_response_sequence(
|
||||
&server,
|
||||
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": error_message,
|
||||
"type": "invalid_request_error",
|
||||
"param": "input"
|
||||
}
|
||||
})),
|
||||
vec![
|
||||
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": error_message,
|
||||
"type": "invalid_request_error",
|
||||
"param": "input"
|
||||
}
|
||||
})),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2030,6 +2085,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(request_log.requests().len(), 1);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut denial_rationales = Vec::new();
|
||||
@@ -2080,13 +2136,255 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_retries_transient_session_failure_then_approves() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "retry succeeded",
|
||||
})
|
||||
.to_string();
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse_failed(
|
||||
"resp-session-failure",
|
||||
"server_is_overloaded",
|
||||
"temporary reviewer overload",
|
||||
),
|
||||
sse(vec![
|
||||
ev_response_created("resp-approved"),
|
||||
ev_assistant_message("msg-approved", &approval),
|
||||
ev_completed("resp-approved"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let (outcome, metadata) = run_guardian_review_session_for_test(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
guardian_shell_request("shell-session-retry"),
|
||||
/*retry_reason*/ None,
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 3,
|
||||
)
|
||||
.await;
|
||||
|
||||
let GuardianReviewOutcome::Completed(assessment) = outcome else {
|
||||
panic!("expected guardian assessment");
|
||||
};
|
||||
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
||||
assert_eq!(assessment.rationale, "retry succeeded");
|
||||
assert_eq!(metadata.attempt_count, 2);
|
||||
assert!(matches!(
|
||||
metadata.guardian_session_kind,
|
||||
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
||||
));
|
||||
assert_eq!(request_log.requests().len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![sse(vec![
|
||||
ev_response_created("resp-missing-assessment"),
|
||||
ev_completed("resp-missing-assessment"),
|
||||
])],
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-missing-assessment".to_string(),
|
||||
guardian_shell_request("shell-missing-assessment"),
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(request_log.requests().len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval = serde_json::json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "retry succeeded",
|
||||
})
|
||||
.to_string();
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-parse-failure-1"),
|
||||
ev_assistant_message("msg-parse-failure-1", "not valid guardian json"),
|
||||
ev_completed("resp-parse-failure-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-parse-failure-2"),
|
||||
ev_assistant_message("msg-parse-failure-2", "still not valid guardian json"),
|
||||
ev_completed("resp-parse-failure-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-approved"),
|
||||
ev_assistant_message("msg-approved", &approval),
|
||||
ev_completed("resp-approved"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let (outcome, metadata) = run_guardian_review_session_for_test(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
guardian_shell_request("shell-parse-retry"),
|
||||
/*retry_reason*/ None,
|
||||
guardian_output_schema(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 3,
|
||||
)
|
||||
.await;
|
||||
|
||||
let GuardianReviewOutcome::Completed(assessment) = outcome else {
|
||||
panic!("expected guardian assessment");
|
||||
};
|
||||
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
||||
assert_eq!(assessment.rationale, "retry succeeded");
|
||||
assert_eq!(metadata.attempt_count, 3);
|
||||
assert!(matches!(
|
||||
metadata.guardian_session_kind,
|
||||
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
||||
));
|
||||
assert_eq!(request_log.requests().len(), 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-parse-failure-1"),
|
||||
ev_assistant_message("msg-parse-failure-1", "invalid one"),
|
||||
ev_completed("resp-parse-failure-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-parse-failure-2"),
|
||||
ev_assistant_message("msg-parse-failure-2", "invalid two"),
|
||||
ev_completed("resp-parse-failure-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-parse-failure-3"),
|
||||
ev_assistant_message("msg-parse-failure-3", "invalid three"),
|
||||
ev_completed("resp-parse-failure-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let (session, turn, rx) = guardian_test_session_turn_and_rx(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-exhausted-retry".to_string(),
|
||||
guardian_shell_request("shell-exhausted-retry"),
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(request_log.requests().len(), 3);
|
||||
let mut statuses = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let EventMsg::GuardianAssessment(event) = event.msg {
|
||||
statuses.push(event.status);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
statuses,
|
||||
vec![
|
||||
GuardianAssessmentStatus::InProgress,
|
||||
GuardianAssessmentStatus::Denied,
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let denial = serde_json::json!({
|
||||
"risk_level": "high",
|
||||
"user_authorization": "unknown",
|
||||
"outcome": "deny",
|
||||
"rationale": "unsafe",
|
||||
})
|
||||
.to_string();
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![sse(vec![
|
||||
ev_response_created("resp-denied"),
|
||||
ev_assistant_message("msg-denied", &denial),
|
||||
ev_completed("resp-denied"),
|
||||
])],
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
"review-valid-denial".to_string(),
|
||||
guardian_shell_request("shell-valid-denial"),
|
||||
/*retry_reason*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Denied);
|
||||
assert_eq!(request_log.requests().len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()>
|
||||
{
|
||||
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
let handle =
|
||||
std::thread::Builder::new()
|
||||
.name("guardian_parallel_reviews_fork_from_last_committed_trunk_history".to_string())
|
||||
.name("guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history".to_string())
|
||||
.stack_size(TEST_STACK_SIZE_BYTES)
|
||||
.spawn(|| -> anyhow::Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -2141,10 +2439,18 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("resp-guardian-3"),
|
||||
ev_assistant_message("msg-guardian-3", &third_assessment),
|
||||
ev_assistant_message("msg-guardian-3", "not valid guardian json"),
|
||||
ev_completed("resp-guardian-3"),
|
||||
]),
|
||||
}],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![
|
||||
ev_response_created("resp-guardian-4"),
|
||||
ev_assistant_message("msg-guardian-4", &third_assessment),
|
||||
ev_completed("resp-guardian-4"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
|
||||
@@ -2271,20 +2577,28 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
|
||||
.await;
|
||||
assert_eq!(third_decision, ReviewDecision::Approved);
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(requests.len(), 4);
|
||||
let second_request_body = serde_json::from_slice::<serde_json::Value>(&requests[1])?;
|
||||
let third_request_body = serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
||||
let failed_ephemeral_request_body =
|
||||
serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
||||
let retried_ephemeral_request_body =
|
||||
serde_json::from_slice::<serde_json::Value>(&requests[3])?;
|
||||
assert_eq!(
|
||||
second_request_body["prompt_cache_key"],
|
||||
third_request_body["prompt_cache_key"],
|
||||
failed_ephemeral_request_body["prompt_cache_key"],
|
||||
"forked guardian review should reuse the trunk guardian prompt cache key"
|
||||
);
|
||||
let third_request_body_text = third_request_body.to_string();
|
||||
assert_eq!(
|
||||
failed_ephemeral_request_body["prompt_cache_key"],
|
||||
retried_ephemeral_request_body["prompt_cache_key"],
|
||||
"retried ephemeral review should preserve the guardian prompt cache key"
|
||||
);
|
||||
let third_request_body_text = retried_ephemeral_request_body.to_string();
|
||||
assert!(
|
||||
third_request_body_text.contains("first guardian rationale"),
|
||||
"forked guardian review should include the last committed trunk assessment"
|
||||
);
|
||||
let third_user_message = last_user_message_text_from_body(&third_request_body);
|
||||
let third_user_message = last_user_message_text_from_body(&retried_ephemeral_request_body);
|
||||
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
|
||||
assert!(
|
||||
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
|
||||
@@ -2315,7 +2629,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"guardian_parallel_reviews_fork_from_last_committed_trunk_history thread panicked"
|
||||
"guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history thread panicked"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -2541,6 +2855,10 @@ async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5
|
||||
)
|
||||
.expect("guardian config");
|
||||
|
||||
let mut expected_model_provider =
|
||||
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
|
||||
expected_model_provider.request_max_retries = Some(1);
|
||||
expected_model_provider.stream_max_retries = Some(1);
|
||||
assert_eq!(
|
||||
(
|
||||
guardian_config.model,
|
||||
@@ -2550,7 +2868,7 @@ async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5
|
||||
(
|
||||
Some(AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string()),
|
||||
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
||||
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
||||
expected_model_provider,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_thread_store::ReadThreadParams;
|
||||
use serde_json::Value;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::HookAdditionalContext;
|
||||
@@ -97,6 +98,7 @@ impl From<UserPromptSubmitOutcome> for ContextInjectingHookOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn run_pending_session_start_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -291,6 +293,7 @@ pub(crate) async fn run_post_tool_use_hooks(
|
||||
outcome
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn run_turn_stop_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -382,9 +385,7 @@ pub(crate) async fn run_pre_compact_hooks(
|
||||
let outcome = sess.hooks().run_pre_compact(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
|
||||
if outcome.should_stop {
|
||||
PreCompactHookOutcome::Stopped {
|
||||
reason: outcome.stop_reason,
|
||||
}
|
||||
PreCompactHookOutcome::Stopped
|
||||
} else {
|
||||
PreCompactHookOutcome::Continue
|
||||
}
|
||||
@@ -392,7 +393,7 @@ pub(crate) async fn run_pre_compact_hooks(
|
||||
|
||||
pub(crate) enum PreCompactHookOutcome {
|
||||
Continue,
|
||||
Stopped { reason: Option<String> },
|
||||
Stopped,
|
||||
}
|
||||
|
||||
pub(crate) enum PostCompactHookOutcome {
|
||||
@@ -427,6 +428,7 @@ pub(crate) async fn run_post_compact_hooks(
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn run_legacy_after_agent_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_utils_image::ImageProcessingError;
|
||||
use codex_utils_image::PromptImageMode;
|
||||
use codex_utils_image::PromptImageResizeLimits;
|
||||
use codex_utils_image::load_data_url_for_prompt;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) const IMAGE_PROCESSING_ERROR_PLACEHOLDER: &str =
|
||||
"image content omitted because it could not be processed";
|
||||
const IMAGE_TOO_LARGE_PLACEHOLDER: &str =
|
||||
"image content omitted because it exceeded the supported size limit; use a smaller image";
|
||||
const UNSUPPORTED_LOW_DETAIL_PLACEHOLDER: &str = "image content omitted because detail 'low' is not supported; use 'high', 'original', or 'auto'";
|
||||
|
||||
const HIGH_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits {
|
||||
max_dimension: 2048,
|
||||
max_patches: 2_500,
|
||||
};
|
||||
const ORIGINAL_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits {
|
||||
max_dimension: 6000,
|
||||
max_patches: 10_000,
|
||||
};
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ImagePreparationError {
|
||||
#[error("image detail `low` is not supported")]
|
||||
UnsupportedLowDetail,
|
||||
#[error(transparent)]
|
||||
Processing(#[from] ImageProcessingError),
|
||||
}
|
||||
|
||||
impl ImagePreparationError {
|
||||
fn placeholder(&self) -> &'static str {
|
||||
match self {
|
||||
ImagePreparationError::UnsupportedLowDetail => UNSUPPORTED_LOW_DETAIL_PLACEHOLDER,
|
||||
ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge { .. }) => {
|
||||
IMAGE_TOO_LARGE_PLACEHOLDER
|
||||
}
|
||||
ImagePreparationError::Processing(_) => IMAGE_PROCESSING_ERROR_PLACEHOLDER,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) {
|
||||
for item in items {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => prepare_message_content(content),
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content) = output.content_items_mut() {
|
||||
prepare_tool_output_content(content);
|
||||
}
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_message_content(items: &mut [ContentItem]) {
|
||||
for item in items {
|
||||
if let ContentItem::InputImage { image_url, detail } = item
|
||||
&& is_data_url(image_url)
|
||||
&& let Err(error) = prepare_image(image_url, *detail)
|
||||
{
|
||||
warn!(%error, "failed to prepare message image");
|
||||
*item = ContentItem::InputText {
|
||||
text: error.placeholder().to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) {
|
||||
for item in items {
|
||||
if let FunctionCallOutputContentItem::InputImage { image_url, detail } = item
|
||||
&& is_data_url(image_url)
|
||||
&& let Err(error) = prepare_image(image_url, *detail)
|
||||
{
|
||||
warn!(%error, "failed to prepare tool output image");
|
||||
*item = FunctionCallOutputContentItem::InputText {
|
||||
text: error.placeholder().to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_data_url(image_url: &str) -> bool {
|
||||
image_url
|
||||
.get(.."data:".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
}
|
||||
|
||||
fn prepare_image(
|
||||
image_url: &mut String,
|
||||
detail: Option<ImageDetail>,
|
||||
) -> Result<(), ImagePreparationError> {
|
||||
let limits = match detail {
|
||||
None | Some(ImageDetail::Auto | ImageDetail::High) => HIGH_DETAIL_LIMITS,
|
||||
Some(ImageDetail::Original) => ORIGINAL_DETAIL_LIMITS,
|
||||
Some(ImageDetail::Low) => return Err(ImagePreparationError::UnsupportedLowDetail),
|
||||
};
|
||||
let image = load_data_url_for_prompt(image_url, PromptImageMode::ResizeWithLimits(limits))?;
|
||||
*image_url = image.into_data_url();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_preparation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_utils_image::data_url_from_bytes;
|
||||
use image::DynamicImage;
|
||||
use image::GenericImageView;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn png_data_url(width: u32, height: u32) -> (String, Vec<u8>) {
|
||||
let image = ImageBuffer::from_pixel(width, height, Rgba([10u8, 20, 30, 255]));
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.expect("encode PNG");
|
||||
let bytes = encoded.into_inner();
|
||||
(data_url_from_bytes("image/png", &bytes), bytes)
|
||||
}
|
||||
|
||||
fn decoded_image(image_url: &str) -> (Vec<u8>, DynamicImage) {
|
||||
let (_, payload) = image_url.split_once(',').expect("data URL payload");
|
||||
let bytes = BASE64_STANDARD.decode(payload).expect("decode image URL");
|
||||
let image = image::load_from_memory(&bytes).expect("decode processed image");
|
||||
(bytes, image)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_preserves_small_image_bytes_and_non_data_urls() {
|
||||
let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32);
|
||||
let http_url = "https://example.com/image.png".to_string();
|
||||
let mut items = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputImage {
|
||||
image_url: data_url,
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: http_url.clone(),
|
||||
detail: Some(ImageDetail::Low),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
}];
|
||||
|
||||
prepare_response_items(&mut items);
|
||||
|
||||
let ResponseItem::Message { content, .. } = &items[0] else {
|
||||
panic!("expected message");
|
||||
};
|
||||
let [
|
||||
ContentItem::InputImage { image_url, .. },
|
||||
ContentItem::InputImage {
|
||||
image_url: preserved_http_url,
|
||||
detail: Some(ImageDetail::Low),
|
||||
},
|
||||
] = content.as_slice()
|
||||
else {
|
||||
panic!("expected two images");
|
||||
};
|
||||
assert_eq!(decoded_image(image_url).0, original_bytes);
|
||||
assert_eq!(preserved_http_url, &http_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_policies_apply_the_expected_budgets() {
|
||||
for (detail, input_dimensions, expected_dimensions) in [
|
||||
(Some(ImageDetail::High), (2048, 2048), (1600, 1600)),
|
||||
(Some(ImageDetail::Original), (6401, 100), (6000, 94)),
|
||||
(Some(ImageDetail::Original), (3201, 3201), (3200, 3200)),
|
||||
(Some(ImageDetail::Auto), (2048, 2048), (1600, 1600)),
|
||||
(None, (2048, 2048), (1600, 1600)),
|
||||
] {
|
||||
let (image_url, _) = png_data_url(input_dimensions.0, input_dimensions.1);
|
||||
let mut items = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage { image_url, detail }],
|
||||
phase: None,
|
||||
}];
|
||||
|
||||
prepare_response_items(&mut items);
|
||||
|
||||
let ResponseItem::Message { content, .. } = &items[0] else {
|
||||
panic!("expected message");
|
||||
};
|
||||
let [ContentItem::InputImage { image_url, .. }] = content.as_slice() else {
|
||||
panic!("expected image");
|
||||
};
|
||||
assert_eq!(decoded_image(image_url).1.dimensions(), expected_dimensions);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() {
|
||||
let (valid_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32);
|
||||
let expected_valid_image_url = valid_image_url.clone();
|
||||
let mut items = vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,%%%".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: data_url_from_bytes("image/png", b"not an image"),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: valid_image_url.clone(),
|
||||
detail: Some(ImageDetail::Low),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: valid_image_url,
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
}];
|
||||
|
||||
prepare_response_items(&mut items);
|
||||
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: UNSUPPORTED_LOW_DETAIL_PLACEHOLDER.to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: expected_valid_image_url,
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_errors_use_bounded_actionable_placeholders() {
|
||||
let cases = [
|
||||
(
|
||||
ImagePreparationError::UnsupportedLowDetail,
|
||||
UNSUPPORTED_LOW_DETAIL_PLACEHOLDER,
|
||||
),
|
||||
(
|
||||
ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge {
|
||||
representation: "decoded input",
|
||||
size: 2,
|
||||
max: 1,
|
||||
}),
|
||||
IMAGE_TOO_LARGE_PLACEHOLDER,
|
||||
),
|
||||
(
|
||||
ImagePreparationError::Processing(ImageProcessingError::InvalidDataUrl {
|
||||
reason: "details remain in logs".to_string(),
|
||||
}),
|
||||
IMAGE_PROCESSING_ERROR_PLACEHOLDER,
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected) in cases {
|
||||
assert_eq!(error.placeholder(), expected);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod codex_thread;
|
||||
mod compact_remote;
|
||||
mod compact_remote_v2;
|
||||
mod config_lock;
|
||||
pub use codex_thread::BackgroundTerminalInfo;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadSettingsOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
@@ -41,6 +42,7 @@ mod exec_policy;
|
||||
mod git_info_tests;
|
||||
mod guardian;
|
||||
mod hook_runtime;
|
||||
mod image_preparation;
|
||||
mod installation_id;
|
||||
pub(crate) mod landlock;
|
||||
pub use landlock::spawn_command_under_linux_sandbox;
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::EffectiveMcpServer;
|
||||
use codex_mcp::McpConfig;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::codex_apps_mcp_server_config;
|
||||
use codex_mcp::configured_mcp_servers;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
|
||||
@@ -40,17 +41,36 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the MCP config after applying runtime-only extension overlays.
|
||||
/// Returns the MCP config after applying compatibility built-ins and
|
||||
/// runtime-only extension overlays.
|
||||
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
|
||||
let mut mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
|
||||
let contributions = self.contributions(config).await;
|
||||
if contributions
|
||||
let disabled_server_names = mcp_config
|
||||
.configured_mcp_servers
|
||||
.iter()
|
||||
.any(|contribution| contribution.name() == CODEX_APPS_MCP_SERVER_NAME)
|
||||
{
|
||||
mcp_config.legacy_apps_mcp_loader_enabled = false;
|
||||
.filter(|(_, server)| !server.enabled)
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if mcp_config.apps_enabled {
|
||||
mcp_config.configured_mcp_servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_apps_mcp_server_config(
|
||||
&mcp_config.chatgpt_base_url,
|
||||
mcp_config.apps_mcp_product_sku.as_deref(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
let contributions = self.contributions(config).await;
|
||||
Self::apply_to_configured_servers(&contributions, &mut mcp_config.configured_mcp_servers);
|
||||
for name in disabled_server_names {
|
||||
if let Some(server) = mcp_config.configured_mcp_servers.get_mut(&name) {
|
||||
server.enabled = false;
|
||||
}
|
||||
}
|
||||
mcp_config
|
||||
}
|
||||
|
||||
|
||||
@@ -319,8 +319,7 @@ async fn handle_approved_mcp_tool_call(
|
||||
let server_origin = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.server_origin(&server)
|
||||
.map(str::to_string);
|
||||
|
||||
@@ -606,8 +605,7 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
if !sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.is_host_owned_codex_apps_server(server)
|
||||
{
|
||||
return result;
|
||||
@@ -669,13 +667,9 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
auth_elicitation_completed_result(&plan.auth_failure, result.meta)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "Codex Apps cache refresh reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) {
|
||||
let mcp_tools_result = {
|
||||
let manager = sess.services.mcp_connection_manager.read().await;
|
||||
let manager = sess.services.mcp_connection_manager.load_full();
|
||||
manager.hard_refresh_codex_apps_tools_cache().await
|
||||
};
|
||||
|
||||
@@ -694,10 +688,6 @@ async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP sandbox metadata reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -707,8 +697,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
let supports_sandbox_state_meta = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.server_supports_sandbox_state_meta_capability(server)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -757,8 +746,7 @@ async fn maybe_mark_thread_memory_mode_polluted(
|
||||
let pollutes_memory = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.server_pollutes_memory(server);
|
||||
if !pollutes_memory {
|
||||
return;
|
||||
@@ -1414,17 +1402,13 @@ async fn mcp_tool_approval_decision_from_guardian(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP approval metadata reads through the session-owned manager guard"
|
||||
)]
|
||||
pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> Option<McpToolApprovalMetadata> {
|
||||
let manager = sess.services.mcp_connection_manager.read().await;
|
||||
let manager = sess.services.mcp_connection_manager.load_full();
|
||||
let plugin_id = manager
|
||||
.plugin_id_for_mcp_server_name(server)
|
||||
.map(str::to_string);
|
||||
@@ -1509,10 +1493,6 @@ fn get_mcp_app_resource_uri(
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP app metadata reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn lookup_mcp_app_usage_metadata(
|
||||
sess: &Session,
|
||||
server: &str,
|
||||
@@ -1521,8 +1501,7 @@ async fn lookup_mcp_app_usage_metadata(
|
||||
let tools = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_all_tools()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Instrument;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
@@ -1256,13 +1257,14 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
|
||||
|
||||
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
|
||||
let manager = codex_mcp::McpConnectionManager::new(
|
||||
&HashMap::new(),
|
||||
turn_context.config.mcp_oauth_credentials_store_mode,
|
||||
HashMap::new(),
|
||||
&turn_context.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
session.get_tx_event(),
|
||||
CancellationToken::new(),
|
||||
turn_context.permission_profile(),
|
||||
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
|
||||
#[allow(deprecated)]
|
||||
@@ -1278,7 +1280,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
|
||||
/*elicitation_reviewer*/ None,
|
||||
)
|
||||
.await;
|
||||
*session.services.mcp_connection_manager.write().await = manager;
|
||||
session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.store(Arc::new(manager));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo as McpToolInfo;
|
||||
use codex_mcp::tool_is_model_visible;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::connectors;
|
||||
@@ -15,6 +16,7 @@ pub(crate) struct McpToolExposure {
|
||||
pub(crate) deferred_tools: Option<Vec<McpToolInfo>>,
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) fn build_mcp_tool_exposure(
|
||||
all_mcp_tools: &[McpToolInfo],
|
||||
connectors: Option<&[connectors::AppInfo]>,
|
||||
|
||||
@@ -41,6 +41,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
.into_iter()
|
||||
.map(|plugin| DiscoverablePluginInfo {
|
||||
id: plugin.id,
|
||||
remote_plugin_id: plugin.remote_plugin_id,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
has_skills: plugin.has_skills,
|
||||
|
||||
@@ -393,6 +393,7 @@ remote_plugin = true
|
||||
remote_plugins,
|
||||
vec![DiscoverablePluginInfo {
|
||||
id: "github@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_remote_github".to_string()),
|
||||
name: "Remote GitHub".to_string(),
|
||||
description: Some("Remote GitHub short".to_string()),
|
||||
has_skills: true,
|
||||
@@ -680,6 +681,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
|
||||
discoverable_plugins,
|
||||
vec![DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
remote_plugin_id: None,
|
||||
name: "slack".to_string(),
|
||||
description: Some("Plugin with extra spacing".to_string()),
|
||||
has_skills: true,
|
||||
@@ -816,6 +818,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
|
||||
discoverable_plugins,
|
||||
vec![DiscoverablePluginInfo {
|
||||
id: "sample@openai-curated".to_string(),
|
||||
remote_plugin_id: None,
|
||||
name: "sample".to_string(),
|
||||
description: Some(
|
||||
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_login::AuthManager;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -78,8 +77,7 @@ pub(crate) async fn build_prompt_input_from_session(
|
||||
.await;
|
||||
|
||||
if !input.is_empty() {
|
||||
let input_item = ResponseInputItem::from(input);
|
||||
let response_item = ResponseItem::from(input_item);
|
||||
let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input);
|
||||
sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::client::ModelClient;
|
||||
use crate::realtime_context::build_realtime_startup_context;
|
||||
use crate::realtime_context::truncate_realtime_text_to_token_budget;
|
||||
use crate::realtime_prompt::prepare_realtime_backend_prompt;
|
||||
use crate::session::session::Session;
|
||||
use anyhow::Context;
|
||||
@@ -64,6 +65,8 @@ const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64;
|
||||
const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64;
|
||||
const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256;
|
||||
const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_300;
|
||||
const REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET: usize = 1_000;
|
||||
const STANDALONE_HANDOFF_ID: &str = "codex";
|
||||
const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5";
|
||||
pub(crate) const REALTIME_USER_TEXT_PREFIX: &str = "[USER] ";
|
||||
pub(crate) const REALTIME_BACKEND_TEXT_PREFIX: &str = "[BACKEND] ";
|
||||
@@ -106,6 +109,9 @@ struct RealtimeHandoffState {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum HandoffOutput {
|
||||
StandaloneAssistantOutput {
|
||||
output_text: String,
|
||||
},
|
||||
ProgressUpdate {
|
||||
handoff_id: String,
|
||||
output_text: String,
|
||||
@@ -460,22 +466,38 @@ impl RealtimeConversationManager {
|
||||
state.handoff.clone()
|
||||
};
|
||||
|
||||
let Some(handoff_id) = handoff.active_handoff.lock().await.clone() else {
|
||||
return Ok(());
|
||||
let active_handoff = handoff.active_handoff.lock().await.clone();
|
||||
let output = match active_handoff {
|
||||
Some(handoff_id) => {
|
||||
let output_text = prefix_realtime_text(
|
||||
output_text,
|
||||
REALTIME_BACKEND_TEXT_PREFIX,
|
||||
handoff.session_kind,
|
||||
);
|
||||
*handoff.last_output_text.lock().await = Some(output_text.clone());
|
||||
HandoffOutput::ProgressUpdate {
|
||||
handoff_id,
|
||||
output_text,
|
||||
}
|
||||
}
|
||||
None if output_text.trim().is_empty() => return Ok(()),
|
||||
None => {
|
||||
let output_text = prefix_realtime_text(
|
||||
output_text,
|
||||
REALTIME_BACKEND_TEXT_PREFIX,
|
||||
handoff.session_kind,
|
||||
);
|
||||
HandoffOutput::StandaloneAssistantOutput {
|
||||
output_text: truncate_realtime_text_to_token_budget(
|
||||
&output_text,
|
||||
REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET,
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let output_text = prefix_realtime_text(
|
||||
output_text,
|
||||
REALTIME_BACKEND_TEXT_PREFIX,
|
||||
handoff.session_kind,
|
||||
);
|
||||
*handoff.last_output_text.lock().await = Some(output_text.clone());
|
||||
handoff
|
||||
.output_tx
|
||||
.send(HandoffOutput::ProgressUpdate {
|
||||
handoff_id,
|
||||
output_text,
|
||||
})
|
||||
.send(output)
|
||||
.await
|
||||
.map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))?;
|
||||
Ok(())
|
||||
@@ -511,14 +533,6 @@ impl RealtimeConversationManager {
|
||||
.map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn active_handoff_id(&self) -> Option<String> {
|
||||
let handoff = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.as_ref().map(|state| state.handoff.clone())
|
||||
}?;
|
||||
handoff.active_handoff.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_active_handoff(&self) {
|
||||
let handoff = {
|
||||
let guard = self.state.lock().await;
|
||||
@@ -625,12 +639,14 @@ async fn prepare_realtime_start(
|
||||
if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url {
|
||||
api_provider.base_url = realtime_ws_base_url.clone();
|
||||
}
|
||||
let version = config.realtime.version;
|
||||
let version = params.version.unwrap_or(config.realtime.version);
|
||||
let session_config = build_realtime_session_config(
|
||||
sess,
|
||||
params.model,
|
||||
params.prompt,
|
||||
params.realtime_session_id,
|
||||
params.output_modality,
|
||||
version,
|
||||
params.voice,
|
||||
)
|
||||
.await?;
|
||||
@@ -664,9 +680,11 @@ async fn prepare_realtime_start(
|
||||
|
||||
pub(crate) async fn build_realtime_session_config(
|
||||
sess: &Arc<Session>,
|
||||
model: Option<String>,
|
||||
prompt: Option<Option<String>>,
|
||||
realtime_session_id: Option<String>,
|
||||
output_modality: RealtimeOutputModality,
|
||||
version: RealtimeWsVersion,
|
||||
voice: Option<RealtimeVoice>,
|
||||
) -> CodexResult<RealtimeSessionConfig> {
|
||||
let config = sess.get_config().await;
|
||||
@@ -689,18 +707,15 @@ pub(crate) async fn build_realtime_session_config(
|
||||
(false, false) => format!("{prompt}\n\n{startup_context}"),
|
||||
};
|
||||
let model = Some(
|
||||
config
|
||||
.experimental_realtime_ws_model
|
||||
.clone()
|
||||
model
|
||||
.or_else(|| config.experimental_realtime_ws_model.clone())
|
||||
.unwrap_or_else(|| DEFAULT_REALTIME_MODEL.to_string()),
|
||||
);
|
||||
let event_parser = match config.realtime.version {
|
||||
let event_parser = match version {
|
||||
RealtimeWsVersion::V1 => RealtimeEventParser::V1,
|
||||
RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2,
|
||||
};
|
||||
if config.realtime.version == RealtimeWsVersion::V1
|
||||
&& matches!(output_modality, RealtimeOutputModality::Text)
|
||||
{
|
||||
if version == RealtimeWsVersion::V1 && matches!(output_modality, RealtimeOutputModality::Text) {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"text realtime output modality requires realtime v2".to_string(),
|
||||
));
|
||||
@@ -711,8 +726,8 @@ pub(crate) async fn build_realtime_session_config(
|
||||
};
|
||||
let voice = voice
|
||||
.or(config.realtime.voice)
|
||||
.unwrap_or_else(|| default_realtime_voice(config.realtime.version));
|
||||
validate_realtime_voice(config.realtime.version, voice)?;
|
||||
.unwrap_or_else(|| default_realtime_voice(version));
|
||||
validate_realtime_voice(version, voice)?;
|
||||
Ok(RealtimeSessionConfig {
|
||||
instructions: prompt,
|
||||
model,
|
||||
@@ -739,10 +754,6 @@ fn prefix_realtime_text(text: String, prefix: &str, session_kind: RealtimeSessio
|
||||
format!("{prefix}{text}")
|
||||
}
|
||||
|
||||
pub(crate) fn prefix_realtime_v2_text(text: String, prefix: &str) -> String {
|
||||
prefix_realtime_text(text, prefix, RealtimeSessionKind::V2)
|
||||
}
|
||||
|
||||
fn validate_realtime_voice(version: RealtimeWsVersion, voice: RealtimeVoice) -> CodexResult<()> {
|
||||
let voices = RealtimeVoicesList::builtin();
|
||||
let allowed = match version {
|
||||
@@ -1191,6 +1202,15 @@ async fn handle_handoff_output(
|
||||
|
||||
let result = match event_parser {
|
||||
RealtimeEventParser::V1 => match handoff_output {
|
||||
HandoffOutput::StandaloneAssistantOutput { output_text } => {
|
||||
// TODO(guinness): Use the new client event for standalone handoffs once the API changes are complete.
|
||||
writer
|
||||
.send_conversation_handoff_append(
|
||||
STANDALONE_HANDOFF_ID.to_string(),
|
||||
output_text,
|
||||
)
|
||||
.await
|
||||
}
|
||||
HandoffOutput::ProgressUpdate {
|
||||
handoff_id,
|
||||
output_text,
|
||||
@@ -1205,6 +1225,15 @@ async fn handle_handoff_output(
|
||||
}
|
||||
},
|
||||
RealtimeEventParser::RealtimeV2 => match handoff_output {
|
||||
HandoffOutput::StandaloneAssistantOutput { output_text } => {
|
||||
if let Err(err) = writer.send_conversation_item_create(output_text).await {
|
||||
Err(err)
|
||||
} else {
|
||||
return response_create_queue
|
||||
.request_create(writer, events_tx, "standalone assistant output")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
HandoffOutput::ProgressUpdate {
|
||||
handoff_id,
|
||||
output_text,
|
||||
|
||||
@@ -2,7 +2,6 @@ use anyhow::Context;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_features::AppsMcpPathOverrideConfigToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureToml;
|
||||
use codex_features::FeaturesToml;
|
||||
@@ -149,10 +148,6 @@ fn save_config_resolved_fields(
|
||||
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
|
||||
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
|
||||
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
|
||||
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
|
||||
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
|
||||
path: config.apps_mcp_path_override.clone(),
|
||||
}));
|
||||
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
|
||||
&config.memories,
|
||||
"memories",
|
||||
@@ -325,6 +320,32 @@ mod tests {
|
||||
assert!(message.contains("model = "), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_ignores_removed_apps_mcp_path_override() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let mut expected_value = toml::Value::try_from(&actual).expect("lock should become TOML");
|
||||
expected_value["config"]["features"]
|
||||
.as_table_mut()
|
||||
.expect("features should be a table")
|
||||
.insert(
|
||||
"apps_mcp_path_override".to_string(),
|
||||
toml::Value::Table(toml::Table::from_iter([
|
||||
("enabled".to_string(), toml::Value::Boolean(true)),
|
||||
(
|
||||
"path".to_string(),
|
||||
toml::Value::String("/custom/mcp".to_string()),
|
||||
),
|
||||
])),
|
||||
);
|
||||
let expected: ConfigLockfileToml = expected_value
|
||||
.try_into()
|
||||
.expect("lock with removed input should deserialize");
|
||||
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect("removed compatibility input should not cause lock drift");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
|
||||
@@ -15,10 +15,6 @@ use crate::session::session::Session;
|
||||
use crate::session::session::SessionSettingsUpdate;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET;
|
||||
use crate::realtime_context::truncate_realtime_text_to_token_budget;
|
||||
use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX;
|
||||
use crate::realtime_conversation::prefix_realtime_v2_text;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::session::spawn_review_thread;
|
||||
use crate::tasks::CompactTask;
|
||||
@@ -54,9 +50,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use serde_json::Value;
|
||||
@@ -91,14 +85,7 @@ pub async fn user_input_or_turn(
|
||||
op: Op,
|
||||
client_user_message_id: Option<String>,
|
||||
) {
|
||||
user_input_or_turn_inner(
|
||||
sess,
|
||||
sub_id,
|
||||
op,
|
||||
/*mirror_user_text_to_realtime*/ Some(()),
|
||||
client_user_message_id,
|
||||
)
|
||||
.await;
|
||||
user_input_or_turn_inner(sess, sub_id, op, client_user_message_id).await;
|
||||
}
|
||||
|
||||
pub async fn update_thread_settings(
|
||||
@@ -196,7 +183,6 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
op: Op,
|
||||
mirror_user_text_to_realtime: Option<()>,
|
||||
client_user_message_id: Option<String>,
|
||||
) {
|
||||
let Op::UserInput {
|
||||
@@ -230,7 +216,7 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
}
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
let accepted_items = match sess
|
||||
match sess
|
||||
.steer_input(
|
||||
items.clone(),
|
||||
additional_context.clone(),
|
||||
@@ -242,7 +228,6 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
{
|
||||
Ok(_) => {
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
Some(items)
|
||||
}
|
||||
Err(SteerInputError::NoActiveTurn(items)) => {
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
|
||||
@@ -256,7 +241,6 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
)
|
||||
.await;
|
||||
let accepted_items = items.clone();
|
||||
let additional_context_input = {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.additional_context.merge(additional_context)
|
||||
@@ -278,7 +262,6 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
crate::tasks::RegularTask::new(),
|
||||
)
|
||||
.await;
|
||||
Some(accepted_items)
|
||||
}
|
||||
Err(err) => {
|
||||
sess.send_event_raw(Event {
|
||||
@@ -286,33 +269,7 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
msg: EventMsg::Error(err.to_error_event()),
|
||||
})
|
||||
.await;
|
||||
None
|
||||
}
|
||||
};
|
||||
if let (Some(items), Some(())) = (accepted_items, mirror_user_text_to_realtime) {
|
||||
self::mirror_user_text_to_realtime(sess, &items).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
|
||||
let text = UserMessageItem::new(items).message();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = if sess.conversation.is_running_v2().await {
|
||||
prefix_realtime_v2_text(text, REALTIME_USER_TEXT_PREFIX)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let text = truncate_realtime_text_to_token_budget(&text, REALTIME_TURN_TOKEN_BUDGET);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if sess.conversation.running_state().await.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = sess.conversation.text_in(text).await {
|
||||
debug!("failed to mirror user text to realtime conversation: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,6 +580,9 @@ pub async fn set_thread_memory_mode(sess: &Arc<Session>, sub_id: String, mode: T
|
||||
}
|
||||
|
||||
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.services
|
||||
@@ -632,11 +592,11 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
if let Err(err) = sess.services.code_mode_service.shutdown().await {
|
||||
warn!("failed to shutdown code mode session: {err}");
|
||||
}
|
||||
let mcp_shutdown = {
|
||||
let mut manager = sess.services.mcp_connection_manager.write().await;
|
||||
manager.begin_shutdown()
|
||||
};
|
||||
mcp_shutdown.await;
|
||||
sess.services
|
||||
.mcp_connection_manager
|
||||
.load_full()
|
||||
.shutdown()
|
||||
.await;
|
||||
sess.guardian_review_session.shutdown().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +91,7 @@ impl Session {
|
||||
if self
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.elicitations_auto_deny()
|
||||
{
|
||||
return McpServerElicitationOutcome {
|
||||
@@ -226,16 +225,11 @@ impl Session {
|
||||
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.resolve_elicitation(server_name, id, response)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
||||
)]
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -243,16 +237,11 @@ impl Session {
|
||||
) -> anyhow::Result<ListResourcesResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_resources(server, params)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
||||
)]
|
||||
pub async fn list_resource_templates(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -260,16 +249,11 @@ impl Session {
|
||||
) -> anyhow::Result<ListResourceTemplatesResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_resource_templates(server, params)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP resource calls are serialized through the session-owned manager guard"
|
||||
)]
|
||||
pub async fn read_resource(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -277,16 +261,11 @@ impl Session {
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.read_resource(server, params)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP tool calls are serialized through the session-owned manager guard"
|
||||
)]
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
server: &str,
|
||||
@@ -296,8 +275,7 @@ impl Session {
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.call_tool(server, tool, arguments, meta)
|
||||
.await
|
||||
}
|
||||
@@ -338,18 +316,21 @@ impl Session {
|
||||
turn_context.cwd.to_path_buf(),
|
||||
),
|
||||
};
|
||||
{
|
||||
let mcp_startup_cancellation_token = {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
*guard = CancellationToken::new();
|
||||
}
|
||||
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
|
||||
let cancellation_token = CancellationToken::new();
|
||||
*guard = cancellation_token.clone();
|
||||
cancellation_token
|
||||
};
|
||||
let refreshed_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
store_mode,
|
||||
auth_statuses,
|
||||
&turn_context.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
self.get_tx_event(),
|
||||
mcp_startup_cancellation_token,
|
||||
turn_context.permission_profile(),
|
||||
mcp_runtime_context,
|
||||
config.codex_home.to_path_buf(),
|
||||
@@ -363,22 +344,12 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let current_manager = self.services.mcp_connection_manager.read().await;
|
||||
let current_manager = self.services.mcp_connection_manager.load_full();
|
||||
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
|
||||
}
|
||||
{
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
if guard.is_cancelled() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
*guard = cancel_token;
|
||||
}
|
||||
|
||||
let mut old_manager = {
|
||||
let mut manager = self.services.mcp_connection_manager.write().await;
|
||||
std::mem::replace(&mut *manager, refreshed_manager)
|
||||
};
|
||||
old_manager.shutdown().await;
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.store(Arc::new(refreshed_manager));
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_mcp_servers_if_requested(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -31,6 +32,7 @@ use crate::context::PersonalitySpecInstructions;
|
||||
use crate::default_skill_metadata_budget;
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::image_preparation::prepare_response_items;
|
||||
use crate::parse_turn_item;
|
||||
use crate::realtime_conversation::RealtimeConversationManager;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
@@ -63,6 +65,7 @@ use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
@@ -205,6 +208,7 @@ mod review;
|
||||
mod rollout_reconstruction;
|
||||
#[allow(clippy::module_inception)]
|
||||
pub(crate) mod session;
|
||||
mod token_budget;
|
||||
pub(crate) mod turn;
|
||||
pub(crate) mod turn_context;
|
||||
use self::config_lock::export_config_lock_if_configured;
|
||||
@@ -273,10 +277,12 @@ impl SteerInputError {
|
||||
/// Conceptually this is the same role that `previous_model` used to fill, but
|
||||
/// it can carry other prior-turn settings that matter when constructing
|
||||
/// sensible state-change diffs or full-context reinjection, such as model
|
||||
/// switches or detecting a prior `realtime_active -> false` transition.
|
||||
/// switches, compaction compatibility, or detecting a prior
|
||||
/// `realtime_active -> false` transition.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PreviousTurnSettings {
|
||||
pub(crate) model: String,
|
||||
pub(crate) comp_hash: Option<String>,
|
||||
pub(crate) realtime_active: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -326,6 +332,7 @@ use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::LocalImagePreparation;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -793,7 +800,7 @@ impl Codex {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await;
|
||||
let mcp_connection_manager = self.session.services.mcp_connection_manager.load_full();
|
||||
mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1132,7 +1139,6 @@ impl Session {
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
/*mirror_user_text_to_realtime*/ None,
|
||||
/*client_user_message_id*/ None,
|
||||
)
|
||||
.await;
|
||||
@@ -1300,15 +1306,27 @@ impl Session {
|
||||
turn_context: &TurnContext,
|
||||
rollout_items: &[RolloutItem],
|
||||
) -> Option<PreviousTurnSettings> {
|
||||
let reconstructed_rollout = self
|
||||
let rollout_reconstruction::RolloutReconstruction {
|
||||
mut history,
|
||||
previous_turn_settings,
|
||||
reference_context_item,
|
||||
window_id,
|
||||
} = self
|
||||
.reconstruct_history_from_rollout(turn_context, rollout_items)
|
||||
.await;
|
||||
let previous_turn_settings = reconstructed_rollout.previous_turn_settings.clone();
|
||||
self.replace_history(
|
||||
reconstructed_rollout.history,
|
||||
reconstructed_rollout.reference_context_item,
|
||||
)
|
||||
.await;
|
||||
if turn_context.features.enabled(Feature::ResizeAllImages) {
|
||||
// Keep the recorded rollout unchanged. Prepare its reconstructed history before
|
||||
// installing it, so legacy images are processed once for this resume or fork and
|
||||
// will be processed again if the rollout is reconstructed in a future session.
|
||||
// This meets image resizing requirements without modifying persisted rollouts.
|
||||
prepare_response_items(&mut history);
|
||||
}
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(history, reference_context_item);
|
||||
state.set_auto_compact_window_id(window_id);
|
||||
state.set_previous_turn_settings(previous_turn_settings.clone());
|
||||
}
|
||||
let prefix_tokens = if matches!(
|
||||
turn_context.config.model_auto_compact_token_limit_scope,
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix
|
||||
@@ -1323,8 +1341,6 @@ impl Session {
|
||||
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
|
||||
.await;
|
||||
}
|
||||
self.set_previous_turn_settings(previous_turn_settings.clone())
|
||||
.await;
|
||||
previous_turn_settings
|
||||
}
|
||||
|
||||
@@ -1794,9 +1810,7 @@ impl Session {
|
||||
let Some(text) = realtime_text_for_event(msg) else {
|
||||
return;
|
||||
};
|
||||
if self.conversation.running_state().await.is_none()
|
||||
|| self.conversation.active_handoff_id().await.is_none()
|
||||
{
|
||||
if self.conversation.running_state().await.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.conversation.handoff_out(text).await {
|
||||
@@ -2583,11 +2597,43 @@ impl Session {
|
||||
|
||||
/// Records conversation items: append to history, persist to rollout, and
|
||||
/// notify clients observing raw response items.
|
||||
pub(crate) fn prepare_conversation_items_for_history<'a>(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
items: &'a [ResponseItem],
|
||||
) -> Cow<'a, [ResponseItem]> {
|
||||
if !turn_context.features.enabled(Feature::ResizeAllImages) {
|
||||
return Cow::Borrowed(items);
|
||||
}
|
||||
|
||||
let mut prepared_items = items.to_vec();
|
||||
prepare_response_items(&mut prepared_items);
|
||||
Cow::Owned(prepared_items)
|
||||
}
|
||||
|
||||
pub(crate) fn response_item_from_user_input(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
input: Vec<UserInput>,
|
||||
) -> ResponseItem {
|
||||
let local_image_preparation = if turn_context.features.enabled(Feature::ResizeAllImages) {
|
||||
LocalImagePreparation::Defer
|
||||
} else {
|
||||
LocalImagePreparation::Process
|
||||
};
|
||||
ResponseItem::from(ResponseInputItem::from_user_input(
|
||||
input,
|
||||
local_image_preparation,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn record_conversation_items(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
items: &[ResponseItem],
|
||||
) {
|
||||
let items = self.prepare_conversation_items_for_history(turn_context, items);
|
||||
let items = items.as_ref();
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.record_items(items.iter(), turn_context.truncation_policy);
|
||||
@@ -2656,6 +2702,7 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn replace_history(
|
||||
&self,
|
||||
items: Vec<ResponseItem>,
|
||||
@@ -2674,7 +2721,6 @@ impl Session {
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(items, reference_context_item.clone());
|
||||
state.start_next_auto_compact_window();
|
||||
}
|
||||
|
||||
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
|
||||
@@ -2687,7 +2733,6 @@ impl Session {
|
||||
let mut state = self.state.lock().await;
|
||||
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
|
||||
}
|
||||
self.services.model_client.advance_window_generation();
|
||||
}
|
||||
|
||||
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
|
||||
@@ -2749,10 +2794,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP app context rendering reads through the session-owned manager guard"
|
||||
)]
|
||||
pub(crate) async fn build_initial_context(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
@@ -2766,6 +2807,7 @@ impl Session {
|
||||
collaboration_mode,
|
||||
base_instructions,
|
||||
session_source,
|
||||
auto_compact_window_id,
|
||||
) = {
|
||||
let state = self.state.lock().await;
|
||||
(
|
||||
@@ -2774,6 +2816,7 @@ impl Session {
|
||||
state.session_configuration.collaboration_mode.clone(),
|
||||
state.session_configuration.base_instructions.clone(),
|
||||
state.session_configuration.session_source.clone(),
|
||||
state.auto_compact_window_id(),
|
||||
)
|
||||
};
|
||||
if let Some(model_switch_message) =
|
||||
@@ -2845,7 +2888,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.load_full();
|
||||
let accessible_and_enabled_connectors =
|
||||
connectors::list_accessible_and_enabled_connectors_from_manager(
|
||||
&mcp_connection_manager,
|
||||
@@ -2923,6 +2966,18 @@ impl Session {
|
||||
.render(),
|
||||
);
|
||||
}
|
||||
// This is full-context metadata. Steady-state context diffs should not re-emit it.
|
||||
if turn_context.features.enabled(Feature::TokenBudget)
|
||||
&& let Some(model_context_window) = turn_context.model_context_window()
|
||||
{
|
||||
developer_sections.push(
|
||||
crate::context::TokenBudgetContext::new(
|
||||
auto_compact_window_id,
|
||||
model_context_window,
|
||||
)
|
||||
.render(),
|
||||
);
|
||||
}
|
||||
if turn_context.config.include_environment_context {
|
||||
let shell = self.user_shell();
|
||||
let subagents = self
|
||||
@@ -2994,6 +3049,56 @@ impl Session {
|
||||
state.clone_history()
|
||||
}
|
||||
|
||||
pub(crate) async fn current_window_id(&self) -> String {
|
||||
let state = self.state.lock().await;
|
||||
let thread_id = self.thread_id;
|
||||
let window_id = state.auto_compact_window_id();
|
||||
format!("{thread_id}:{window_id}")
|
||||
}
|
||||
|
||||
pub(crate) async fn advance_auto_compact_window_id(&self) -> u64 {
|
||||
let mut state = self.state.lock().await;
|
||||
state.advance_auto_compact_window_id()
|
||||
}
|
||||
|
||||
pub(crate) async fn request_new_context_window(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.request_new_context_window();
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_start_new_context_window(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> Option<u64> {
|
||||
let window_id = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.start_new_context_window_if_requested()
|
||||
};
|
||||
let window_id = window_id?;
|
||||
let context_items = self.build_initial_context(turn_context).await;
|
||||
let turn_context_item = turn_context.to_turn_context_item();
|
||||
let replacement_history = context_items;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.replace_history(replacement_history.clone(), Some(turn_context_item.clone()));
|
||||
};
|
||||
self.persist_rollout_items(&[
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history),
|
||||
window_id: Some(window_id),
|
||||
}),
|
||||
RolloutItem::TurnContext(turn_context_item),
|
||||
])
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
|
||||
}
|
||||
self.recompute_token_usage(turn_context).await;
|
||||
Some(window_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn reference_context_item(&self) -> Option<TurnContextItem> {
|
||||
let state = self.state.lock().await;
|
||||
state.reference_context_item()
|
||||
@@ -3012,6 +3117,7 @@ impl Session {
|
||||
/// Mid-turn compaction is the other path that can re-establish that baseline when it
|
||||
/// reinjects full initial context into replacement history. Other non-regular tasks
|
||||
/// intentionally do not update the baseline.
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn record_context_updates_and_set_reference_context_item(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
@@ -3201,7 +3307,7 @@ impl Session {
|
||||
// Persist the user message to history, but emit the turn item from `UserInput` so
|
||||
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
||||
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
||||
let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec()));
|
||||
let response_item = self.response_item_from_user_input(turn_context, input.to_vec());
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
let mut user_message_item = UserMessageItem::new(input);
|
||||
|
||||
@@ -114,6 +114,7 @@ pub(super) async fn spawn_review_thread(
|
||||
config: per_turn_config,
|
||||
auth_manager: auth_manager_for_context,
|
||||
model_info: model_info.clone(),
|
||||
comp_hash: model_info.comp_hash.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: session_telemetry_for_context,
|
||||
provider: provider_for_context,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(super) struct RolloutReconstruction {
|
||||
pub(super) history: Vec<ResponseItem>,
|
||||
pub(super) previous_turn_settings: Option<PreviousTurnSettings>,
|
||||
pub(super) reference_context_item: Option<TurnContextItem>,
|
||||
pub(super) window_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -33,6 +34,7 @@ struct ActiveReplaySegment<'a> {
|
||||
previous_turn_settings: Option<PreviousTurnSettings>,
|
||||
reference_context_item: TurnReferenceContextItem,
|
||||
base_replacement_history: Option<&'a [ResponseItem]>,
|
||||
window_id: Option<u64>,
|
||||
}
|
||||
|
||||
fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool {
|
||||
@@ -45,6 +47,7 @@ fn finalize_active_segment<'a>(
|
||||
base_replacement_history: &mut Option<&'a [ResponseItem]>,
|
||||
previous_turn_settings: &mut Option<PreviousTurnSettings>,
|
||||
reference_context_item: &mut TurnReferenceContextItem,
|
||||
window_id: &mut Option<u64>,
|
||||
pending_rollback_turns: &mut usize,
|
||||
) {
|
||||
// Thread rollback drops the newest surviving real user-message boundaries. In replay, that
|
||||
@@ -65,6 +68,10 @@ fn finalize_active_segment<'a>(
|
||||
*base_replacement_history = Some(segment_base_replacement_history);
|
||||
}
|
||||
|
||||
if window_id.is_none() {
|
||||
*window_id = active_segment.window_id;
|
||||
}
|
||||
|
||||
// `previous_turn_settings` come from the newest surviving user turn that established them.
|
||||
if previous_turn_settings.is_none() && active_segment.counts_as_user_turn {
|
||||
*previous_turn_settings = active_segment.previous_turn_settings;
|
||||
@@ -97,6 +104,7 @@ impl Session {
|
||||
let mut base_replacement_history: Option<&[ResponseItem]> = None;
|
||||
let mut previous_turn_settings = None;
|
||||
let mut reference_context_item = TurnReferenceContextItem::NeverSet;
|
||||
let mut window_id = None;
|
||||
// Rollback is "drop the newest N user turns". While scanning in reverse, that becomes
|
||||
// "skip the next N user-turn segments we finalize".
|
||||
let mut pending_rollback_turns = 0usize;
|
||||
@@ -112,6 +120,9 @@ impl Session {
|
||||
RolloutItem::Compacted(compacted) => {
|
||||
let active_segment =
|
||||
active_segment.get_or_insert_with(ActiveReplaySegment::default);
|
||||
if active_segment.window_id.is_none() {
|
||||
active_segment.window_id = compacted.window_id;
|
||||
}
|
||||
// Looking backward, compaction clears any older baseline unless a newer
|
||||
// `TurnContextItem` in this same segment has already re-established it.
|
||||
if matches!(
|
||||
@@ -173,6 +184,7 @@ impl Session {
|
||||
) {
|
||||
active_segment.previous_turn_settings = Some(PreviousTurnSettings {
|
||||
model: ctx.model.clone(),
|
||||
comp_hash: ctx.comp_hash.clone(),
|
||||
realtime_active: ctx.realtime_active,
|
||||
});
|
||||
if matches!(
|
||||
@@ -198,6 +210,7 @@ impl Session {
|
||||
&mut base_replacement_history,
|
||||
&mut previous_turn_settings,
|
||||
&mut reference_context_item,
|
||||
&mut window_id,
|
||||
&mut pending_rollback_turns,
|
||||
);
|
||||
}
|
||||
@@ -227,10 +240,19 @@ impl Session {
|
||||
&mut base_replacement_history,
|
||||
&mut previous_turn_settings,
|
||||
&mut reference_context_item,
|
||||
&mut window_id,
|
||||
&mut pending_rollback_turns,
|
||||
);
|
||||
}
|
||||
|
||||
let fallback_window_id = u64::try_from(
|
||||
rollout_items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
let mut history = ContextManager::new();
|
||||
let mut saw_legacy_compaction_without_replacement_history = false;
|
||||
if let Some(base_replacement_history) = base_replacement_history {
|
||||
@@ -296,6 +318,7 @@ impl Session {
|
||||
history: history.raw_items().to_vec(),
|
||||
previous_turn_settings,
|
||||
reference_context_item,
|
||||
window_id: window_id.unwrap_or(fallback_window_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -109,6 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: Some("comp-hash-a".to_string()),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -166,6 +168,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: Some("comp-hash-a".to_string()),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -271,6 +274,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -364,6 +368,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -489,6 +494,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -589,6 +595,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -791,6 +798,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
@@ -809,6 +817,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -846,6 +855,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -871,6 +881,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: "legacy summary".to_string(),
|
||||
replacement_history: None,
|
||||
window_id: None,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -902,6 +913,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: "legacy summary".to_string(),
|
||||
replacement_history: None,
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
@@ -959,6 +971,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -994,6 +1007,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
@@ -1019,6 +1033,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -1038,6 +1053,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -1067,6 +1083,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -1140,6 +1157,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1155,6 +1173,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -1186,6 +1205,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: current_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -1275,6 +1295,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: current_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -1304,6 +1325,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -1369,6 +1391,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1384,6 +1407,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -1434,6 +1458,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -1463,6 +1488,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -1529,6 +1555,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(Vec::new()),
|
||||
window_id: None,
|
||||
}),
|
||||
// A newer TurnStarted replaces the incomplete compacted turn without a matching
|
||||
// completion/abort for the old one.
|
||||
@@ -1555,6 +1582,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -469,10 +469,6 @@ impl Session {
|
||||
|
||||
#[instrument(name = "session_init", level = "info", skip_all)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "session initialization must serialize access through session-owned manager guards"
|
||||
)]
|
||||
pub(crate) async fn new(
|
||||
mut session_configuration: SessionConfiguration,
|
||||
config: Arc<Config>,
|
||||
@@ -519,17 +515,6 @@ impl Session {
|
||||
}
|
||||
InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id,
|
||||
};
|
||||
let window_generation = match &initial_history {
|
||||
InitialHistory::Resumed(resumed_history) => u64::try_from(
|
||||
resumed_history
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX),
|
||||
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => 0,
|
||||
};
|
||||
// Kick off independent async setup tasks in parallel to reduce startup latency.
|
||||
//
|
||||
// - initialize thread persistence with new or resumed session info
|
||||
@@ -961,8 +946,20 @@ impl Session {
|
||||
.effective_agent_max_threads(MultiAgentVersion::V2)
|
||||
.unwrap_or(usize::MAX),
|
||||
);
|
||||
// Keep one stable manager handle for the session so extension resource clients
|
||||
// automatically observe the manager installed at startup and on later refreshes.
|
||||
let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
McpConnectionManager::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
config.prefix_mcp_tool_names(),
|
||||
),
|
||||
));
|
||||
let session_extension_data =
|
||||
codex_extension_api::ExtensionData::new(session_id.to_string());
|
||||
session_extension_data.insert(McpResourceClient::new(Arc::clone(
|
||||
&mcp_connection_manager,
|
||||
)));
|
||||
let thread_extension_data = codex_extension_api::ExtensionData::new_with_init(
|
||||
thread_id.to_string(),
|
||||
thread_extension_init,
|
||||
@@ -985,13 +982,7 @@ impl Session {
|
||||
// before any MCP-related events. It is reasonable to consider
|
||||
// changing this to use Option or OnceCell, though the current
|
||||
// setup is straightforward enough and performs well.
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
McpConnectionManager::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
config.prefix_mcp_tool_names(),
|
||||
),
|
||||
)),
|
||||
mcp_connection_manager,
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
@@ -1051,9 +1042,6 @@ impl Session {
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
environment_manager,
|
||||
};
|
||||
services
|
||||
.model_client
|
||||
.set_window_generation(window_generation);
|
||||
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
|
||||
watch::channel(false);
|
||||
|
||||
@@ -1116,15 +1104,6 @@ impl Session {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
let mut required_mcp_servers: Vec<String> = mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, server)| server.enabled() && server.required())
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
required_mcp_servers.sort();
|
||||
let enabled_mcp_server_count =
|
||||
mcp_servers.values().filter(|server| server.enabled()).count();
|
||||
let required_mcp_server_count = required_mcp_servers.len();
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await;
|
||||
let host_owned_codex_apps_enabled = config
|
||||
.features
|
||||
@@ -1137,11 +1116,13 @@ impl Session {
|
||||
} else {
|
||||
ElicitationCapability::default()
|
||||
};
|
||||
{
|
||||
let mcp_startup_cancellation_token = {
|
||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
*cancel_guard = CancellationToken::new();
|
||||
}
|
||||
let cancel_token = CancellationToken::new();
|
||||
*cancel_guard = cancel_token.clone();
|
||||
cancel_token
|
||||
};
|
||||
let turn_environment = crate::environment_selection::resolve_environment_selections(
|
||||
sess.services.environment_manager.as_ref(),
|
||||
session_configuration.environment_selections(),
|
||||
@@ -1164,13 +1145,14 @@ impl Session {
|
||||
session_configuration.cwd().to_path_buf(),
|
||||
),
|
||||
};
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
let mcp_connection_manager = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
auth_statuses,
|
||||
&session_configuration.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event.clone(),
|
||||
mcp_startup_cancellation_token,
|
||||
session_configuration.permission_profile(),
|
||||
mcp_runtime_context,
|
||||
config.codex_home.to_path_buf(),
|
||||
@@ -1185,43 +1167,11 @@ impl Session {
|
||||
.instrument(info_span!(
|
||||
"session_init.mcp_manager_init",
|
||||
otel.name = "session_init.mcp_manager_init",
|
||||
session_init.enabled_mcp_server_count = enabled_mcp_server_count,
|
||||
session_init.required_mcp_server_count = required_mcp_server_count,
|
||||
))
|
||||
.await;
|
||||
{
|
||||
let mut manager_guard = sess.services.mcp_connection_manager.write().await;
|
||||
*manager_guard = mcp_connection_manager;
|
||||
}
|
||||
{
|
||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||
if cancel_guard.is_cancelled() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
*cancel_guard = cancel_token;
|
||||
}
|
||||
if !required_mcp_servers.is_empty() {
|
||||
let failures = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.required_startup_failures(&required_mcp_servers)
|
||||
.instrument(info_span!(
|
||||
"session_init.required_mcp_wait",
|
||||
otel.name = "session_init.required_mcp_wait",
|
||||
session_init.required_mcp_server_count = required_mcp_server_count,
|
||||
))
|
||||
.await;
|
||||
if !failures.is_empty() {
|
||||
let details = failures
|
||||
.iter()
|
||||
.map(|failure| format!("{}: {}", failure.server, failure.error))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
anyhow::bail!("required MCP servers failed to initialize: {details}");
|
||||
}
|
||||
}
|
||||
sess.services
|
||||
.install_mcp_connection_manager(mcp_connection_manager)
|
||||
.await?;
|
||||
sess.schedule_startup_prewarm(session_configuration.base_instructions.clone())
|
||||
.await;
|
||||
let session_start_source = match &initial_history {
|
||||
|
||||
+279
-106
@@ -42,7 +42,9 @@ use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::SandboxEnforcement;
|
||||
use codex_protocol::openai_models::ModelServiceTier;
|
||||
@@ -319,8 +321,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
|
||||
session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.set_elicitations_auto_deny(/*auto_deny*/ true);
|
||||
|
||||
let requested_schema: McpElicitationSchema = serde_json::from_value(json!({
|
||||
@@ -1586,6 +1587,7 @@ async fn reconstruct_history_matches_live_compactions() {
|
||||
.await;
|
||||
|
||||
assert_eq!(expected, reconstructed.history);
|
||||
assert_eq!(2, reconstructed.window_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1613,6 +1615,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
|
||||
let rollout_items = vec![RolloutItem::Compacted(CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(replacement_history.clone()),
|
||||
window_id: Some(42),
|
||||
})];
|
||||
|
||||
let reconstructed = session
|
||||
@@ -1620,6 +1623,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
|
||||
.await;
|
||||
|
||||
assert_eq!(reconstructed.history, replacement_history);
|
||||
assert_eq!(42, reconstructed.window_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1639,6 +1643,114 @@ async fn record_initial_history_reconstructs_resumed_transcript() {
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_all_images_prepares_failures_before_history_insertion() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
Vec::new(),
|
||||
|config| {
|
||||
let _ = config.features.enable(Feature::ResizeAllImages);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let item = ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,%%%".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
};
|
||||
|
||||
session
|
||||
.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
session.state.lock().await.clone_history().raw_items(),
|
||||
&[ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "image content omitted because it could not be processed".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
},
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_all_images_prepares_resumed_history_before_installing_it() {
|
||||
let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
Vec::new(),
|
||||
|config| {
|
||||
let _ = config.features.enable(Feature::ResizeAllImages);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let resumed_item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,%%%".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "keep me".to_string(),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
};
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: ThreadId::default(),
|
||||
history: vec![RolloutItem::ResponseItem(resumed_item)],
|
||||
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
session.state.lock().await.clone_history().raw_items(),
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "image content omitted because it could not be processed".to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "keep me".to_string(),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_multi_agent_version_handles_unset_and_legacy_history() {
|
||||
let thread_id = ThreadId::default();
|
||||
@@ -1922,26 +2034,27 @@ async fn record_token_usage_info_notifies_extension_contributors() {
|
||||
records: Arc<std::sync::Mutex<Vec<RecordedTokenUsage>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder {
|
||||
async fn on_token_usage(
|
||||
&self,
|
||||
session_store: &codex_extension_api::ExtensionData,
|
||||
thread_store: &codex_extension_api::ExtensionData,
|
||||
turn_store: &codex_extension_api::ExtensionData,
|
||||
token_usage: &TokenUsageInfo,
|
||||
) {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("token usage records lock")
|
||||
.push(RecordedTokenUsage {
|
||||
session_level_id: session_store.level_id().to_string(),
|
||||
thread_level_id: thread_store.level_id().to_string(),
|
||||
turn_level_id: turn_store.level_id().to_string(),
|
||||
token_usage: token_usage.clone(),
|
||||
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
|
||||
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
|
||||
});
|
||||
fn on_token_usage<'a>(
|
||||
&'a self,
|
||||
session_store: &'a codex_extension_api::ExtensionData,
|
||||
thread_store: &'a codex_extension_api::ExtensionData,
|
||||
turn_store: &'a codex_extension_api::ExtensionData,
|
||||
token_usage: &'a TokenUsageInfo,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("token usage records lock")
|
||||
.push(RecordedTokenUsage {
|
||||
session_level_id: session_store.level_id().to_string(),
|
||||
thread_level_id: thread_store.level_id().to_string(),
|
||||
turn_level_id: turn_store.level_id().to_string(),
|
||||
token_usage: token_usage.clone(),
|
||||
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
|
||||
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2040,25 +2153,32 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() {
|
||||
records: Arc<std::sync::Mutex<Vec<RecordedTurnStart>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::TurnLifecycleContributor for TurnStartRecorder {
|
||||
async fn on_turn_start(&self, input: codex_extension_api::TurnStartInput<'_>) {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("turn start records lock")
|
||||
.push(RecordedTurnStart {
|
||||
session_level_id: input.session_store.level_id().to_string(),
|
||||
thread_level_id: input.thread_store.level_id().to_string(),
|
||||
turn_level_id: input.turn_store.level_id().to_string(),
|
||||
turn_id: input.turn_id.to_string(),
|
||||
collaboration_mode: input.collaboration_mode.clone(),
|
||||
token_usage_at_turn_start: input.token_usage_at_turn_start.clone(),
|
||||
saw_session_store: input
|
||||
.session_store
|
||||
.get::<SessionTurnStartMarker>()
|
||||
.is_some(),
|
||||
saw_thread_store: input.thread_store.get::<ThreadTurnStartMarker>().is_some(),
|
||||
});
|
||||
fn on_turn_start<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::TurnStartInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("turn start records lock")
|
||||
.push(RecordedTurnStart {
|
||||
session_level_id: input.session_store.level_id().to_string(),
|
||||
thread_level_id: input.thread_store.level_id().to_string(),
|
||||
turn_level_id: input.turn_store.level_id().to_string(),
|
||||
turn_id: input.turn_id.to_string(),
|
||||
collaboration_mode: input.collaboration_mode.clone(),
|
||||
token_usage_at_turn_start: input.token_usage_at_turn_start.clone(),
|
||||
saw_session_store: input
|
||||
.session_store
|
||||
.get::<SessionTurnStartMarker>()
|
||||
.is_some(),
|
||||
saw_thread_store: input
|
||||
.thread_store
|
||||
.get::<ThreadTurnStartMarker>()
|
||||
.is_some(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2138,24 +2258,31 @@ async fn turn_error_lifecycle_exposes_error_and_stores() {
|
||||
records: Arc<std::sync::Mutex<Vec<RecordedTurnError>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::TurnLifecycleContributor for TurnErrorRecorder {
|
||||
async fn on_turn_error(&self, input: codex_extension_api::TurnErrorInput<'_>) {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("turn error records lock")
|
||||
.push(RecordedTurnError {
|
||||
session_level_id: input.session_store.level_id().to_string(),
|
||||
thread_level_id: input.thread_store.level_id().to_string(),
|
||||
turn_level_id: input.turn_store.level_id().to_string(),
|
||||
turn_id: input.turn_id.to_string(),
|
||||
error: input.error,
|
||||
saw_session_store: input
|
||||
.session_store
|
||||
.get::<SessionTurnErrorMarker>()
|
||||
.is_some(),
|
||||
saw_thread_store: input.thread_store.get::<ThreadTurnErrorMarker>().is_some(),
|
||||
});
|
||||
fn on_turn_error<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::TurnErrorInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.records
|
||||
.lock()
|
||||
.expect("turn error records lock")
|
||||
.push(RecordedTurnError {
|
||||
session_level_id: input.session_store.level_id().to_string(),
|
||||
thread_level_id: input.thread_store.level_id().to_string(),
|
||||
turn_level_id: input.turn_store.level_id().to_string(),
|
||||
turn_id: input.turn_id.to_string(),
|
||||
error: input.error,
|
||||
saw_session_store: input
|
||||
.session_store
|
||||
.get::<SessionTurnErrorMarker>()
|
||||
.is_some(),
|
||||
saw_thread_store: input
|
||||
.thread_store
|
||||
.get::<ThreadTurnErrorMarker>()
|
||||
.is_some(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2542,6 +2669,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
multi_agent_version: None,
|
||||
@@ -2594,6 +2722,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: previous_model.to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -2636,6 +2765,7 @@ async fn thread_rollback_drops_last_turn_from_history() {
|
||||
sess.persist_rollout_items(&rollout_items).await;
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: "stale-model".to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
@@ -2818,6 +2948,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
|
||||
.await;
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: "stale-model".to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: None,
|
||||
}))
|
||||
.await;
|
||||
@@ -2834,6 +2965,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
|
||||
sess.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
model: tc.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(tc.realtime_active),
|
||||
})
|
||||
);
|
||||
@@ -2905,6 +3037,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: "summary after compaction".to_string(),
|
||||
replacement_history: Some(compacted_history.clone()),
|
||||
window_id: Some(7),
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: compact_turn_id,
|
||||
@@ -2933,6 +3066,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(rolled_back_turn_id.clone()),
|
||||
model: "rolled-back-model".to_string(),
|
||||
comp_hash: None,
|
||||
..first_context_item.clone()
|
||||
}),
|
||||
RolloutItem::ResponseItem(user_message("turn 2 user")),
|
||||
@@ -2951,6 +3085,10 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
Some(first_context_item),
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.set_auto_compact_window_id(/*window_id*/ 99);
|
||||
}
|
||||
|
||||
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
|
||||
let rollback_event = wait_for_thread_rolled_back(&rx).await;
|
||||
@@ -2958,6 +3096,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
|
||||
|
||||
assert_eq!(sess.clone_history().await.raw_items(), compacted_history);
|
||||
assert!(sess.reference_context_item().await.is_none());
|
||||
assert!(sess.current_window_id().await.ends_with(":7"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4801,7 +4940,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
mcp_connection_manager: Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
McpConnectionManager::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
@@ -6411,16 +6550,20 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
|
||||
expected_thread_id: ThreadId,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder {
|
||||
async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
assert!(input.session_store.get::<SessionStopMarker>().is_some());
|
||||
assert!(input.thread_store.get::<ThreadStopMarker>().is_some());
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
fn on_thread_stop<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::ThreadStopInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
assert!(input.session_store.get::<SessionStopMarker>().is_some());
|
||||
assert!(input.thread_store.get::<ThreadStopMarker>().is_some());
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6457,33 +6600,41 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
|
||||
expected_turn_id: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder {
|
||||
async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push("thread_stop");
|
||||
fn on_thread_stop<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::ThreadStopInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push("thread_stop");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder {
|
||||
async fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
assert_eq!(self.expected_turn_id, input.turn_store.level_id());
|
||||
assert_eq!(TurnAbortReason::Interrupted, input.reason);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push("turn_abort");
|
||||
fn on_turn_abort<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::TurnAbortInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
assert_eq!(self.expected_turn_id, input.turn_store.level_id());
|
||||
assert_eq!(TurnAbortReason::Interrupted, input.reason);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push("turn_abort");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6867,7 +7018,7 @@ where
|
||||
);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
mcp_connection_manager: Arc::new(arc_swap::ArcSwap::from_pointee(
|
||||
McpConnectionManager::new_uninitialized_with_permission_profile(
|
||||
&config.permissions.approval_policy,
|
||||
config.permissions.permission_profile(),
|
||||
@@ -7312,6 +7463,7 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en
|
||||
previous_context_item.realtime_active = None;
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: previous_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(true),
|
||||
};
|
||||
let mut current_context = previous_context
|
||||
@@ -7905,6 +8057,7 @@ async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(true),
|
||||
};
|
||||
|
||||
@@ -7927,6 +8080,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is
|
||||
turn_context.realtime_active = true;
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(true),
|
||||
};
|
||||
|
||||
@@ -7960,6 +8114,18 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy
|
||||
policy
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_context_item_uses_turn_context_comp_hash_snapshot() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.comp_hash = Some("turn-context-hash".to_string());
|
||||
turn_context.model_info.comp_hash = Some("model-info-hash".to_string());
|
||||
|
||||
assert_eq!(
|
||||
turn_context.to_turn_context_item().comp_hash.as_deref(),
|
||||
Some("turn-context-hash")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
|
||||
let (_session, turn_context) = make_session_and_context().await;
|
||||
@@ -8151,6 +8317,7 @@ async fn build_initial_context_prepends_model_switch_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: "previous-regular-model".to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: None,
|
||||
};
|
||||
|
||||
@@ -8203,6 +8370,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
|
||||
session
|
||||
.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: previous_context.model_info.slug.clone(),
|
||||
comp_hash: None,
|
||||
realtime_active: Some(previous_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
@@ -8698,15 +8866,19 @@ async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() {
|
||||
expected_thread_id: ThreadId,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
|
||||
async fn on_thread_idle(&self, input: codex_extension_api::ThreadIdleInput<'_>) {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.idle_tx.send(()).await.expect("idle receiver open");
|
||||
fn on_thread_idle<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::ThreadIdleInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(
|
||||
self.expected_thread_id.to_string(),
|
||||
input.thread_store.level_id()
|
||||
);
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.idle_tx.send(()).await.expect("idle receiver open");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8740,10 +8912,14 @@ async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
|
||||
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
|
||||
async fn on_thread_idle(&self, _input: codex_extension_api::ThreadIdleInput<'_>) {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
fn on_thread_idle<'a>(
|
||||
&'a self,
|
||||
_input: codex_extension_api::ThreadIdleInput<'a>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9405,18 +9581,13 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "test builds a router from session-owned MCP manager state"
|
||||
)]
|
||||
async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
||||
let tools = {
|
||||
session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_all_tools()
|
||||
.await
|
||||
};
|
||||
@@ -9552,6 +9723,7 @@ async fn sample_rollout(
|
||||
rollout_items.push(RolloutItem::Compacted(CompactedItem {
|
||||
message: summary1.to_string(),
|
||||
replacement_history: None,
|
||||
window_id: None,
|
||||
}));
|
||||
|
||||
let user2 = ResponseItem::Message {
|
||||
@@ -9592,6 +9764,7 @@ async fn sample_rollout(
|
||||
rollout_items.push(RolloutItem::Compacted(CompactedItem {
|
||||
message: summary2.to_string(),
|
||||
replacement_history: None,
|
||||
window_id: None,
|
||||
}));
|
||||
|
||||
let user3 = ResponseItem::Message {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::session::Session;
|
||||
use super::turn_context::TurnContext;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_features::Feature;
|
||||
|
||||
const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75];
|
||||
|
||||
pub(super) async fn maybe_record_token_budget_remaining_context(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
tokens_before_sampling: i64,
|
||||
tokens_after_sampling: i64,
|
||||
) {
|
||||
if !turn_context.features.enabled(Feature::TokenBudget) {
|
||||
return;
|
||||
}
|
||||
let Some(model_context_window) = turn_context.model_context_window() else {
|
||||
return;
|
||||
};
|
||||
if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling {
|
||||
return;
|
||||
}
|
||||
|
||||
let tokens_before_sampling = tokens_before_sampling.max(0);
|
||||
let tokens_after_sampling = tokens_after_sampling.max(0);
|
||||
let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| {
|
||||
tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold)
|
||||
&& tokens_after_sampling.saturating_mul(100)
|
||||
>= model_context_window.saturating_mul(*threshold)
|
||||
});
|
||||
if !crossed_threshold {
|
||||
return;
|
||||
}
|
||||
|
||||
let tokens_left = model_context_window
|
||||
.saturating_sub(tokens_after_sampling)
|
||||
.max(0);
|
||||
|
||||
let response_item = ContextualUserFragment::into(
|
||||
crate::context::TokenBudgetRemainingContext::new(tokens_left),
|
||||
);
|
||||
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
}
|
||||
@@ -172,6 +172,7 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
comp_hash: turn_context.comp_hash.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
@@ -186,10 +187,9 @@ pub(crate) async fn run_turn(
|
||||
let mut stop_hook_active = false;
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
let display_roots = turn_diff_display_roots(turn_context.as_ref()).await;
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(
|
||||
TurnDiffTracker::with_environment_display_roots(
|
||||
turn_diff_display_roots(turn_context.as_ref()).await,
|
||||
),
|
||||
TurnDiffTracker::with_environment_display_roots(display_roots),
|
||||
));
|
||||
|
||||
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
|
||||
@@ -214,22 +214,26 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
|
||||
// Construct the input that we will send to the model.
|
||||
let sampling_request_input: Vec<ResponseItem> = {
|
||||
let sampling_request_input: Vec<ResponseItem> = async {
|
||||
sess.clone_history()
|
||||
.await
|
||||
.for_prompt(&turn_context.model_info.input_modalities)
|
||||
};
|
||||
}
|
||||
.instrument(trace_span!("run_turn.prepare_sampling_request_input"))
|
||||
.await;
|
||||
|
||||
let window_id = sess.services.model_client.current_window_id();
|
||||
let window_id = sess.current_window_id().await;
|
||||
let turn_metadata_header = turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_model_request(&window_id);
|
||||
let tokens_before_sampling = sess.get_total_token_usage().await;
|
||||
match run_sampling_request(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&turn_extension_data),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
&mut client_session,
|
||||
&window_id,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input.clone(),
|
||||
cancellation_token.child_token(),
|
||||
@@ -242,15 +246,20 @@ pub(crate) async fn run_turn(
|
||||
last_agent_message: sampling_request_last_agent_message,
|
||||
} = sampling_request_output;
|
||||
can_drain_pending_input = true;
|
||||
let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await;
|
||||
let (has_pending_input, token_status, estimated_token_count) = async {
|
||||
let has_pending_input =
|
||||
sess.input_queue.has_pending_input(&sess.active_turn).await;
|
||||
let token_status =
|
||||
auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
|
||||
let estimated_token_count =
|
||||
sess.get_estimated_token_count(turn_context.as_ref()).await;
|
||||
(has_pending_input, token_status, estimated_token_count)
|
||||
}
|
||||
.instrument(trace_span!("run_turn.collect_post_sampling_state"))
|
||||
.await;
|
||||
let needs_follow_up = model_needs_follow_up || has_pending_input;
|
||||
let token_status =
|
||||
auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
|
||||
let token_limit_reached = token_status.token_limit_reached;
|
||||
|
||||
let estimated_token_count =
|
||||
sess.get_estimated_token_count(turn_context.as_ref()).await;
|
||||
|
||||
trace!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
total_usage_tokens = token_status.active_context_tokens,
|
||||
@@ -258,7 +267,6 @@ pub(crate) async fn run_turn(
|
||||
estimated_token_count = ?estimated_token_count,
|
||||
auto_compact_scope_limit = token_status.auto_compact_scope_limit,
|
||||
auto_compact_limit_scope = ?turn_context.config.model_auto_compact_token_limit_scope,
|
||||
auto_compact_window_ordinal = ?token_status.auto_compact_window_ordinal,
|
||||
auto_compact_window_prefill_tokens = ?token_status.auto_compact_window_prefill_tokens,
|
||||
full_context_window_limit = ?token_status.full_context_window_limit,
|
||||
full_context_window_limit_reached = token_status.full_context_window_limit_reached,
|
||||
@@ -269,6 +277,24 @@ pub(crate) async fn run_turn(
|
||||
"post sampling token usage"
|
||||
);
|
||||
|
||||
let tokens_after_sampling = token_status.active_context_tokens;
|
||||
super::token_budget::maybe_record_token_budget_remaining_context(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
tokens_before_sampling,
|
||||
tokens_after_sampling,
|
||||
)
|
||||
.await;
|
||||
|
||||
let started_new_context_window = sess
|
||||
.maybe_start_new_context_window(turn_context.as_ref())
|
||||
.await
|
||||
.is_some();
|
||||
if started_new_context_window && needs_follow_up {
|
||||
can_drain_pending_input = !model_needs_follow_up;
|
||||
continue;
|
||||
}
|
||||
|
||||
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
|
||||
if token_limit_reached && needs_follow_up {
|
||||
if let Err(err) = run_auto_compact(
|
||||
@@ -381,6 +407,7 @@ pub(crate) async fn run_turn(
|
||||
last_agent_message
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> {
|
||||
let mut display_roots = Vec::new();
|
||||
for turn_environment in &turn_context.environments.turn_environments {
|
||||
@@ -396,6 +423,7 @@ async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, Pat
|
||||
display_roots
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
async fn run_hooks_and_record_inputs(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -424,10 +452,7 @@ async fn run_hooks_and_record_inputs(
|
||||
blocked_input && !accepted_user_input
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP tool listing borrows the read guard across cancellation-aware await"
|
||||
)]
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
async fn build_skills_and_plugins(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &TurnContext,
|
||||
@@ -464,8 +489,7 @@ async fn build_skills_and_plugins(
|
||||
match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_all_tools()
|
||||
.or_cancel(cancellation_token)
|
||||
.await
|
||||
@@ -698,7 +722,6 @@ struct AutoCompactTokenStatus {
|
||||
auto_compact_scope_tokens: i64,
|
||||
auto_compact_scope_limit: i64,
|
||||
full_context_window_limit: Option<i64>,
|
||||
auto_compact_window_ordinal: Option<u64>,
|
||||
auto_compact_window_prefill_tokens: Option<i64>,
|
||||
full_context_window_limit_reached: bool,
|
||||
token_limit_reached: bool,
|
||||
@@ -709,7 +732,6 @@ async fn auto_compact_token_status(
|
||||
turn_context: &TurnContext,
|
||||
) -> AutoCompactTokenStatus {
|
||||
let active_context_tokens = sess.get_total_token_usage().await;
|
||||
let mut auto_compact_window_ordinal = None;
|
||||
let mut auto_compact_window_prefill_tokens = None;
|
||||
let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) =
|
||||
match turn_context.config.model_auto_compact_token_limit_scope {
|
||||
@@ -723,7 +745,6 @@ async fn auto_compact_token_status(
|
||||
),
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix => {
|
||||
let window = sess.auto_compact_window_snapshot().await;
|
||||
auto_compact_window_ordinal = Some(window.ordinal);
|
||||
auto_compact_window_prefill_tokens = window.prefill_input_tokens;
|
||||
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
|
||||
(
|
||||
@@ -749,13 +770,13 @@ async fn auto_compact_token_status(
|
||||
auto_compact_scope_tokens,
|
||||
auto_compact_scope_limit,
|
||||
full_context_window_limit,
|
||||
auto_compact_window_ordinal,
|
||||
auto_compact_window_prefill_tokens,
|
||||
full_context_window_limit_reached,
|
||||
token_limit_reached,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
async fn run_pre_sampling_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -778,8 +799,16 @@ async fn run_pre_sampling_compact(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs pre-sampling compaction against the previous model when switching to a smaller
|
||||
/// context-window model.
|
||||
/// Returns true only when both turns declare compaction compatibility hashes and they differ.
|
||||
/// A missing hash does not provide enough information to trigger compaction.
|
||||
fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool {
|
||||
previous
|
||||
.zip(current)
|
||||
.is_some_and(|(previous, current)| previous != current)
|
||||
}
|
||||
|
||||
/// Runs pre-sampling compaction against the previous model when its compaction compatibility
|
||||
/// hash changed or when switching to a smaller context-window model.
|
||||
///
|
||||
/// Returns `Err(_)` only when compaction was attempted and failed.
|
||||
async fn maybe_run_previous_model_inline_compact(
|
||||
@@ -790,12 +819,29 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let should_compact_for_comp_hash_change = comp_hash_changed(
|
||||
previous_turn_settings.comp_hash.as_deref(),
|
||||
turn_context.comp_hash.as_deref(),
|
||||
);
|
||||
let previous_model_turn_context = Arc::new(
|
||||
turn_context
|
||||
.with_model(previous_turn_settings.model, &sess.services.models_manager)
|
||||
.await,
|
||||
);
|
||||
|
||||
if should_compact_for_comp_hash_change {
|
||||
run_auto_compact(
|
||||
sess,
|
||||
&previous_model_turn_context,
|
||||
client_session,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionReason::CompHashChanged,
|
||||
CompactionPhase::PreTurn,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(old_context_window) = previous_model_turn_context.model_context_window() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -834,6 +880,11 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
level = "trace",
|
||||
skip_all,
|
||||
fields(reason = ?reason, phase = ?phase)
|
||||
)]
|
||||
async fn run_auto_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -942,6 +993,7 @@ pub(super) fn collect_explicit_app_ids_from_skill_items(
|
||||
connector_ids
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) fn build_prompt(
|
||||
input: Vec<ResponseItem>,
|
||||
router: &ToolRouter,
|
||||
@@ -977,6 +1029,7 @@ async fn run_sampling_request(
|
||||
turn_store: Arc<codex_extension_api::ExtensionData>,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
client_session: &mut ModelClientSession,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
input: Vec<ResponseItem>,
|
||||
cancellation_token: CancellationToken,
|
||||
@@ -1020,6 +1073,7 @@ async fn run_sampling_request(
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&turn_store),
|
||||
client_session,
|
||||
window_id,
|
||||
turn_metadata_header,
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
&prompt,
|
||||
@@ -1062,10 +1116,6 @@ async fn run_sampling_request(
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "tool router construction reads through the session-owned manager guard"
|
||||
)]
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
@@ -1079,22 +1129,17 @@ pub(crate) async fn built_tools(
|
||||
turn_context: &TurnContext,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> CodexResult<Arc<ToolRouter>> {
|
||||
let mcp_connection_manager = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.instrument(trace_span!("read_mcp_connection_manager"))
|
||||
.await;
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.load_full();
|
||||
let has_mcp_servers = mcp_connection_manager.has_servers();
|
||||
let all_mcp_tools = mcp_connection_manager
|
||||
.list_all_tools()
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
drop(mcp_connection_manager);
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config.plugins_config_input())
|
||||
.instrument(trace_span!("built_tools.load_plugins"))
|
||||
.await;
|
||||
|
||||
let apps_enabled = turn_context.apps_enabled();
|
||||
@@ -1125,35 +1170,39 @@ pub(crate) async fn built_tools(
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect::<Vec<_>>();
|
||||
let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) {
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
sess.services.plugins_manager.as_ref(),
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
&loaded_plugin_app_connector_ids,
|
||||
)
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
filter_request_plugin_install_discoverable_tools_for_client(
|
||||
discoverable_tools,
|
||||
turn_context.app_server_client_name.as_deref(),
|
||||
let discoverable_tools = async {
|
||||
if apps_enabled && tool_suggest_enabled(turn_context) {
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
sess.services.plugins_manager.as_ref(),
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
&loaded_plugin_app_connector_ids,
|
||||
)
|
||||
}) {
|
||||
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
|
||||
Ok(discoverable_tools) => Some(discoverable_tools),
|
||||
Err(err) => {
|
||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||
None
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
filter_request_plugin_install_discoverable_tools_for_client(
|
||||
discoverable_tools,
|
||||
turn_context.app_server_client_name.as_deref(),
|
||||
)
|
||||
}) {
|
||||
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
|
||||
Ok(discoverable_tools) => Some(discoverable_tools),
|
||||
Err(err) => {
|
||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
.instrument(trace_span!("built_tools.load_discoverable_tools"))
|
||||
.await;
|
||||
|
||||
let mcp_tool_exposure = build_mcp_tool_exposure(
|
||||
&all_mcp_tools,
|
||||
@@ -1754,6 +1803,7 @@ async fn try_run_sampling_request(
|
||||
turn_context: Arc<TurnContext>,
|
||||
turn_store: Arc<codex_extension_api::ExtensionData>,
|
||||
client_session: &mut ModelClientSession,
|
||||
window_id: &str,
|
||||
turn_metadata_header: Option<&str>,
|
||||
turn_diff_tracker: SharedTurnDiffTracker,
|
||||
prompt: &Prompt,
|
||||
@@ -1775,6 +1825,7 @@ async fn try_run_sampling_request(
|
||||
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
window_id,
|
||||
prompt,
|
||||
&turn_context.model_info,
|
||||
&turn_context.session_telemetry,
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct TurnContext {
|
||||
pub config: Arc<Config>,
|
||||
pub(crate) auth_manager: Option<Arc<AuthManager>>,
|
||||
pub(crate) model_info: ModelInfo,
|
||||
pub(crate) comp_hash: Option<String>,
|
||||
pub(crate) tool_mode: ToolMode,
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
pub(crate) provider: SharedModelProvider,
|
||||
@@ -229,6 +230,7 @@ impl TurnContext {
|
||||
config: Arc::new(config),
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
model_info: model_info.clone(),
|
||||
comp_hash: model_info.comp_hash.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: self
|
||||
.session_telemetry
|
||||
@@ -355,6 +357,7 @@ impl TurnContext {
|
||||
network: self.turn_context_network_item(),
|
||||
file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(),
|
||||
model: self.model_info.slug.clone(),
|
||||
comp_hash: self.comp_hash.clone(),
|
||||
personality: self.personality,
|
||||
collaboration_mode: Some(self.collaboration_mode.clone()),
|
||||
multi_agent_version: Some(self.multi_agent_version),
|
||||
@@ -531,6 +534,7 @@ impl Session {
|
||||
config: per_turn_config.clone(),
|
||||
auth_manager: auth_manager_for_context,
|
||||
model_info: model_info.clone(),
|
||||
comp_hash: model_info.comp_hash.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: session_telemetry_for_context,
|
||||
provider: provider_for_context,
|
||||
@@ -724,7 +728,7 @@ impl Session {
|
||||
.unwrap_or_else(|| session_configuration.cwd().clone());
|
||||
let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone());
|
||||
{
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.load_full();
|
||||
mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy);
|
||||
mcp_connection_manager
|
||||
.set_permission_profile(session_configuration.permission_profile());
|
||||
|
||||
@@ -7,20 +7,21 @@ use std::sync::Arc;
|
||||
|
||||
struct RewriteAgentMessageContributor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnItemContributor for RewriteAgentMessageContributor {
|
||||
async fn contribute(
|
||||
&self,
|
||||
_thread_store: &ExtensionData,
|
||||
_turn_store: &ExtensionData,
|
||||
item: &mut TurnItem,
|
||||
) -> Result<(), String> {
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.content = vec![AgentMessageContent::Text {
|
||||
text: "plan contributed assistant text".to_string(),
|
||||
}];
|
||||
}
|
||||
Ok(())
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_thread_store: &'a ExtensionData,
|
||||
_turn_store: &'a ExtensionData,
|
||||
item: &'a mut TurnItem,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
|
||||
Box::pin(async move {
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.content = vec![AgentMessageContent::Text {
|
||||
text: "plan contributed assistant text".to_string(),
|
||||
}];
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Instant;
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -19,7 +20,7 @@ use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
|
||||
pub(crate) struct SessionStartupPrewarmHandle {
|
||||
task: JoinHandle<CodexResult<ModelClientSession>>,
|
||||
task: AbortOnDropHandle<CodexResult<ModelClientSession>>,
|
||||
started_at: Instant,
|
||||
timeout: Duration,
|
||||
}
|
||||
@@ -40,12 +41,17 @@ impl SessionStartupPrewarmHandle {
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
task,
|
||||
task: AbortOnDropHandle::new(task),
|
||||
started_at,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn abort(self) {
|
||||
self.task.abort();
|
||||
let _ = self.task.await;
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
self,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -260,7 +266,7 @@ async fn schedule_startup_prewarm_inner(
|
||||
build_prompt_started_at.elapsed(),
|
||||
/*status*/ None,
|
||||
);
|
||||
let window_id = session.services.model_client.current_window_id();
|
||||
let window_id = session.current_window_id().await;
|
||||
let startup_turn_metadata_header = startup_turn_context
|
||||
.turn_metadata_state
|
||||
.current_header_value_for_prewarm(&window_id);
|
||||
@@ -268,6 +274,7 @@ async fn schedule_startup_prewarm_inner(
|
||||
let websocket_warmup_started_at = Instant::now();
|
||||
client_session
|
||||
.prewarm_websocket(
|
||||
&window_id,
|
||||
&startup_prompt,
|
||||
&startup_turn_context.model_info,
|
||||
&startup_turn_context.session_telemetry,
|
||||
|
||||
@@ -2,7 +2,6 @@ use codex_protocol::protocol::TokenUsage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AutoCompactWindowSnapshot {
|
||||
pub(crate) ordinal: u64,
|
||||
pub(crate) prefill_input_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -14,7 +13,8 @@ enum AutoCompactWindowPrefill {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct AutoCompactWindow {
|
||||
ordinal: u64,
|
||||
window_id: u64,
|
||||
new_context_window_requested: bool,
|
||||
/// Absolute input-token baseline for the current compaction window.
|
||||
///
|
||||
/// `body_after_prefix` subtracts this from later active-context usage. It is
|
||||
@@ -26,7 +26,8 @@ pub(super) struct AutoCompactWindow {
|
||||
impl AutoCompactWindow {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
ordinal: 1,
|
||||
window_id: 0,
|
||||
new_context_window_requested: false,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
}
|
||||
@@ -35,9 +36,28 @@ impl AutoCompactWindow {
|
||||
self.prefill_input_tokens = None;
|
||||
}
|
||||
|
||||
pub(super) fn start_next(&mut self) {
|
||||
self.ordinal = self.ordinal.saturating_add(1);
|
||||
self.clear_prefill();
|
||||
pub(super) fn window_id(&self) -> u64 {
|
||||
self.window_id
|
||||
}
|
||||
|
||||
pub(super) fn set_window_id(&mut self, window_id: u64) {
|
||||
self.window_id = window_id;
|
||||
}
|
||||
|
||||
pub(super) fn advance_window_id(&mut self) -> u64 {
|
||||
self.window_id = self.window_id.saturating_add(1);
|
||||
self.new_context_window_requested = false;
|
||||
self.window_id
|
||||
}
|
||||
|
||||
pub(super) fn request_new_context_window(&mut self) {
|
||||
self.new_context_window_requested = true;
|
||||
}
|
||||
|
||||
pub(super) fn take_new_context_window_request(&mut self) -> bool {
|
||||
let requested = self.new_context_window_requested;
|
||||
self.new_context_window_requested = false;
|
||||
requested
|
||||
}
|
||||
|
||||
/// Records the request-input side of the first server usage sample. The
|
||||
@@ -74,7 +94,6 @@ impl AutoCompactWindow {
|
||||
None => None,
|
||||
};
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: self.ordinal,
|
||||
prefill_input_tokens,
|
||||
}
|
||||
}
|
||||
@@ -89,10 +108,20 @@ mod tests {
|
||||
fn tracks_prefill_and_window_boundaries() {
|
||||
let mut window = AutoCompactWindow::new();
|
||||
|
||||
assert_eq!(window.window_id(), 0);
|
||||
window.set_window_id(/*window_id*/ 3);
|
||||
assert_eq!(window.window_id(), 3);
|
||||
window.request_new_context_window();
|
||||
assert!(window.take_new_context_window_request());
|
||||
assert!(!window.take_new_context_window_request());
|
||||
window.request_new_context_window();
|
||||
assert_eq!(window.advance_window_id(), 4);
|
||||
assert_eq!(window.window_id(), 4);
|
||||
assert!(!window.take_new_context_window_request());
|
||||
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
@@ -101,7 +130,6 @@ mod tests {
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(150),
|
||||
}
|
||||
);
|
||||
@@ -114,7 +142,6 @@ mod tests {
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(120),
|
||||
}
|
||||
);
|
||||
@@ -128,18 +155,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 1,
|
||||
prefill_input_tokens: Some(120),
|
||||
}
|
||||
);
|
||||
|
||||
window.start_next();
|
||||
assert_eq!(
|
||||
window.snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 2,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::tools::code_mode::CodeModeService;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use arc_swap::ArcSwapOption;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
@@ -34,12 +35,12 @@ use codex_thread_store::ThreadStore;
|
||||
use std::path::PathBuf;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
/// The latest manager; callers retain an owned handle while performing MCP I/O.
|
||||
pub(crate) mcp_connection_manager: Arc<ArcSwap<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
@@ -82,3 +83,18 @@ pub(crate) struct SessionServices {
|
||||
/// the same manager through child-thread spawn paths without reconstructing it.
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
}
|
||||
|
||||
impl SessionServices {
|
||||
/// Installs the manager before validating required servers so startup-time elicitation can
|
||||
/// resolve through the session's manager while validation waits.
|
||||
pub(crate) async fn install_mcp_connection_manager(
|
||||
&self,
|
||||
manager: McpConnectionManager,
|
||||
) -> Result<()> {
|
||||
self.mcp_connection_manager.store(Arc::new(manager));
|
||||
self.mcp_connection_manager
|
||||
.load_full()
|
||||
.validate_required_servers()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,14 +140,36 @@ impl SessionState {
|
||||
self.auto_compact_window.set_estimated_prefill(tokens);
|
||||
}
|
||||
|
||||
pub(crate) fn start_next_auto_compact_window(&mut self) {
|
||||
self.auto_compact_window.start_next();
|
||||
}
|
||||
|
||||
pub(crate) fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
|
||||
self.auto_compact_window.snapshot()
|
||||
}
|
||||
|
||||
pub(crate) fn auto_compact_window_id(&self) -> u64 {
|
||||
self.auto_compact_window.window_id()
|
||||
}
|
||||
|
||||
pub(crate) fn set_auto_compact_window_id(&mut self, window_id: u64) {
|
||||
self.auto_compact_window.set_window_id(window_id);
|
||||
}
|
||||
|
||||
pub(crate) fn advance_auto_compact_window_id(&mut self) -> u64 {
|
||||
self.auto_compact_window.advance_window_id()
|
||||
}
|
||||
|
||||
pub(crate) fn request_new_context_window(&mut self) {
|
||||
self.auto_compact_window.request_new_context_window();
|
||||
}
|
||||
|
||||
pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<u64> {
|
||||
if !self.auto_compact_window.take_new_context_window_request() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let window_id = self.auto_compact_window.advance_window_id();
|
||||
self.auto_compact_window.clear_prefill();
|
||||
Some(window_id)
|
||||
}
|
||||
|
||||
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
|
||||
self.history.token_info()
|
||||
}
|
||||
|
||||
@@ -65,18 +65,16 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_history_clears_auto_compact_window_prefill_without_advancing() {
|
||||
async fn replace_history_clears_auto_compact_window_prefill() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
state.start_next_auto_compact_window();
|
||||
state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100);
|
||||
state.replace_history(Vec::new(), /*reference_context_item*/ None);
|
||||
|
||||
assert_eq!(
|
||||
state.auto_compact_window_snapshot(),
|
||||
AutoCompactWindowSnapshot {
|
||||
ordinal: 2,
|
||||
prefill_input_tokens: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -167,41 +167,43 @@ struct TestTurnItemContributor;
|
||||
#[derive(Debug)]
|
||||
struct TurnItemContributorRan;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnItemContributor for TestTurnItemContributor {
|
||||
async fn contribute(
|
||||
&self,
|
||||
_thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
item: &mut TurnItem,
|
||||
) -> Result<(), String> {
|
||||
turn_store.insert(TurnItemContributorRan);
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.memory_citation = Some(MemoryCitation {
|
||||
entries: Vec::new(),
|
||||
rollout_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_thread_store: &'a ExtensionData,
|
||||
turn_store: &'a ExtensionData,
|
||||
item: &'a mut TurnItem,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
|
||||
Box::pin(async move {
|
||||
turn_store.insert(TurnItemContributorRan);
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.memory_citation = Some(MemoryCitation {
|
||||
entries: Vec::new(),
|
||||
rollout_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct RewriteAgentMessageContributor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnItemContributor for RewriteAgentMessageContributor {
|
||||
async fn contribute(
|
||||
&self,
|
||||
_thread_store: &ExtensionData,
|
||||
_turn_store: &ExtensionData,
|
||||
item: &mut TurnItem,
|
||||
) -> Result<(), String> {
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.content = vec![AgentMessageContent::Text {
|
||||
text: "contributed assistant text".to_string(),
|
||||
}];
|
||||
}
|
||||
Ok(())
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_thread_store: &'a ExtensionData,
|
||||
_turn_store: &'a ExtensionData,
|
||||
item: &'a mut TurnItem,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
|
||||
Box::pin(async move {
|
||||
if let TurnItem::AgentMessage(agent_message) = item {
|
||||
agent_message.content = vec![AgentMessageContent::Text {
|
||||
text: "contributed assistant text".to_string(),
|
||||
}];
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use tracing::info_span;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::codex_thread::BackgroundTerminalInfo;
|
||||
use crate::config::Config;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::hook_runtime::inspect_pending_input;
|
||||
@@ -782,6 +783,17 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo> {
|
||||
self.services.unified_exec_manager.list_processes().await
|
||||
}
|
||||
|
||||
pub(crate) async fn terminate_background_terminal(&self, process_id: i32) -> bool {
|
||||
self.services
|
||||
.unified_exec_manager
|
||||
.terminate_process(process_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
|
||||
let sub_id = task.turn_context.sub_id.clone();
|
||||
if task.cancellation_token.is_cancelled() {
|
||||
|
||||
@@ -282,6 +282,7 @@ impl ThreadManager {
|
||||
codex_home.to_path_buf(),
|
||||
restriction_product,
|
||||
));
|
||||
plugins_manager.set_auth_mode(auth_manager.get_api_auth_mode());
|
||||
let mcp_manager = Arc::new(McpManager::new_with_extensions(
|
||||
Arc::clone(&plugins_manager),
|
||||
Arc::clone(&extensions),
|
||||
@@ -375,6 +376,7 @@ impl ThreadManager {
|
||||
codex_home.clone(),
|
||||
restriction_product,
|
||||
));
|
||||
plugins_manager.set_auth_mode(auth_manager.get_api_auth_mode());
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
skills_codex_home,
|
||||
@@ -525,14 +527,12 @@ impl ThreadManager {
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> CodexResult<Vec<ThreadId>> {
|
||||
let thread = self.state.get_thread(thread_id).await?;
|
||||
|
||||
let mut subtree_thread_ids = Vec::new();
|
||||
let mut seen_thread_ids = HashSet::new();
|
||||
subtree_thread_ids.push(thread_id);
|
||||
seen_thread_ids.insert(thread_id);
|
||||
|
||||
if let Some(state_db_ctx) = thread.state_db() {
|
||||
if let Some(state_db_ctx) = self.state.state_db() {
|
||||
for status in [
|
||||
DirectionalThreadSpawnEdgeStatus::Open,
|
||||
DirectionalThreadSpawnEdgeStatus::Closed,
|
||||
@@ -551,11 +551,8 @@ impl ThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
for descendant_id in thread
|
||||
.codex
|
||||
.session
|
||||
.services
|
||||
.agent_control
|
||||
for descendant_id in self
|
||||
.agent_control()
|
||||
.list_live_agent_subtree_thread_ids(thread_id)
|
||||
.await?
|
||||
{
|
||||
|
||||
@@ -394,20 +394,24 @@ async fn start_thread_seeds_extension_data_before_lifecycle_contributors_run() {
|
||||
observed: Arc<std::sync::Mutex<Option<(String, String)>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ThreadLifecycleContributor<Config> for InitialDataRecorder {
|
||||
async fn on_thread_start(&self, input: codex_extension_api::ThreadStartInput<'_, Config>) {
|
||||
let marker = input
|
||||
.thread_store
|
||||
.get::<InitialMarker>()
|
||||
.expect("initial extension data should be available");
|
||||
*self
|
||||
.observed
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((
|
||||
input.thread_store.level_id().to_string(),
|
||||
marker.0.to_string(),
|
||||
));
|
||||
fn on_thread_start<'a>(
|
||||
&'a self,
|
||||
input: codex_extension_api::ThreadStartInput<'a, Config>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let marker = input
|
||||
.thread_store
|
||||
.get::<InitialMarker>()
|
||||
.expect("initial extension data should be available");
|
||||
*self
|
||||
.observed
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((
|
||||
input.thread_store.level_id().to_string(),
|
||||
marker.0.to_string(),
|
||||
));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ impl CodeModeExecuteHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(PUBLIC_TOOL_NAME)
|
||||
@@ -101,7 +100,13 @@ impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
|
||||
self.spec.clone()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeExecuteHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -44,7 +44,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(WAIT_TOOL_NAME)
|
||||
@@ -54,7 +53,13 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
|
||||
create_wait_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeWaitHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -588,7 +588,7 @@ async fn emit_patch_end(
|
||||
if let Some(tracker) = ctx.turn_diff_tracker {
|
||||
let (should_emit_turn_diff, unified_diff) = {
|
||||
let mut guard = tracker.lock().await;
|
||||
let previous_diff = guard.get_unified_diff();
|
||||
let had_unified_diff = guard.has_unified_diff();
|
||||
let tracker_changed = match tracker_update {
|
||||
TurnDiffTrackerUpdate::Track {
|
||||
environment_id,
|
||||
@@ -605,7 +605,7 @@ async fn emit_patch_end(
|
||||
};
|
||||
let unified_diff = guard.get_unified_diff();
|
||||
(
|
||||
tracker_changed && (previous_diff.is_some() || unified_diff.is_some()),
|
||||
tracker_changed && (had_unified_diff || unified_diff.is_some()),
|
||||
unified_diff.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
@@ -718,4 +718,98 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn net_zero_patch_emits_empty_turn_diff() {
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd");
|
||||
|
||||
for patch in [
|
||||
"*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch",
|
||||
"*** Begin Patch\n*** Delete File: a.txt\n*** End Patch",
|
||||
] {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let delta = codex_apply_patch::apply_patch(
|
||||
patch,
|
||||
&cwd,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("apply patch");
|
||||
|
||||
emit_patch_end(
|
||||
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
|
||||
HashMap::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
PatchApplyStatus::Completed,
|
||||
TurnDiffTrackerUpdate::Track {
|
||||
environment_id: None,
|
||||
delta: &delta,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
rx_event.recv().await.expect("item completed event");
|
||||
let unified_diff = loop {
|
||||
let event = rx_event.recv().await.expect("turn diff event");
|
||||
if let EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) = event.msg {
|
||||
break unified_diff;
|
||||
}
|
||||
};
|
||||
if patch.contains("Delete File") {
|
||||
assert_eq!(unified_diff, "");
|
||||
} else {
|
||||
assert!(unified_diff.contains("+one"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidation_emits_empty_turn_diff() {
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd");
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let delta = codex_apply_patch::apply_patch(
|
||||
"*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch",
|
||||
&cwd,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("apply patch");
|
||||
tracker.lock().await.track_delta("", &delta);
|
||||
|
||||
emit_patch_end(
|
||||
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
|
||||
HashMap::new(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
PatchApplyStatus::Completed,
|
||||
TurnDiffTrackerUpdate::Invalidate,
|
||||
)
|
||||
.await;
|
||||
|
||||
rx_event.recv().await.expect("item completed event");
|
||||
loop {
|
||||
let event = rx_event.recv().await.expect("turn diff event");
|
||||
if let EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) = event.msg {
|
||||
assert_eq!(unified_diff, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use super::*;
|
||||
|
||||
pub struct ReportAgentJobResultHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ReportAgentJobResultHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("report_agent_job_result")
|
||||
@@ -23,7 +22,13 @@ impl ToolExecutor<ToolInvocation> for ReportAgentJobResultHandler {
|
||||
create_report_agent_job_result_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportAgentJobResultHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -14,7 +14,6 @@ use super::*;
|
||||
|
||||
pub struct SpawnAgentsOnCsvHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for SpawnAgentsOnCsvHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("spawn_agents_on_csv")
|
||||
@@ -24,7 +23,13 @@ impl ToolExecutor<ToolInvocation> for SpawnAgentsOnCsvHandler {
|
||||
create_spawn_agents_on_csv_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnAgentsOnCsvHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -305,7 +305,6 @@ async fn effective_patch_permissions(
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("apply_patch")
|
||||
@@ -315,7 +314,13 @@ impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
|
||||
create_apply_patch_freeform_tool(self.multi_environment)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplyPatchHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -33,7 +33,6 @@ pub struct DynamicToolHandler {
|
||||
tool_name: ToolName,
|
||||
spec: ToolSpec,
|
||||
exposure: ToolExposure,
|
||||
search_text: String,
|
||||
}
|
||||
|
||||
impl DynamicToolHandler {
|
||||
@@ -56,12 +55,10 @@ impl DynamicToolHandler {
|
||||
} else {
|
||||
ToolExposure::Direct
|
||||
},
|
||||
search_text: build_dynamic_search_text(tool),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
self.tool_name.clone()
|
||||
@@ -76,8 +73,7 @@ impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
|
||||
}
|
||||
|
||||
fn search_info(&self) -> Option<ToolSearchInfo> {
|
||||
ToolSearchInfo::from_spec(
|
||||
self.search_text.clone(),
|
||||
ToolSearchInfo::from_tool_spec(
|
||||
self.spec(),
|
||||
Some(ToolSearchSourceInfo {
|
||||
name: "Dynamic tools".to_string(),
|
||||
@@ -86,7 +82,13 @@ impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicToolHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
@@ -212,27 +214,3 @@ async fn request_dynamic_tool(
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String {
|
||||
let mut schema_properties = tool
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|map| map.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
schema_properties.sort();
|
||||
let mut parts = vec![
|
||||
tool.name.clone(),
|
||||
tool.name.replace('_', " "),
|
||||
tool.description.clone(),
|
||||
];
|
||||
if let Some(namespace) = &tool.namespace {
|
||||
parts.push(namespace.clone());
|
||||
}
|
||||
parts.extend(schema_properties);
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dynamic_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
use super::*;
|
||||
use codex_tools::ToolSearchSourceInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn search_info_uses_dynamic_tool_metadata_and_parameter_names() {
|
||||
let handler = DynamicToolHandler::new(&DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create or update automations.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timezone": { "type": "string" },
|
||||
"mode": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
defer_loading: true,
|
||||
})
|
||||
.expect("dynamic handler should be created");
|
||||
|
||||
let search_info = handler.search_info().expect("dynamic search info");
|
||||
|
||||
assert_eq!(
|
||||
search_info.entry.search_text,
|
||||
"automation_update automation update Create or update automations. codex_app mode timezone"
|
||||
);
|
||||
assert_eq!(
|
||||
search_info.source_info,
|
||||
Some(ToolSearchSourceInfo {
|
||||
name: "Dynamic tools".to_string(),
|
||||
description: Some("Tools provided by the current Codex thread.".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -11,13 +11,11 @@ use codex_tools::ToolSpec;
|
||||
use codex_tools::TurnItemEmissionFuture;
|
||||
use codex_tools::TurnItemEmitter;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::stream_events_utils::TurnItemContributorPolicy;
|
||||
use crate::stream_events_utils::finalize_turn_item;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
@@ -30,7 +28,6 @@ impl ExtensionToolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
self.0.tool_name()
|
||||
@@ -52,11 +49,8 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
|
||||
self.0.search_info()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
|
||||
self.0.handle(to_extension_call(&invocation).await).await
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { self.0.handle(to_extension_call(&invocation).await).await })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +156,6 @@ mod tests {
|
||||
|
||||
struct StubExtensionExecutor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for StubExtensionExecutor {
|
||||
fn tool_name(&self) -> codex_tools::ToolName {
|
||||
codex_tools::ToolName::plain("extension_echo")
|
||||
@@ -187,13 +180,13 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
Ok(Box::new(codex_tools::JsonToolOutput::new(
|
||||
json!({ "ok": true }),
|
||||
)))
|
||||
fn handle(&self, _call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async {
|
||||
Ok(
|
||||
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
|
||||
as Box<dyn codex_tools::ToolOutput>,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +194,6 @@ mod tests {
|
||||
captured_call: Arc<Mutex<Option<codex_tools::ToolCall>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for CapturingExtensionExecutor {
|
||||
fn tool_name(&self) -> codex_tools::ToolName {
|
||||
codex_tools::ToolName::plain("extension_echo")
|
||||
@@ -218,7 +210,13 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(call))
|
||||
}
|
||||
}
|
||||
|
||||
impl CapturingExtensionExecutor {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
@@ -233,9 +231,10 @@ mod tests {
|
||||
call.turn_item_emitter.emit_started(item.clone()).await;
|
||||
call.turn_item_emitter.emit_completed(item).await;
|
||||
*self.captured_call.lock().await = Some(call);
|
||||
Ok(Box::new(codex_tools::JsonToolOutput::new(
|
||||
json!({ "ok": true }),
|
||||
)))
|
||||
Ok(
|
||||
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
|
||||
as Box<dyn codex_tools::ToolOutput>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,16 +387,17 @@ mod tests {
|
||||
|
||||
struct RecordExtensionTurnItemContributor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnItemContributor for RecordExtensionTurnItemContributor {
|
||||
async fn contribute(
|
||||
&self,
|
||||
_thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
_item: &mut TurnItem,
|
||||
) -> Result<(), String> {
|
||||
turn_store.insert(ExtensionTurnItemContributorRan);
|
||||
Ok(())
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_thread_store: &'a ExtensionData,
|
||||
turn_store: &'a ExtensionData,
|
||||
_item: &'a mut TurnItem,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
|
||||
Box::pin(async move {
|
||||
turn_store.insert(ExtensionTurnItemContributorRan);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +431,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {
|
||||
fn tool_name(&self) -> codex_tools::ToolName {
|
||||
codex_tools::ToolName::namespaced("image_gen", "imagegen")
|
||||
@@ -448,7 +447,13 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(call))
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageGenerationExtensionExecutor {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
@@ -474,9 +479,10 @@ mod tests {
|
||||
},
|
||||
))
|
||||
.await;
|
||||
Ok(Box::new(codex_tools::JsonToolOutput::new(
|
||||
json!({ "ok": true }),
|
||||
)))
|
||||
Ok(
|
||||
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
|
||||
as Box<dyn codex_tools::ToolOutput>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::get_context_remaining_spec::GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use crate::tools::handlers::get_context_remaining_spec::create_get_context_remaining_tool;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
pub struct GetContextRemainingHandler;
|
||||
|
||||
impl ToolExecutor<ToolInvocation> for GetContextRemainingHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(GET_CONTEXT_REMAINING_TOOL_NAME)
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_get_context_remaining_tool()
|
||||
}
|
||||
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move {
|
||||
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"get_context_remaining handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(model_context_window) = invocation.turn.model_context_window() else {
|
||||
let fragment = crate::context::TokenBudgetRemainingContext::unknown().render();
|
||||
return Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
fragment,
|
||||
Some(true),
|
||||
)));
|
||||
};
|
||||
let active_context_tokens = invocation.session.get_total_token_usage().await.max(0);
|
||||
let tokens_left = model_context_window
|
||||
.saturating_sub(active_context_tokens)
|
||||
.max(0);
|
||||
let fragment = crate::context::TokenBudgetRemainingContext::new(tokens_left).render();
|
||||
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
fragment,
|
||||
Some(true),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for GetContextRemainingHandler {}
|
||||
@@ -0,0 +1,17 @@
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const GET_CONTEXT_REMAINING_TOOL_NAME: &str = "get_context_remaining";
|
||||
|
||||
pub fn create_get_context_remaining_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: GET_CONTEXT_REMAINING_TOOL_NAME.to_string(),
|
||||
description: "Get the remaining tokens in the current context window.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
@@ -54,7 +54,6 @@ impl ListAvailablePluginsToInstallHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
|
||||
@@ -68,7 +67,13 @@ impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
|
||||
false
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ListAvailablePluginsToInstallHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -64,7 +64,6 @@ fn ensure_mcp_prefix(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for McpHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
self.tool_info.canonical_tool_name()
|
||||
@@ -113,7 +112,13 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl McpHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -7,6 +7,8 @@ use codex_protocol::items::McpToolCallItem;
|
||||
use codex_protocol::items::McpToolCallStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use rmcp::model::ListResourceTemplatesResult;
|
||||
use rmcp::model::ListResourcesResult;
|
||||
use rmcp::model::ReadResourceResult;
|
||||
@@ -270,7 +272,10 @@ fn normalize_required_string(field: &str, value: String) -> Result<String, Funct
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_function_output<T>(payload: T) -> Result<FunctionToolOutput, FunctionCallError>
|
||||
fn serialize_function_output<T>(
|
||||
payload: T,
|
||||
truncation_policy: TruncationPolicy,
|
||||
) -> Result<FunctionToolOutput, FunctionCallError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
@@ -279,6 +284,9 @@ where
|
||||
"failed to serialize MCP resource response: {err}"
|
||||
))
|
||||
})?;
|
||||
// Match regular MCP tool outputs by bounding the copy persisted to the
|
||||
// rollout and injected into model context.
|
||||
let content = truncate_text(&content, truncation_policy * 1.2);
|
||||
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ use super::serialize_function_output;
|
||||
|
||||
pub struct ListMcpResourceTemplatesHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("list_mcp_resource_templates")
|
||||
@@ -40,11 +39,13 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
|
||||
true
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP resource template listing reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ListMcpResourceTemplatesHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
@@ -107,8 +108,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
|
||||
let templates = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_all_resource_templates()
|
||||
.await;
|
||||
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
|
||||
@@ -117,7 +117,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
|
||||
.await;
|
||||
|
||||
match payload_result {
|
||||
Ok(payload) => match serialize_function_output(payload) {
|
||||
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
|
||||
Ok(output) => {
|
||||
let content = function_call_output_content_items_to_text(&output.body)
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -26,7 +26,6 @@ use super::serialize_function_output;
|
||||
|
||||
pub struct ListMcpResourcesHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("list_mcp_resources")
|
||||
@@ -40,11 +39,13 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
|
||||
true
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "MCP resource listing reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ListMcpResourcesHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
@@ -105,8 +106,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
|
||||
let resources = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.load_full()
|
||||
.list_all_resources()
|
||||
.await;
|
||||
Ok(ListResourcesPayload::from_all_servers(resources))
|
||||
@@ -115,7 +115,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
|
||||
.await;
|
||||
|
||||
match payload_result {
|
||||
Ok(payload) => match serialize_function_output(payload) {
|
||||
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
|
||||
Ok(output) => {
|
||||
let content = function_call_output_content_items_to_text(&output.body)
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -26,7 +26,6 @@ use super::serialize_function_output;
|
||||
|
||||
pub struct ReadMcpResourceHandler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("read_mcp_resource")
|
||||
@@ -40,7 +39,13 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadMcpResourceHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
@@ -93,7 +98,7 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
|
||||
.await;
|
||||
|
||||
match payload_result {
|
||||
Ok(payload) => match serialize_function_output(payload) {
|
||||
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
|
||||
Ok(output) => {
|
||||
let content = function_call_output_content_items_to_text(&output.body)
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::AnnotateAble;
|
||||
use rmcp::model::ResourceContents;
|
||||
use serde_json::json;
|
||||
|
||||
fn resource(uri: &str, name: &str) -> Resource {
|
||||
@@ -123,3 +124,39 @@ fn template_with_server_serializes_server_field() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_function_output_preserves_small_payload() {
|
||||
let payload = json!({"server": "hosted", "resources": []});
|
||||
let expected = serde_json::to_string(&payload).expect("serialize payload");
|
||||
|
||||
let output = serialize_function_output(payload, TruncationPolicy::Bytes(1_024))
|
||||
.expect("serialize function output")
|
||||
.into_text();
|
||||
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_function_output_caps_read_resource_payload() {
|
||||
let truncation_policy = TruncationPolicy::Bytes(8_000);
|
||||
let payload = ReadResourcePayload {
|
||||
server: "hosted".to_string(),
|
||||
uri: "skill://large/SKILL.md".to_string(),
|
||||
result: ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
|
||||
uri: "skill://large/SKILL.md".to_string(),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
text: "x".repeat(16_000),
|
||||
meta: None,
|
||||
}]),
|
||||
};
|
||||
let serialized = serde_json::to_string(&payload).expect("serialize payload");
|
||||
let expected = truncate_text(&serialized, truncation_policy * 1.2);
|
||||
|
||||
let output = serialize_function_output(payload, truncation_policy)
|
||||
.expect("serialize bounded function output")
|
||||
.into_text();
|
||||
|
||||
assert_ne!(output, serialized);
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ pub(crate) mod apply_patch;
|
||||
pub(crate) mod apply_patch_spec;
|
||||
mod dynamic;
|
||||
pub(crate) mod extension_tools;
|
||||
mod get_context_remaining;
|
||||
pub(crate) mod get_context_remaining_spec;
|
||||
mod list_available_plugins_to_install;
|
||||
pub(crate) mod list_available_plugins_to_install_spec;
|
||||
mod mcp;
|
||||
@@ -13,6 +15,8 @@ pub(crate) mod multi_agents;
|
||||
pub(crate) mod multi_agents_common;
|
||||
pub(crate) mod multi_agents_spec;
|
||||
pub(crate) mod multi_agents_v2;
|
||||
mod new_context_window;
|
||||
pub(crate) mod new_context_window_spec;
|
||||
mod plan;
|
||||
pub(crate) mod plan_spec;
|
||||
mod request_permissions;
|
||||
@@ -51,11 +55,13 @@ pub use apply_patch::ApplyPatchHandler;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
pub use get_context_remaining::GetContextRemainingHandler;
|
||||
pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler;
|
||||
pub use mcp::McpHandler;
|
||||
pub use mcp_resource::ListMcpResourceTemplatesHandler;
|
||||
pub use mcp_resource::ListMcpResourcesHandler;
|
||||
pub use mcp_resource::ReadMcpResourceHandler;
|
||||
pub use new_context_window::NewContextWindowHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub use request_plugin_install::RequestPluginInstallHandler;
|
||||
|
||||
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent")
|
||||
@@ -23,11 +22,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
handle_close_agent(invocation).await.map(boxed_tool_output)
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { handle_close_agent(invocation).await.map(boxed_tool_output) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::sync::Arc;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent")
|
||||
@@ -24,11 +23,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
handle_resume_agent(invocation).await.map(boxed_tool_output)
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { handle_resume_agent(invocation).await.map(boxed_tool_output) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input")
|
||||
@@ -23,7 +22,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -22,7 +22,6 @@ impl Handler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent")
|
||||
@@ -39,11 +38,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
handle_spawn_agent(invocation).await.map(boxed_tool_output)
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +200,7 @@ async fn handle_spawn_agent(
|
||||
turn.session_telemetry.counter(
|
||||
"codex.multi_agent.spawn",
|
||||
/*inc*/ 1,
|
||||
&[("role", role_tag)],
|
||||
&[("role", role_tag), ("version", "v1")],
|
||||
);
|
||||
|
||||
Ok(SpawnAgentResult {
|
||||
|
||||
@@ -27,7 +27,6 @@ impl Handler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent")
|
||||
@@ -44,7 +43,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -26,7 +26,6 @@ pub struct SpawnAgentToolOptions {
|
||||
pub hide_agent_type_model_reasoning: bool,
|
||||
pub include_usage_hint: bool,
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub max_concurrent_threads_per_session: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -102,7 +101,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
inherited_model_guidance,
|
||||
options.include_usage_hint,
|
||||
options.usage_hint_text,
|
||||
options.max_concurrent_threads_per_session,
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
@@ -722,17 +720,9 @@ fn spawn_agent_tool_description_v2(
|
||||
inherited_model_guidance: Option<&str>,
|
||||
include_usage_hint: bool,
|
||||
usage_hint_text: Option<String>,
|
||||
max_concurrent_threads_per_session: Option<usize>,
|
||||
) -> String {
|
||||
let agent_role_guidance = available_models_description.unwrap_or_default();
|
||||
let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();
|
||||
let concurrency_guidance = max_concurrent_threads_per_session
|
||||
.map(|limit| {
|
||||
format!(
|
||||
"This session is configured with `max_concurrent_threads_per_session = {limit}` for concurrently open agent threads."
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let tool_description = format!(
|
||||
r#"
|
||||
@@ -743,8 +733,7 @@ The spawned agent will have the same tools as you and the ability to spawn its o
|
||||
{inherited_model_guidance}
|
||||
Only call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally.
|
||||
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
|
||||
The new agent's canonical task name will be provided to it along with the message.
|
||||
{concurrency_guidance}"#
|
||||
The new agent's canonical task name will be provided to it along with the message."#
|
||||
);
|
||||
|
||||
if !include_usage_hint {
|
||||
|
||||
@@ -47,7 +47,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
hide_agent_type_model_reasoning: false,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
@@ -69,7 +68,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
.expect("spawn_agent should use object params");
|
||||
assert!(description.contains("Spawns an agent to work on the specified task."));
|
||||
assert!(description.contains("The spawned agent will have the same tools as you"));
|
||||
assert!(description.contains("`max_concurrent_threads_per_session = 4`"));
|
||||
assert!(!description.contains("max_concurrent_threads_per_session"));
|
||||
assert!(description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE));
|
||||
assert!(
|
||||
description
|
||||
@@ -124,7 +123,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
hide_agent_type_model_reasoning: false,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: None,
|
||||
});
|
||||
|
||||
let ToolSpec::Namespace(namespace) = tool else {
|
||||
@@ -182,7 +180,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
|
||||
hide_agent_type_model_reasoning: false,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
|
||||
@@ -227,7 +224,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
|
||||
hide_agent_type_model_reasoning: true,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
|
||||
@@ -7,7 +7,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("followup_task")
|
||||
@@ -17,7 +16,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_followup_task_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("interrupt_agent")
|
||||
@@ -16,13 +15,12 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_interrupt_agent_tool_v2()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
handle_interrupt_agent(invocation)
|
||||
.await
|
||||
.map(boxed_tool_output)
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move {
|
||||
handle_interrupt_agent(invocation)
|
||||
.await
|
||||
.map(boxed_tool_output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("list_agents")
|
||||
@@ -15,7 +14,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_list_agents_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -7,7 +7,6 @@ use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("send_message")
|
||||
@@ -17,7 +16,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_send_message_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -22,7 +22,6 @@ impl Handler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("spawn_agent")
|
||||
@@ -32,11 +31,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_spawn_agent_tool_v2(self.options.clone())
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
handle_spawn_agent(invocation).await.map(boxed_tool_output)
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +161,7 @@ async fn handle_spawn_agent(
|
||||
turn.session_telemetry.counter(
|
||||
"codex.multi_agent.spawn",
|
||||
/*inc*/ 1,
|
||||
&[("role", role_tag)],
|
||||
&[("role", role_tag), ("version", "v2")],
|
||||
);
|
||||
let task_name = String::from(new_agent_path);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ impl Handler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for Handler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("wait_agent")
|
||||
@@ -29,7 +28,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
|
||||
create_wait_agent_tool_v2(self.options)
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::new_context_window_spec::NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use crate::tools::handlers::new_context_window_spec::create_new_context_window_tool;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) const NEW_CONTEXT_WINDOW_MESSAGE: &str =
|
||||
"A new context window will start without summarizing conversation history.";
|
||||
|
||||
pub struct NewContextWindowHandler;
|
||||
|
||||
impl ToolExecutor<ToolInvocation> for NewContextWindowHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(NEW_CONTEXT_WINDOW_TOOL_NAME)
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_new_context_window_tool()
|
||||
}
|
||||
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move {
|
||||
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"new_context handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
invocation.session.request_new_context_window().await;
|
||||
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
NEW_CONTEXT_WINDOW_MESSAGE.to_string(),
|
||||
Some(true),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for NewContextWindowHandler {}
|
||||
@@ -0,0 +1,17 @@
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "new_context";
|
||||
|
||||
pub fn create_new_context_window_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
|
||||
description: "Start a new context window.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
@@ -45,7 +45,6 @@ impl ToolOutput for PlanToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for PlanHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("update_plan")
|
||||
@@ -55,7 +54,13 @@ impl ToolExecutor<ToolInvocation> for PlanHandler {
|
||||
create_update_plan_tool()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlanHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -25,7 +25,6 @@ struct RequestPermissionsEnvironmentArgs {
|
||||
environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain("request_permissions")
|
||||
@@ -35,7 +34,13 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
|
||||
create_request_permissions_tool(request_permissions_tool_description())
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestPermissionsHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
@@ -48,7 +48,6 @@ impl RequestPluginInstallHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
|
||||
@@ -62,7 +61,13 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestPluginInstallHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
@@ -304,10 +309,6 @@ fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
|
||||
.is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "connector cache refresh reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn refresh_missing_requested_connectors(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
@@ -319,7 +320,7 @@ async fn refresh_missing_requested_connectors(
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let manager = session.services.mcp_connection_manager.load_full();
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
@@ -358,7 +359,7 @@ fn verified_plugin_install_completed(
|
||||
) -> bool {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
plugins_manager
|
||||
.list_marketplaces_for_config(&plugins_input, &[])
|
||||
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|outcome| outcome.marketplaces)
|
||||
|
||||
@@ -130,6 +130,7 @@ async fn persist_disabled_install_request_writes_plugin_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
remote_plugin_id: None,
|
||||
name: "Slack".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct RequestUserInputHandler {
|
||||
pub available_modes: Vec<ModeKind>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME)
|
||||
@@ -30,7 +29,13 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
|
||||
create_request_user_input_tool(request_user_input_tool_description(&self.available_modes))
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestUserInputHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user