diff --git a/Cargo.lock b/Cargo.lock index 094f6a0..50fff2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,8 +85,7 @@ dependencies = [ [[package]] name = "agent-client-protocol" version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2" +source = "git+https://github.com/agentclientprotocol/rust-sdk?branch=main#06ca772108909542e764eb80b8a058a2ca7d939d" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -101,9 +100,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789" +checksum = "96daddd0d00f2eab88f8099d38190881bf8d6c5e46b6fa21751f482775d0dba7" dependencies = [ "anyhow", "derive_more 2.1.1", @@ -1030,6 +1029,8 @@ dependencies = [ "shlex", "tokio", "tokio-util", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.24.1+spec-1.1.0", "tracing", "tracing-subscriber", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index 6c32cf5..ca440cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ name = "codex_acp" path = "src/lib.rs" [dependencies] -agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } +agent-client-protocol = { git = "https://github.com/agentclientprotocol/rust-sdk", branch = "main", features = ["unstable"] } anyhow = "1" async-trait = "0.1" clap = "4" @@ -40,6 +40,8 @@ serde_json = { version = "1", features = ["preserve_order"] } shlex = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util"] } tokio-util = { version = "0.7", features = ["compat"] } +toml = "0.9.12" +toml_edit = "0.24.1" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } heck = "0.5.0" diff --git a/src/codex_agent.rs b/src/codex_agent.rs index 33891c0..11734e1 100644 --- a/src/codex_agent.rs +++ b/src/codex_agent.rs @@ -1,23 +1,23 @@ use agent_client_protocol::{ Agent, AgentCapabilities, AuthEnvVar, AuthMethod, AuthMethodAgent, AuthMethodEnvVar, AuthMethodId, AuthenticateRequest, AuthenticateResponse, CancelNotification, - ClientCapabilities, CloseSessionRequest, CloseSessionResponse, Error, Implementation, - InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, McpServerHttp, - McpServerStdio, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, - PromptResponse, ProtocolVersion, SessionCapabilities, SessionCloseCapabilities, SessionId, - SessionInfo, SessionListCapabilities, SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, - SetSessionModelRequest, SetSessionModelResponse, + ClientCapabilities, CloseSessionRequest, CloseSessionResponse, Error, ForkSessionRequest, + ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, + NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion, + SessionCapabilities, SessionCloseCapabilities, SessionForkCapabilities, SessionId, SessionInfo, + SessionListCapabilities, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, + SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, }; use codex_core::{ CodexAuth, NewThread, RolloutRecorder, ThreadManager, ThreadSortKey, auth::{AuthManager, read_codex_api_key_from_env, read_openai_api_key_from_env}, config::{ - Config, + Config, ConfigBuilder, ConfigOverrides, types::{McpServerConfig, McpServerTransportConfig}, }, - find_thread_path_by_id_str, + find_thread_names_by_ids, find_thread_path_by_id_str, models_manager::collaboration_mode_presets::CollaborationModesConfig, parse_cursor, }; @@ -33,6 +33,7 @@ use std::{ rc::Rc, sync::{Arc, Mutex}, }; +use toml::Value as TomlValue; use tracing::{debug, info}; use unicode_segmentation::UnicodeSegmentation; @@ -52,6 +53,10 @@ pub struct CodexAgent { client_capabilities: Arc>, /// The underlying codex configuration config: Config, + /// CLI config overrides captured at startup and reapplied when building sessions. + cli_config_overrides: Vec<(String, TomlValue)>, + /// Non-TOML harness overrides captured at startup and reapplied when building sessions. + config_overrides: ConfigOverrides, /// Thread manager for handling sessions thread_manager: ThreadManager, /// Active sessions mapped by `SessionId` @@ -65,7 +70,11 @@ const SESSION_TITLE_MAX_GRAPHEMES: usize = 120; impl CodexAgent { /// Create a new `CodexAgent` with the given configuration - pub fn new(config: Config) -> Self { + pub fn new( + config: Config, + cli_config_overrides: Vec<(String, TomlValue)>, + config_overrides: ConfigOverrides, + ) -> Self { let auth_manager = AuthManager::shared( config.codex_home.clone(), false, @@ -81,7 +90,7 @@ impl CodexAgent { let thread_manager = ThreadManager::new_with_fs( &config, auth_manager.clone(), - SessionSource::Unknown, + SessionSource::VSCode, CollaborationModesConfig { // False for now default_mode_request_user_input: false, @@ -99,6 +108,8 @@ impl CodexAgent { auth_manager, client_capabilities, config, + cli_config_overrides, + config_overrides, thread_manager, sessions: Rc::default(), session_roots, @@ -127,14 +138,22 @@ impl CodexAgent { /// Build a session config from base config, working directory, and MCP servers. /// This is shared between `new_session` and `load_session`. - fn build_session_config( + async fn build_session_config( &self, cwd: &PathBuf, mcp_servers: Vec, ) -> Result { - let mut config = self.config.clone(); + let mut config_overrides = self.config_overrides.clone(); + config_overrides.cwd = Some(cwd.clone()); + + let mut config = ConfigBuilder::default() + .codex_home(self.config.codex_home.clone()) + .cli_overrides(self.cli_config_overrides.clone()) + .harness_overrides(config_overrides) + .build() + .await + .map_err(|e| Error::internal_error().data(e.to_string()))?; config.include_apply_patch_tool = true; - config.cwd.clone_from(cwd); // Propagate any client-provided MCP servers that codex-rs supports. let mut new_mcp_servers = config.mcp_servers.get().clone(); @@ -232,7 +251,10 @@ impl Agent for CodexAgent { debug!("Received initialize request with protocol version {protocol_version:?}",); let protocol_version = ProtocolVersion::V1; - *self.client_capabilities.lock().unwrap() = client_capabilities; + *self + .client_capabilities + .lock() + .unwrap_or_else(|e| e.into_inner()) = client_capabilities; let mut agent_capabilities = AgentCapabilities::new() .prompt_capabilities(PromptCapabilities::new().embedded_context(true).image(true)) @@ -241,7 +263,8 @@ impl Agent for CodexAgent { agent_capabilities.session_capabilities = SessionCapabilities::new() .close(SessionCloseCapabilities::new()) - .list(SessionListCapabilities::new()); + .list(SessionListCapabilities::new()) + .fork(SessionForkCapabilities::new()); let mut auth_methods = vec![ CodexAuthMethod::ChatGpt.into(), @@ -337,7 +360,7 @@ impl Agent for CodexAgent { } = request; info!("Creating new session with cwd: {}", cwd.display()); - let config = self.build_session_config(&cwd, mcp_servers)?; + let config = self.build_session_config(&cwd, mcp_servers).await?; let num_mcp_servers = config.mcp_servers.len(); let NewThread { @@ -407,7 +430,7 @@ impl Agent for CodexAgent { InitialHistory::New => Vec::new(), }; - let config = self.build_session_config(&cwd, mcp_servers)?; + let config = self.build_session_config(&cwd, mcp_servers).await?; let NewThread { thread_id: _, @@ -453,6 +476,17 @@ impl Agent for CodexAgent { self.check_auth().await?; let ListSessionsRequest { cwd, cursor, .. } = request; + // Zed often sends cwd=None. Fall back to the most recently used session's cwd. + let effective_cwd = cwd.or_else(|| { + self.session_roots + .lock() + .ok() + .and_then(|roots| roots.values().next().cloned()) + }); + debug!( + "list_sessions called: effective_cwd={:?}, cursor={:?}", + effective_cwd, cursor + ); let cursor_obj = cursor.as_deref().and_then(parse_cursor); let page = RolloutRecorder::list_threads( @@ -460,11 +494,7 @@ impl Agent for CodexAgent { SESSION_LIST_PAGE_SIZE, cursor_obj.as_ref(), ThreadSortKey::UpdatedAt, - &[ - SessionSource::Cli, - SessionSource::VSCode, - SessionSource::Unknown, - ], + &[SessionSource::VSCode], None, self.config.model_provider_id.as_str(), None, @@ -472,30 +502,63 @@ impl Agent for CodexAgent { .await .map_err(|err| Error::internal_error().data(format!("failed to list sessions: {err}")))?; - let sessions = page + // Filter to current cwd (normalize paths for comparison). + let filter_cwd = effective_cwd.as_ref().map(|p| normalize_path(p)); + debug!( + "list_sessions: {} items from backend, filter_cwd={:?}", + page.items.len(), + filter_cwd + ); + let items_with_ids: Vec<_> = page .items .into_iter() .filter_map(|item| { let thread_id = item.thread_id?; let item_cwd = item.cwd?; - if let Some(filter_cwd) = cwd.as_ref() - && item_cwd != *filter_cwd - { - return None; + if let Some(ref filter) = filter_cwd { + let normalized = normalize_path(&item_cwd); + if normalized != *filter { + debug!( + "list_sessions: filtered out thread {:?} (cwd={:?} != {:?})", + thread_id, normalized, filter + ); + return None; + } } - let title = item - .first_user_message - .as_deref() - .and_then(format_session_title); - let updated_at = item.updated_at.or(item.created_at); + Some(( + thread_id, + item_cwd, + item.first_user_message, + item.updated_at.or(item.created_at), + )) + }) + .collect(); + debug!( + "list_sessions: {} items after cwd filter", + items_with_ids.len() + ); - Some( - SessionInfo::new(SessionId::new(thread_id.to_string()), item_cwd) - .title(title) - .updated_at(updated_at), - ) + // Look up thread names from session_index.jsonl (same as TUI /resume). + let thread_ids: std::collections::HashSet = + items_with_ids.iter().map(|(id, ..)| id.clone()).collect(); + let thread_names = find_thread_names_by_ids(&self.config.codex_home, &thread_ids) + .await + .unwrap_or_default(); + + let sessions = items_with_ids + .into_iter() + .map(|(thread_id, item_cwd, first_user_message, updated_at)| { + // Prefer thread_name (from /rename) over first_user_message. + let title = thread_names + .get(&thread_id) + .cloned() + .or_else(|| first_user_message.as_deref().and_then(format_session_title)); + + SessionInfo::new(SessionId::new(thread_id.to_string()), item_cwd) + .title(title) + .updated_at(updated_at) }) .collect::>(); @@ -527,6 +590,76 @@ impl Agent for CodexAgent { Ok(CloseSessionResponse::new()) } + async fn fork_session( + &self, + request: ForkSessionRequest, + ) -> Result { + info!("Forking session: {} into new session", request.session_id); + self.check_auth().await?; + + let ForkSessionRequest { + session_id: source_session_id, + cwd, + mcp_servers, + .. + } = request; + + // Find the source session's rollout path on disk + let rollout_path = + find_thread_path_by_id_str(&self.config.codex_home, source_session_id.0.as_ref()) + .await + .map_err(|e| Error::internal_error().data(e.to_string()))? + .ok_or_else(|| { + Error::resource_not_found(Some(format!( + "Source session not found: {}", + source_session_id + ))) + })?; + + let config = self.build_session_config(&cwd, mcp_servers).await?; + + // Fork the thread — this creates a new thread with the source's full history + let NewThread { + thread_id, + thread, + session_configured: _, + } = Box::pin(self.thread_manager.fork_thread( + usize::MAX, // Include all user messages from source + config.clone(), + rollout_path, + false, + )) + .await + .map_err(|e| Error::internal_error().data(e.to_string()))?; + + let new_session_id = Self::session_id_from_thread_id(thread_id); + self.session_roots + .lock() + .unwrap() + .insert(new_session_id.clone(), config.cwd.clone()); + + let thread = Rc::new(Thread::new( + new_session_id.clone(), + thread, + self.auth_manager.clone(), + self.thread_manager.get_models_manager(), + self.client_capabilities.clone(), + config, + )); + let load = thread.load().await?; + + self.sessions + .borrow_mut() + .insert(new_session_id.clone(), thread); + + info!("Forked session {} -> {}", source_session_id, new_session_id); + + Ok(ForkSessionResponse::new(new_session_id) + .modes(load.modes) + .models(load.models) + .config_options(load.config_options)) + } + async fn prompt(&self, request: PromptRequest) -> Result { info!("Processing prompt for session: {}", request.session_id); // Check before sending if authentication was successful or not @@ -680,3 +813,58 @@ fn format_session_title(message: &str) -> Option { Some(truncate_graphemes(trimmed, SESSION_TITLE_MAX_GRAPHEMES)) } } + +/// Normalize a path for comparison: resolve `.` / `..`, strip trailing slash. +/// Does NOT follow symlinks (uses lexical normalization only). +fn normalize_path(p: &std::path::Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for c in p.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::config_types::ServiceTier; + + fn make_test_codex_home() -> anyhow::Result { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + Ok(codex_home) + } + + #[tokio::test] + async fn build_session_config_reloads_latest_user_defaults() -> anyhow::Result<()> { + let codex_home = make_test_codex_home()?; + let cwd = codex_home.join("workspace"); + std::fs::create_dir_all(&cwd)?; + + let initial_config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.clone()), + ..ConfigOverrides::default() + }) + .build() + .await?; + + let agent = CodexAgent::new(initial_config, vec![], ConfigOverrides::default()); + + std::fs::write(codex_home.join("config.toml"), "service_tier = \"fast\"\n")?; + + let config = agent.build_session_config(&cwd, vec![]).await?; + assert_eq!(config.service_tier, Some(ServiceTier::Fast)); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7da85ed..db60010 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,18 +50,24 @@ pub async fn run_main( ..ConfigOverrides::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, config_overrides) - .await - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("error loading config: {e}"), - ) - })?; + let config = Config::load_with_cli_overrides_and_harness_overrides( + cli_kv_overrides.clone(), + config_overrides.clone(), + ) + .await + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("error loading config: {e}"), + ) + })?; // Create our Agent implementation with notification channel - let agent = Rc::new(codex_agent::CodexAgent::new(config)); + let agent = Rc::new(codex_agent::CodexAgent::new( + config, + cli_kv_overrides, + config_overrides, + )); let stdin = tokio::io::stdin().compat(); let stdout = tokio::io::stdout().compat_write(); diff --git a/src/local_spawner.rs b/src/local_spawner.rs index b431cf8..d57fe6c 100644 --- a/src/local_spawner.rs +++ b/src/local_spawner.rs @@ -151,7 +151,13 @@ impl AcpFs { impl codex_apply_patch::Fs for AcpFs { fn read_to_string(&self, path: &std::path::Path) -> std::io::Result { - if !self.client_capabilities.lock().unwrap().fs.read_text_file { + if !self + .client_capabilities + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fs + .read_text_file + { return StdFs.read_to_string(path); } let path = self.ensure_within_root(path)?; @@ -167,7 +173,13 @@ impl codex_apply_patch::Fs for AcpFs { } fn write(&self, path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { - if !self.client_capabilities.lock().unwrap().fs.write_text_file { + if !self + .client_capabilities + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fs + .write_text_file + { return StdFs.write(path, contents); } let path = self.ensure_within_root(path)?; @@ -196,7 +208,13 @@ impl codex_core::codex::Fs for AcpFs { + Send, >, > { - if !self.client_capabilities.lock().unwrap().fs.read_text_file { + if !self + .client_capabilities + .lock() + .unwrap_or_else(|e| e.into_inner()) + .fs + .read_text_file + { return StdFs.file_buffer(path, limit); } let path = match self.ensure_within_root(path) { diff --git a/src/thread.rs b/src/thread.rs index 4b074cd..710321b 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -1,6 +1,6 @@ use std::{ cell::RefCell, - collections::HashMap, + collections::{HashMap, HashSet}, ops::DerefMut, path::{Path, PathBuf}, rc::Rc, @@ -9,7 +9,7 @@ use std::{ use agent_client_protocol::{ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, Client, ClientCapabilities, - ConfigOptionUpdate, Content, ContentBlock, ContentChunk, Diff, EmbeddedResource, + ConfigOptionUpdate, Content, ContentBlock, ContentChunk, Cost, Diff, EmbeddedResource, EmbeddedResourceResource, Error, LoadSessionResponse, Meta, ModelId, ModelInfo, PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, @@ -23,7 +23,11 @@ use agent_client_protocol::{ use codex_apply_patch::parse_patch; use codex_core::{ AuthManager, CodexThread, - config::{Config, set_project_trust_level}, + config::{ + Config, + edit::{ConfigEdit, ConfigEditsBuilder}, + set_project_trust_level, + }, error::CodexErr, models_manager::manager::{ModelsManager, RefreshStrategy}, review_format::format_review_findings_block, @@ -31,9 +35,10 @@ use codex_core::{ }; use codex_protocol::{ approvals::{ElicitationRequest, ElicitationRequestEvent}, - config_types::TrustLevel, + config_types::{CollaborationMode, ModeKind, ServiceTier, Settings, TrustLevel}, custom_prompts::CustomPrompt, dynamic_tools::{DynamicToolCallOutputContentItem, DynamicToolCallRequest}, + items::TurnItem, mcp::CallToolResult, models::{MacOsSeatbeltProfileExtensions, PermissionProfile, ResponseItem, WebSearchAction}, openai_models::{ModelPreset, ReasoningEffort}, @@ -42,17 +47,23 @@ use codex_protocol::{ protocol::{ AgentMessageContentDeltaEvent, AgentMessageEvent, AgentReasoningEvent, AgentReasoningRawContentEvent, AgentReasoningSectionBreakEvent, - ApplyPatchApprovalRequestEvent, DynamicToolCallResponseEvent, ElicitationAction, - ErrorEvent, Event, EventMsg, ExecApprovalRequestEvent, ExecCommandBeginEvent, - ExecCommandEndEvent, ExecCommandOutputDeltaEvent, ExecCommandStatus, ExitedReviewModeEvent, - FileChange, ItemCompletedEvent, ItemStartedEvent, ListCustomPromptsResponseEvent, - McpInvocation, McpStartupCompleteEvent, McpStartupUpdateEvent, McpToolCallBeginEvent, - McpToolCallEndEvent, ModelRerouteEvent, NetworkApprovalContext, NetworkPolicyRuleAction, - Op, PatchApplyBeginEvent, PatchApplyEndEvent, PatchApplyStatus, ReasoningContentDeltaEvent, + ApplyPatchApprovalRequestEvent, AskForApproval, BackgroundEventEvent, + CollabAgentInteractionBeginEvent, CollabAgentInteractionEndEvent, + CollabAgentSpawnBeginEvent, CollabAgentSpawnEndEvent, DeprecationNoticeEvent, + DynamicToolCallResponseEvent, ElicitationAction, ErrorEvent, Event, EventMsg, + ExecApprovalRequestEvent, ExecCommandBeginEvent, ExecCommandEndEvent, + ExecCommandOutputDeltaEvent, ExecCommandStatus, ExitedReviewModeEvent, FileChange, + HookCompletedEvent, HookStartedEvent, ImageGenerationBeginEvent, ImageGenerationEndEvent, + ItemCompletedEvent, ItemStartedEvent, ListCustomPromptsResponseEvent, + ListSkillsResponseEvent, McpInvocation, McpListToolsResponseEvent, McpStartupCompleteEvent, + McpStartupUpdateEvent, McpToolCallBeginEvent, McpToolCallEndEvent, ModelRerouteEvent, + NetworkApprovalContext, NetworkPolicyRuleAction, Op, PatchApplyBeginEvent, + PatchApplyEndEvent, PatchApplyStatus, PlanDeltaEvent, ReasoningContentDeltaEvent, ReasoningRawContentDeltaEvent, ReviewDecision, ReviewOutputEvent, ReviewRequest, ReviewTarget, RolloutItem, SandboxPolicy, StreamErrorEvent, TerminalInteractionEvent, - TokenCountEvent, TurnAbortedEvent, TurnCompleteEvent, TurnStartedEvent, UserMessageEvent, - ViewImageToolCallEvent, WarningEvent, WebSearchBeginEvent, WebSearchEndEvent, + ThreadRolledBackEvent, TokenCountEvent, TurnAbortedEvent, TurnCompleteEvent, + TurnStartedEvent, UserMessageEvent, ViewImageToolCallEvent, WarningEvent, + WebSearchBeginEvent, WebSearchEndEvent, }, request_permissions::{ PermissionGrantScope, RequestPermissionsEvent, RequestPermissionsResponse, @@ -65,7 +76,8 @@ use heck::ToTitleCase; use itertools::Itertools; use serde_json::json; use tokio::sync::{mpsc, oneshot}; -use tracing::{error, info, warn}; +use toml_edit::value; +use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::{ @@ -175,6 +187,16 @@ pub struct Thread { } impl Thread { + /// Send a message to the actor, logging if the channel is closed. + fn send_message(&self, message: ThreadMessage) { + if let Err(err) = self.message_tx.send(message) { + warn!( + "Thread actor channel closed, message dropped: {:?}", + std::mem::discriminant(&err.0) + ); + } + } + pub fn new( session_id: SessionId, thread: Arc, @@ -209,7 +231,7 @@ impl Thread { let (response_tx, response_rx) = oneshot::channel(); let message = ThreadMessage::Load { response_tx }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -220,7 +242,7 @@ impl Thread { let (response_tx, response_rx) = oneshot::channel(); let message = ThreadMessage::GetConfigOptions { response_tx }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -234,7 +256,7 @@ impl Thread { request, response_tx, }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -247,7 +269,7 @@ impl Thread { let (response_tx, response_rx) = oneshot::channel(); let message = ThreadMessage::SetMode { mode, response_tx }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -258,7 +280,7 @@ impl Thread { let (response_tx, response_rx) = oneshot::channel(); let message = ThreadMessage::SetModel { model, response_tx }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -277,7 +299,7 @@ impl Thread { value, response_tx, }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -288,7 +310,7 @@ impl Thread { let (response_tx, response_rx) = oneshot::channel(); let message = ThreadMessage::Cancel { response_tx }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -302,7 +324,7 @@ impl Thread { history, response_tx, }; - drop(self.message_tx.send(message)); + self.send_message(message); response_rx .await @@ -350,6 +372,16 @@ struct PendingPermissionInteraction { task: tokio::task::JoinHandle<()>, } +enum ListResponseKind { + McpTools, + Skills, +} + +struct PendingListResponse { + kind: ListResponseKind, + response_tx: oneshot::Sender>, +} + fn exec_request_key(call_id: &str) -> String { format!("exec:{call_id}") } @@ -362,6 +394,65 @@ fn permissions_request_key(call_id: &str) -> String { format!("permissions:{call_id}") } +fn context_compaction_call_id(item_id: &str) -> String { + format!("context-compaction:{item_id}") +} + +fn context_compaction_status_text(status: ToolCallStatus) -> &'static str { + match status { + ToolCallStatus::Completed => "Context compacted.", + ToolCallStatus::Failed => "Context compaction did not complete.", + ToolCallStatus::Pending => "Context compaction pending.", + ToolCallStatus::InProgress => "Context compaction still running.", + _ => "Context compaction state updated.", + } +} + +fn response_tool_status(status: Option<&str>) -> Option { + match status { + Some("pending") => Some(ToolCallStatus::Pending), + Some("in_progress") => Some(ToolCallStatus::InProgress), + Some("completed") => Some(ToolCallStatus::Completed), + Some("failed") => Some(ToolCallStatus::Failed), + _ => None, + } +} + +fn patch_apply_tool_status(status: &PatchApplyStatus, success: bool) -> ToolCallStatus { + match status { + PatchApplyStatus::Completed => ToolCallStatus::Completed, + _ if success => ToolCallStatus::Completed, + PatchApplyStatus::Failed | PatchApplyStatus::Declined => ToolCallStatus::Failed, + } +} + +fn is_auto_compact_submission_id(id: &str) -> bool { + id.starts_with("auto-compact-") +} + +fn is_auto_compaction_event(msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::ContextCompaction(..), + .. + }) | EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::ContextCompaction(..), + .. + }) | EventMsg::ContextCompacted(..) + ) +} + +fn is_global_event(msg: &EventMsg) -> bool { + matches!( + msg, + EventMsg::McpListToolsResponse(..) + | EventMsg::ListSkillsResponse(..) + | EventMsg::McpStartupUpdate(..) + | EventMsg::McpStartupComplete(..) + ) +} + enum SubmissionState { /// Loading custom prompts from the project CustomPrompts(CustomPromptsState), @@ -410,7 +501,9 @@ impl SubmissionState { if let Self::Prompt(state) = self && let Some(response_tx) = state.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } } @@ -439,7 +532,9 @@ impl CustomPromptsState { custom_prompts, }) => { if let Some(tx) = self.response_tx.take() { - drop(tx.send(Ok(custom_prompts))); + if tx.send(Ok(custom_prompts)).is_err() { + warn!("Custom prompts response channel closed"); + } } } e => { @@ -456,10 +551,35 @@ struct ActiveCommand { file_extension: Option, } +/// Cumulative token usage tracked across a prompt's turn. +#[derive(Debug, Default)] +struct AccumulatedUsage { + input_tokens: i64, + output_tokens: i64, + cached_input_tokens: i64, + reasoning_output_tokens: i64, +} + +impl AccumulatedUsage { + #[allow(dead_code)] + fn total_tokens(&self) -> i64 { + self.input_tokens + + self.output_tokens + + self.cached_input_tokens + + self.reasoning_output_tokens + } +} + struct PromptState { submission_id: String, active_commands: HashMap, - active_web_search: Option, + active_web_searches: HashMap, + active_context_compactions: HashSet, + active_patch_applies: HashSet, + /// Tracks the most recently spawned sub-agent tool call ID so that + /// waiting/resume/close events can update its status instead of emitting + /// standalone text messages. + active_subagent_call_id: Option, thread: Arc, resolution_tx: mpsc::UnboundedSender, pending_permission_interactions: HashMap, @@ -467,6 +587,7 @@ struct PromptState { response_tx: Option>>, seen_message_deltas: bool, seen_reasoning_deltas: bool, + accumulated_usage: AccumulatedUsage, } impl PromptState { @@ -479,7 +600,10 @@ impl PromptState { Self { submission_id, active_commands: HashMap::new(), - active_web_search: None, + active_web_searches: HashMap::new(), + active_context_compactions: HashSet::new(), + active_patch_applies: HashSet::new(), + active_subagent_call_id: None, thread, resolution_tx, pending_permission_interactions: HashMap::new(), @@ -487,6 +611,7 @@ impl PromptState { response_tx: Some(response_tx), seen_message_deltas: false, seen_reasoning_deltas: false, + accumulated_usage: AccumulatedUsage::default(), } } @@ -503,6 +628,97 @@ impl PromptState { } } + async fn start_context_compaction(&mut self, client: &SessionClient, item_id: &str) { + let call_id = context_compaction_call_id(item_id); + if !self.active_context_compactions.insert(call_id.clone()) { + return; + } + + client + .send_tool_call( + ToolCall::new(call_id, "Compacting context") + .kind(ToolKind::Think) + .status(ToolCallStatus::InProgress) + .content(vec![ + "Condensing earlier conversation so the next turn can continue." + .to_string() + .into(), + ]), + ) + .await; + } + + async fn settle_context_compaction( + &mut self, + client: &SessionClient, + item_id: &str, + status: ToolCallStatus, + ) { + let call_id = context_compaction_call_id(item_id); + let content = context_compaction_status_text(status).to_string(); + + if self.active_context_compactions.remove(&call_id) { + client + .send_tool_call_update(ToolCallUpdate::new( + call_id, + ToolCallUpdateFields::new() + .status(status) + .content(vec![content.into()]), + )) + .await; + } else if matches!(status, ToolCallStatus::Completed | ToolCallStatus::Failed) { + client + .send_tool_call( + ToolCall::new(call_id, "Compacting context") + .kind(ToolKind::Think) + .status(status) + .content(vec![content.into()]), + ) + .await; + } + } + + async fn settle_all_context_compactions( + &mut self, + client: &SessionClient, + status: ToolCallStatus, + ) { + let pending: Vec<_> = self.active_context_compactions.drain().collect(); + for call_id in pending { + client + .send_tool_call_update(ToolCallUpdate::new( + call_id, + ToolCallUpdateFields::new().status(status).content(vec![ + context_compaction_status_text(status).to_string().into(), + ]), + )) + .await; + } + } + + async fn settle_all_patch_applies(&mut self, client: &SessionClient, status: ToolCallStatus) { + let pending: Vec<_> = self.active_patch_applies.drain().collect(); + let content = match status { + ToolCallStatus::Completed => "Edit completed.", + ToolCallStatus::Failed => "Edit interrupted before completion.", + ToolCallStatus::Pending => "Edit pending.", + ToolCallStatus::InProgress => "Edit still running.", + _ => "Edit status unknown.", + } + .to_string(); + + for call_id in pending { + client + .send_tool_call_update(ToolCallUpdate::new( + call_id, + ToolCallUpdateFields::new() + .status(status) + .content(vec![content.clone().into()]), + )) + .await; + } + } + fn spawn_permission_request( &mut self, client: &SessionClient, @@ -677,22 +893,50 @@ impl PromptState { collaboration_mode_kind, turn_id, }) => { - info!("Task started with context window of {turn_id} {model_context_window:?} {collaboration_mode_kind:?}"); + info!( + "Task started with context window of {turn_id} {model_context_window:?} {collaboration_mode_kind:?}" + ); } - EventMsg::TokenCount(TokenCountEvent { info, .. }) => { - if let Some(info) = info - && let Some(size) = info.model_context_window { - let used = info.last_token_usage.tokens_in_context_window().max(0) as u64; + EventMsg::TokenCount(TokenCountEvent { info, rate_limits }) => { + if let Some(info) = info { + // Accumulate token usage across the turn + let last = &info.last_token_usage; + self.accumulated_usage.input_tokens += last.input_tokens; + self.accumulated_usage.output_tokens += last.output_tokens; + self.accumulated_usage.cached_input_tokens += last.cached_input_tokens; + self.accumulated_usage.reasoning_output_tokens += last.reasoning_output_tokens; + + if let Some(size) = info.model_context_window { + let used = info.total_token_usage.tokens_in_context_window().max(0) as u64; + let mut update = UsageUpdate::new(used, size as u64); + + // Include cost if credits info is available from rate limits + if let Some(rate_limits) = &rate_limits + && let Some(credits) = &rate_limits.credits + && let Some(balance) = &credits.balance + { + if let Ok(amount) = balance.parse::() { + update = update.cost(Cost::new(amount, "USD")); + } + } + client - .send_notification(SessionUpdate::UsageUpdate(UsageUpdate::new( - used, - size as u64, - ))) + .send_notification(SessionUpdate::UsageUpdate(update)) .await; } + } } - EventMsg::ItemStarted(ItemStartedEvent { thread_id, turn_id, item }) => { - info!("Item started with thread_id: {thread_id}, turn_id: {turn_id}, item: {item:?}"); + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id, + item, + }) => { + info!( + "Item started with thread_id: {thread_id}, turn_id: {turn_id}, item: {item:?}" + ); + if let TurnItem::ContextCompaction(item) = item { + self.start_context_compaction(client, &item.id).await; + } } EventMsg::UserMessage(UserMessageEvent { message, @@ -708,7 +952,9 @@ impl PromptState { item_id, delta, }) => { - info!("Agent message content delta received: thread_id: {thread_id}, turn_id: {turn_id}, item_id: {item_id}, delta: {delta:?}"); + info!( + "Agent message content delta received: thread_id: {thread_id}, turn_id: {turn_id}, item_id: {item_id}, delta: {delta:?}" + ); self.seen_message_deltas = true; client.send_agent_text(delta).await; } @@ -726,7 +972,9 @@ impl PromptState { delta, content_index: index, }) => { - info!("Agent reasoning content delta received: thread_id: {thread_id}, turn_id: {turn_id}, item_id: {item_id}, index: {index}, delta: {delta:?}"); + info!( + "Agent reasoning content delta received: thread_id: {thread_id}, turn_id: {turn_id}, item_id: {item_id}, index: {index}, delta: {delta:?}" + ); self.seen_reasoning_deltas = true; client.send_agent_thought(delta).await; } @@ -734,16 +982,27 @@ impl PromptState { item_id, summary_index, }) => { - info!("Agent reasoning section break received: item_id: {item_id}, index: {summary_index}"); + info!( + "Agent reasoning section break received: item_id: {item_id}, index: {summary_index}" + ); // Make sure the section heading actually get spacing self.seen_reasoning_deltas = true; client.send_agent_thought("\n\n").await; } - EventMsg::AgentMessage(AgentMessageEvent { message , phase: _ }) => { + EventMsg::AgentMessage(AgentMessageEvent { message, phase }) => { info!("Agent message (non-delta) received: {message:?}"); // We didn't receive this message via streaming if !std::mem::take(&mut self.seen_message_deltas) { - client.send_agent_text(message).await; + // While a sub-agent is running, commentary messages are + // just polling narration ("I'm still waiting..."). Redirect + // them to thoughts so they don't clutter the main output. + if self.active_subagent_call_id.is_some() + && phase == Some(codex_protocol::models::MessagePhase::Commentary) + { + client.send_agent_thought(message).await; + } else { + client.send_agent_text(message).await; + } } } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { @@ -794,7 +1053,9 @@ impl PromptState { if let Err(err) = self.exec_approval(client, event).await && let Some(response_tx) = self.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } EventMsg::ExecCommandBegin(event) => { @@ -821,9 +1082,17 @@ impl PromptState { ); self.terminal_interaction(client, event).await; } - EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { call_id, turn_id, tool, arguments }) => { - info!("Dynamic tool call request: call_id={call_id}, turn_id={turn_id}, tool={tool}"); - self.start_dynamic_tool_call(client, call_id, tool, arguments).await; + EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id, + turn_id, + tool, + arguments, + }) => { + info!( + "Dynamic tool call request: call_id={call_id}, turn_id={turn_id}, tool={tool}" + ); + self.start_dynamic_tool_call(client, call_id, tool, arguments) + .await; } EventMsg::DynamicToolCallResponse(event) => { info!( @@ -862,7 +1131,9 @@ impl PromptState { if let Err(err) = self.patch_approval(client, event).await && let Some(response_tx) = self.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } EventMsg::PatchApplyBegin(event) => { @@ -884,13 +1155,34 @@ impl PromptState { turn_id, item, }) => { - info!("Item completed: thread_id={}, turn_id={}, item={:?}", thread_id, turn_id, item); + info!( + "Item completed: thread_id={}, turn_id={}, item={:?}", + thread_id, turn_id, item + ); + if let TurnItem::ContextCompaction(item) = item { + self.settle_context_compaction(client, &item.id, ToolCallStatus::Completed) + .await; + } } - EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message, turn_id }) => { + EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message, + turn_id, + }) => { info!( "Task {turn_id} completed successfully after {} events. Last agent message: {last_agent_message:?}", self.event_count ); + // Clean up any lingering sub-agent tool call. + if let Some(active_id) = self.active_subagent_call_id.take() { + client + .send_tool_call_update(ToolCallUpdate::new( + active_id, + ToolCallUpdateFields::new().status(ToolCallStatus::Completed), + )) + .await; + } + self.settle_all_context_compactions(client, ToolCallStatus::Completed) + .await; self.abort_pending_interactions(); if let Some(response_tx) = self.response_tx.take() { response_tx.send(Ok(StopReason::EndTurn)).ok(); @@ -911,7 +1203,9 @@ impl PromptState { } else { "Undo failed.".to_string() }; - client.send_agent_text(event.message.unwrap_or(fallback)).await; + client + .send_agent_text(event.message.unwrap_or(fallback)) + .await; } EventMsg::StreamError(StreamErrorEvent { message, @@ -927,6 +1221,10 @@ impl PromptState { codex_error_info, }) => { error!("Unhandled error during turn: {message} {codex_error_info:?}"); + self.settle_all_context_compactions(client, ToolCallStatus::Failed) + .await; + self.settle_all_patch_applies(client, ToolCallStatus::Failed) + .await; self.abort_pending_interactions(); if let Some(response_tx) = self.response_tx.take() { response_tx @@ -938,6 +1236,10 @@ impl PromptState { } EventMsg::TurnAborted(TurnAbortedEvent { reason, turn_id }) => { info!("Turn {turn_id:?} aborted: {reason:?}"); + self.settle_all_context_compactions(client, ToolCallStatus::Failed) + .await; + self.settle_all_patch_applies(client, ToolCallStatus::Failed) + .await; self.abort_pending_interactions(); if let Some(response_tx) = self.response_tx.take() { response_tx.send(Ok(StopReason::Cancelled)).ok(); @@ -945,6 +1247,10 @@ impl PromptState { } EventMsg::ShutdownComplete => { info!("Agent shutting down"); + self.settle_all_context_compactions(client, ToolCallStatus::Failed) + .await; + self.settle_all_patch_applies(client, ToolCallStatus::Failed) + .await; self.abort_pending_interactions(); if let Some(response_tx) = self.response_tx.take() { response_tx.send(Ok(StopReason::Cancelled)).ok(); @@ -954,14 +1260,18 @@ impl PromptState { info!("ViewImageToolCallEvent received"); let display_path = path.display().to_string(); client - .send_notification( - SessionUpdate::ToolCall( - ToolCall::new(call_id, format!("View Image {display_path}")) - .kind(ToolKind::Read).status(ToolCallStatus::Completed) - .content(vec![ToolCallContent::Content(Content::new(ContentBlock::ResourceLink(ResourceLink::new(display_path.clone(), display_path.clone()) - ) - ) - )]).locations(vec![ToolCallLocation::new(path)]))) + .send_notification(SessionUpdate::ToolCall( + ToolCall::new(call_id, format!("View Image {display_path}")) + .kind(ToolKind::Read) + .status(ToolCallStatus::Completed) + .content(vec![ToolCallContent::Content(Content::new( + ContentBlock::ResourceLink(ResourceLink::new( + display_path.clone(), + display_path.clone(), + )), + ))]) + .locations(vec![ToolCallLocation::new(path)]), + )) .await; } EventMsg::EnteredReviewMode(review_request) => { @@ -972,7 +1282,9 @@ impl PromptState { if let Err(err) = self.review_mode_exit(client, event).await && let Some(response_tx) = self.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } EventMsg::Warning(WarningEvent { message }) => { @@ -994,70 +1306,294 @@ impl PromptState { ); } EventMsg::ElicitationRequest(event) => { - info!("Elicitation request: server={}, id={:?}", event.server_name, event.id); + info!( + "Elicitation request: server={}, id={:?}", + event.server_name, event.id + ); if let Err(err) = self.mcp_elicitation(client, event).await && let Some(response_tx) = self.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } - EventMsg::ModelReroute(ModelRerouteEvent { from_model, to_model, reason }) => { + EventMsg::ModelReroute(ModelRerouteEvent { + from_model, + to_model, + reason, + }) => { info!("Model reroute: from={from_model}, to={to_model}, reason={reason:?}"); } EventMsg::ContextCompacted(..) => { info!("Context compacted"); - client.send_agent_text("Context compacted\n".to_string()).await; + client + .send_agent_text("Context compacted\n".to_string()) + .await; } EventMsg::RequestPermissions(event) => { info!("Request permissions: {} {}", event.call_id, event.turn_id); if let Err(err) = self.request_permissions(client, event).await && let Some(response_tx) = self.response_tx.take() { - drop(response_tx.send(Err(err))); + if response_tx.send(Err(err)).is_err() { + warn!("Response channel closed, error dropped"); + } } } - // Ignore these events - EventMsg::ImageGenerationBegin(..) - | EventMsg::ImageGenerationEnd(..) - | EventMsg::AgentReasoningRawContent(..) - | EventMsg::ThreadRolledBack(..) - | EventMsg::HookStarted(..) - | EventMsg::HookCompleted(..) - // we already have a way to diff the turn, so ignore - | EventMsg::TurnDiff(..) - // Revisit when we can emit status updates - | EventMsg::BackgroundEvent(..) + // Hook lifecycle — surface as agent status text so users know hooks are running + EventMsg::HookStarted(HookStartedEvent { turn_id: _, run }) => { + info!("Hook started: {} ({:?})", run.id, run.event_name); + client + .send_agent_text(format!( + "Running hook: {} ({:?})...\n", + run.id, run.event_name + )) + .await; + } + EventMsg::HookCompleted(HookCompletedEvent { turn_id: _, run }) => { + let status_msg = run.status_message.as_deref().unwrap_or(""); + info!( + "Hook completed: {} — {:?} {}", + run.id, run.status, status_msg + ); + client + .send_agent_text(format!( + "Hook completed: {} — {:?}{}\n", + run.id, + run.status, + if status_msg.is_empty() { + String::new() + } else { + format!(": {status_msg}") + } + )) + .await; + } + + // Image generation — surface as tool calls so client UI can track progress + EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { call_id }) => { + info!("Image generation started: call_id={call_id}"); + client + .send_tool_call( + ToolCall::new(call_id, "Generating image") + .kind(ToolKind::Other) + .status(ToolCallStatus::InProgress), + ) + .await; + } + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id, + status, + revised_prompt: _, + result, + .. + }) => { + info!("Image generation ended: call_id={call_id}, status={status}"); + let tc_status = if status == "success" { + ToolCallStatus::Completed + } else { + ToolCallStatus::Failed + }; + client + .send_tool_call_update(ToolCallUpdate::new( + call_id, + ToolCallUpdateFields::new() + .status(tc_status) + .content(vec![result.into()]), + )) + .await; + } + + // Agent reasoning raw content (non-delta, full text) + EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { + info!("Agent reasoning raw content received"); + client.send_agent_thought(text).await; + } + + // Thread rollback notification + EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns }) => { + info!("Thread rolled back: {num_turns} turns removed"); + client + .send_agent_text(format!( + "Thread rolled back: {num_turns} turn{} removed from context.\n", + if num_turns == 1 { "" } else { "s" } + )) + .await; + } + + // Background events — surface as agent text + EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { + info!("Background event: {message}"); + client.send_agent_text(format!("{message}\n")).await; + } + + // Deprecation notices — forward to client as warnings + EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { + warn!("Deprecation notice: {summary}"); + let msg = if let Some(details) = details { + format!("**Deprecation:** {summary}\n{details}\n") + } else { + format!("**Deprecation:** {summary}\n") + }; + client.send_agent_text(msg).await; + } + + // Plan delta — forward incremental plan text as agent thought + EventMsg::PlanDelta(PlanDeltaEvent { + thread_id: _, + turn_id: _, + item_id: _, + delta, + }) => { + client.send_agent_thought(delta).await; + } + + // Collaboration events — surface as tool calls so the client can track sub-agent activity + EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { + call_id, + sender_thread_id: _, + prompt, + model, + reasoning_effort: _, + }) => { + info!("Collab agent spawn begin: call_id={call_id}, model={model}"); + let title = if prompt.is_empty() { + format!("Sub-agent ({model})") + } else { + let truncated = if prompt.len() > 80 { + format!("{}...", &prompt[..77]) + } else { + prompt + }; + format!("Sub-agent: {truncated}") + }; + // Keep the tool call InProgress — it represents the full sub-agent + // lifecycle (spawn → work → results). We'll mark it Completed only + // when the interaction ends or the collab close fires. + self.active_subagent_call_id = Some(call_id.clone()); + client + .send_tool_call( + ToolCall::new(call_id, title) + .kind(ToolKind::Other) + .status(ToolCallStatus::InProgress), + ) + .await; + } + EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: _, + new_thread_id, + .. + }) => { + info!("Collab agent spawn end: call_id={call_id}, new_thread={new_thread_id:?}"); + // Don't mark Completed yet — the sub-agent is still working. + // Update content to show it's been spawned and is running. + client + .send_tool_call_update(ToolCallUpdate::new( + call_id, + ToolCallUpdateFields::new() + .content(vec!["Sub-agent spawned, working...".to_string().into()]), + )) + .await; + } + EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + .. + }) => { + info!("Collab interaction begin: call_id={call_id}, receiver={receiver_thread_id}"); + // Update existing sub-agent tool call if we have one, otherwise create new. + if let Some(ref active_id) = self.active_subagent_call_id { + client + .send_tool_call_update(ToolCallUpdate::new( + active_id.clone(), + ToolCallUpdateFields::new() + .content(vec!["Sub-agent interacting...".to_string().into()]), + )) + .await; + } else { + self.active_subagent_call_id = Some(call_id.clone()); + client + .send_tool_call( + ToolCall::new(call_id, "Sub-agent interaction".to_string()) + .kind(ToolKind::Other) + .status(ToolCallStatus::InProgress), + ) + .await; + } + } + EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { + call_id: _, + sender_thread_id: _, + receiver_thread_id, + .. + }) => { + info!("Collab interaction end: receiver={receiver_thread_id}"); + // Don't complete yet — more interactions or the final answer may follow. + } + + // Collab waiting — update the active sub-agent tool call status + EventMsg::CollabWaitingBegin(..) => { + info!("Collab waiting begin"); + if let Some(ref active_id) = self.active_subagent_call_id { + client + .send_tool_call_update(ToolCallUpdate::new( + active_id.clone(), + ToolCallUpdateFields::new().content(vec![ + "Waiting for sub-agent results...".to_string().into(), + ]), + )) + .await; + } + } + EventMsg::CollabWaitingEnd(..) => { + info!("Collab waiting end"); + // Don't emit anything — the next event will be either another + // waiting cycle or the final agent message with results. + } + EventMsg::CollabResumeBegin(..) | EventMsg::CollabResumeEnd(..) => { + info!("Collab resume event"); + } + EventMsg::CollabCloseBegin(..) => { + info!("Collab close begin"); + } + EventMsg::CollabCloseEnd(..) => { + info!("Collab close end"); + // Sub-agent lifecycle is done — complete the tool call. + if let Some(active_id) = self.active_subagent_call_id.take() { + client + .send_tool_call_update(ToolCallUpdate::new( + active_id, + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .content(vec!["Sub-agent finished.".to_string().into()]), + )) + .await; + } + } + + // Events that are truly ignorable (old/superseded or not applicable to ACP) + EventMsg::TurnDiff(..) | EventMsg::SkillsUpdateAvailable - // Old events | EventMsg::AgentMessageDelta(..) | EventMsg::AgentReasoningDelta(..) | EventMsg::AgentReasoningRawContentDelta(..) | EventMsg::RawResponseItem(..) | EventMsg::SessionConfigured(..) - // TODO: Subagent UI? - | EventMsg::CollabAgentSpawnBegin(..) - | EventMsg::CollabAgentSpawnEnd(..) - | EventMsg::CollabAgentInteractionBegin(..) - | EventMsg::CollabAgentInteractionEnd(..) | EventMsg::RealtimeConversationStarted(..) | EventMsg::RealtimeConversationRealtime(..) - | EventMsg::RealtimeConversationClosed(..) - | EventMsg::CollabWaitingBegin(..) - | EventMsg::CollabWaitingEnd(..) - | EventMsg::CollabResumeBegin(..) - | EventMsg::CollabResumeEnd(..) - | EventMsg::CollabCloseBegin(..) - | EventMsg::CollabCloseEnd(..) - | EventMsg::PlanDelta(..) => {} - e @ (EventMsg::McpListToolsResponse(..) - // returned from Op::ListCustomPrompts, ignore - | EventMsg::ListCustomPromptsResponse(..) - | EventMsg::ListSkillsResponse(..) - // Used for returning a single history entry + | EventMsg::RealtimeConversationClosed(..) => {} + + // McpListToolsResponse and ListSkillsResponse are handled at the + // ThreadActor level (handle_list_response), not per-submission. + EventMsg::McpListToolsResponse(..) | EventMsg::ListSkillsResponse(..) => {} + + e @ (EventMsg::ListCustomPromptsResponse(..) | EventMsg::GetHistoryEntryResponse(..) - | EventMsg::DeprecationNotice(..) | EventMsg::RequestUserInput(..) | EventMsg::ListRemoteSkillsResponse(..) | EventMsg::RemoteSkillDownloaded(..)) => { @@ -1182,7 +1718,7 @@ impl PromptState { Ok(()) } - async fn start_patch_apply(&self, client: &SessionClient, event: PatchApplyBeginEvent) { + async fn start_patch_apply(&mut self, client: &SessionClient, event: PatchApplyBeginEvent) { let raw_input = serde_json::json!(&event); let PatchApplyBeginEvent { call_id, @@ -1190,6 +1726,7 @@ impl PromptState { changes, turn_id: _, } = event; + self.active_patch_applies.insert(call_id.clone()); let (title, locations, content) = extract_tool_call_content_from_changes(changes); @@ -1205,7 +1742,7 @@ impl PromptState { .await; } - async fn end_patch_apply(&self, client: &SessionClient, event: PatchApplyEndEvent) { + async fn end_patch_apply(&mut self, client: &SessionClient, event: PatchApplyEndEvent) { let raw_output = serde_json::json!(&event); let PatchApplyEndEvent { call_id, @@ -1216,6 +1753,7 @@ impl PromptState { turn_id: _, status, } = event; + self.active_patch_applies.remove(&call_id); let (title, locations, content) = if !changes.is_empty() { let (title, locations, content) = extract_tool_call_content_from_changes(changes); @@ -1224,11 +1762,7 @@ impl PromptState { (None, None, None) }; - let status = match status { - PatchApplyStatus::Completed => ToolCallStatus::Completed, - _ if success => ToolCallStatus::Completed, - PatchApplyStatus::Failed | PatchApplyStatus::Declined => ToolCallStatus::Failed, - }; + let status = patch_apply_tool_status(&status, success); client .send_tool_call_update(ToolCallUpdate::new( @@ -1591,6 +2125,8 @@ impl PromptState { }; client.send_tool_call_update(update).await; + } else { + warn!("Received ExecCommandOutputDelta for unknown call_id: {call_id}"); } } @@ -1644,6 +2180,8 @@ impl PromptState { ), ) .await; + } else { + warn!("Received ExecCommandEnd for unknown call_id: {call_id} (exit_code={exit_code})"); } } @@ -1693,11 +2231,13 @@ impl PromptState { }; client.send_tool_call_update(update).await; + } else { + warn!("Received TerminalInteraction for unknown call_id: {call_id}"); } } async fn start_web_search(&mut self, client: &SessionClient, call_id: String) { - self.active_web_search = Some(call_id.clone()); + self.active_web_searches.insert(call_id.clone(), ()); client .send_tool_call(ToolCall::new(call_id, "Searching the Web").kind(ToolKind::Fetch)) .await; @@ -1744,7 +2284,8 @@ impl PromptState { } async fn complete_web_search(&mut self, client: &SessionClient) { - if let Some(call_id) = self.active_web_search.take() { + let completed: Vec = self.active_web_searches.drain().map(|(id, _)| id).collect(); + for call_id in completed { client .send_tool_call_update(ToolCallUpdate::new( call_id, @@ -2044,7 +2585,12 @@ impl SessionClient { fn new(session_id: SessionId, client_capabilities: Arc>) -> Self { Self { session_id, - client: ACP_CLIENT.get().expect("Client should be set").clone(), + client: ACP_CLIENT + .get() + .unwrap_or_else(|| { + panic!("ACP_CLIENT must be initialized before creating SessionClient"); + }) + .clone(), client_capabilities, } } @@ -2067,7 +2613,12 @@ impl SessionClient { && self .client_capabilities .lock() - .unwrap() + .unwrap_or_else(|e| { + warn!( + "client_capabilities mutex poisoned, assuming no terminal output support" + ); + e.into_inner() + }) .meta .as_ref() .is_some_and(|v| { @@ -2199,12 +2750,19 @@ struct ThreadActor { resolution_tx: mpsc::UnboundedSender, /// A sender for each interested `Op` submission that needs events routed. submissions: HashMap, + /// The most recently started prompt submission. Used to surface live + /// internal auto-compaction progress in the visible prompt UI. + latest_prompt_submission_id: Option, /// A receiver for incoming thread messages. message_rx: mpsc::UnboundedReceiver, /// A receiver for spawned interaction results. resolution_rx: mpsc::UnboundedReceiver, /// Last config options state we emitted to the client, used for deduping updates. last_sent_config_options: Option>, + /// Pending response for /mcp or /skills list commands. + pending_list_response: Option, + /// Whether plan mode is currently active (tracked separately from approval presets). + plan_mode_active: bool, } impl ThreadActor { @@ -2228,9 +2786,12 @@ impl ThreadActor { models_manager, resolution_tx, submissions: HashMap::new(), + latest_prompt_submission_id: None, message_rx, resolution_rx, last_sent_config_options: None, + pending_list_response: None, + plan_mode_active: false, } } @@ -2268,7 +2829,9 @@ impl ThreadActor { match message { ThreadMessage::Load { response_tx } => { let result = self.handle_load().await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } let client = self.client.clone(); let mut available_commands = Self::builtin_commands(); let load_custom_prompts = self.load_custom_prompts().await; @@ -2313,23 +2876,31 @@ impl ThreadActor { } ThreadMessage::GetConfigOptions { response_tx } => { let result = self.config_options().await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::Prompt { request, response_tx, } => { let result = self.handle_prompt(request).await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::SetMode { mode, response_tx } => { let result = self.handle_set_mode(mode).await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } self.maybe_emit_config_options_update().await; } ThreadMessage::SetModel { model, response_tx } => { let result = self.handle_set_model(model).await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } self.maybe_emit_config_options_update().await; } ThreadMessage::SetConfigOption { @@ -2338,22 +2909,30 @@ impl ThreadActor { response_tx, } => { let result = self.handle_set_config_option(config_id, value).await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::Cancel { response_tx } => { let result = self.handle_cancel().await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::Shutdown { response_tx } => { let result = self.handle_shutdown().await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::ReplayHistory { history, response_tx, } => { let result = self.handle_replay_history(history).await; - drop(response_tx.send(result)); + if response_tx.send(result).is_err() { + warn!("Response channel closed, result dropped"); + } } ThreadMessage::PermissionRequestResolved { submission_id, @@ -2409,15 +2988,205 @@ impl ThreadActor { ), AvailableCommand::new("undo", "undo Codex’s most recent turn"), AvailableCommand::new("logout", "logout of Codex"), + AvailableCommand::new("fast", "toggle fast mode for this session").input( + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new( + "optional: on|off|status", + )), + ), + AvailableCommand::new("diff", "show git diff including untracked files"), + AvailableCommand::new( + "status", + "show current session configuration and token usage", + ), + AvailableCommand::new("stop", "stop all background terminals"), + AvailableCommand::new("rename", "rename the current thread").input( + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("new name")), + ), + AvailableCommand::new("mcp", "list configured MCP tools"), + AvailableCommand::new("skills", "list and manage skills"), ] } + async fn format_session_status(&self) -> String { + let mut status = String::from("## Session Status\n\n"); + + // Model + if let Some(model_id) = self.find_current_model().await { + status.push_str(&format!("**Model:** {}\n", model_id.0)); + } else { + status.push_str("**Model:** unknown\n"); + } + + // Mode + let mode_name = APPROVAL_PRESETS + .iter() + .find(|preset| { + Self::preset_matches_current( + self.config.permissions.approval_policy.value(), + self.config.permissions.sandbox_policy.get(), + preset, + ) + }) + .map(|p| p.label) + .unwrap_or("unknown"); + status.push_str(&format!("**Mode:** {mode_name}\n")); + status.push_str(&format!( + "**Service Tier:** {}\n", + format_service_tier_name(self.config.service_tier) + )); + + // Working directory + status.push_str(&format!( + "**Working Directory:** {}\n", + self.config.cwd.display() + )); + + status.push('\n'); + status + } + + async fn set_service_tier(&mut self, service_tier: Option) -> Result<(), Error> { + self.thread + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: None, + windows_sandbox_level: None, + service_tier: Some(service_tier), + }) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + + self.config.service_tier = service_tier; + self.persist_service_tier_default(service_tier).await?; + + Ok(()) + } + + fn scoped_config_segments(&self, segments: &[&str]) -> Vec { + let mut scoped = Vec::with_capacity(segments.len() + 2); + if let Some(profile) = self.config.active_profile.as_ref() { + scoped.push("profiles".to_string()); + scoped.push(profile.clone()); + } + scoped.extend(segments.iter().map(|segment| (*segment).to_string())); + scoped + } + + fn string_config_edit(&self, segments: &[&str], value_str: impl ToString) -> ConfigEdit { + ConfigEdit::SetPath { + segments: self.scoped_config_segments(segments), + value: value(value_str.to_string()), + } + } + + fn clear_config_edit(&self, segments: &[&str]) -> ConfigEdit { + ConfigEdit::ClearPath { + segments: self.scoped_config_segments(segments), + } + } + + async fn persist_default_edits(&self, edits: Vec) -> Result<(), Error> { + ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|e| { + Error::internal_error().data(format!( + "Updated session, but failed to persist defaults: {e}" + )) + }) + } + + fn sandbox_mode_tag(sandbox: &SandboxPolicy) -> &'static str { + match sandbox { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + "danger-full-access" + } + SandboxPolicy::ReadOnly { .. } => "read-only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", + } + } + + fn preset_matches_current( + current_approval: AskForApproval, + current_sandbox: &SandboxPolicy, + preset: &ApprovalPreset, + ) -> bool { + if current_approval != preset.approval { + return false; + } + + matches!( + (&preset.sandbox, current_sandbox), + ( + SandboxPolicy::ReadOnly { .. }, + SandboxPolicy::ReadOnly { .. } + ) | ( + SandboxPolicy::DangerFullAccess, + SandboxPolicy::DangerFullAccess + ) | ( + SandboxPolicy::WorkspaceWrite { .. }, + SandboxPolicy::WorkspaceWrite { .. } + ) + ) + } + + async fn persist_mode_default(&self, preset: &ApprovalPreset) -> Result<(), Error> { + self.persist_default_edits(vec![ + self.string_config_edit(&["approval_policy"], preset.approval), + self.string_config_edit(&["sandbox_mode"], Self::sandbox_mode_tag(&preset.sandbox)), + ]) + .await + } + + async fn persist_model_default( + &self, + model: &str, + effort: Option, + ) -> Result<(), Error> { + let mut edits = vec![self.string_config_edit(&["model"], model)]; + edits.push(match effort { + Some(effort) => self.string_config_edit(&["model_reasoning_effort"], effort), + None => self.clear_config_edit(&["model_reasoning_effort"]), + }); + self.persist_default_edits(edits).await + } + + async fn persist_reasoning_effort_default(&self, effort: ReasoningEffort) -> Result<(), Error> { + self.persist_default_edits(vec![ + self.string_config_edit(&["model_reasoning_effort"], effort), + ]) + .await + } + + async fn persist_service_tier_default( + &self, + service_tier: Option, + ) -> Result<(), Error> { + self.persist_default_edits(vec![match service_tier { + Some(service_tier) => self.string_config_edit(&["service_tier"], service_tier), + None => self.clear_config_edit(&["service_tier"]), + }]) + .await + } + async fn load_custom_prompts(&mut self) -> oneshot::Receiver, Error>> { let (response_tx, response_rx) = oneshot::channel(); let submission_id = match self.thread.submit(Op::ListCustomPrompts).await { Ok(id) => id, Err(e) => { - drop(response_tx.send(Err(Error::internal_error().data(e.to_string())))); + if response_tx + .send(Err(Error::internal_error().data(e.to_string()))) + .is_err() + { + warn!("Response channel closed while sending ListCustomPrompts error"); + } return response_rx; } }; @@ -2431,38 +3200,44 @@ impl ThreadActor { } fn modes(&self) -> Option { - let current_mode_id = APPROVAL_PRESETS - .iter() - .find(|preset| { - &preset.approval == self.config.permissions.approval_policy.get() - && &preset.sandbox == self.config.permissions.sandbox_policy.get() - }) - .or_else(|| { - // When the project is untrusted, the above code won't match - // since AskForApproval::UnlessTrusted is not part of the - // default presets. However, in this case we still want to show - // the mode selector, which allows the user to choose a - // different mode (which will set the project to be trusted) - // See https://github.com/zed-industries/zed/issues/48132 - if self.config.active_project.is_untrusted() { - APPROVAL_PRESETS - .iter() - .find(|preset| preset.id == "read-only") - } else { - None - } - }) - .map(|preset| SessionModeId::new(preset.id))?; - - Some(SessionModeState::new( - current_mode_id, + let current_mode_id = if self.plan_mode_active { + SessionModeId::new("plan") + } else { APPROVAL_PRESETS .iter() - .map(|preset| { - SessionMode::new(preset.id, preset.label).description(preset.description) + .find(|preset| { + Self::preset_matches_current( + self.config.permissions.approval_policy.value(), + self.config.permissions.sandbox_policy.get(), + preset, + ) }) - .collect(), - )) + .or_else(|| { + // When the project is untrusted, the above code won't match + // since AskForApproval::UnlessTrusted is not part of the + // default presets. However, in this case we still want to show + // the mode selector, which allows the user to choose a + // different mode (which will set the project to be trusted) + // See https://github.com/zed-industries/zed/issues/48132 + if self.config.active_project.is_untrusted() { + APPROVAL_PRESETS + .iter() + .find(|preset| preset.id == "read-only") + } else { + None + } + }) + .map(|preset| SessionModeId::new(preset.id))? + }; + + let mut available_modes: Vec = APPROVAL_PRESETS + .iter() + .map(|preset| SessionMode::new(preset.id, preset.label).description(preset.description)) + .collect(); + available_modes.push( + SessionMode::new("plan", "Plan").description("Planning mode — no tool execution"), + ); + Some(SessionModeState::new(current_mode_id, available_modes)) } async fn find_current_model(&self) -> Option { @@ -2496,6 +3271,14 @@ impl ThreadActor { Some((model.to_owned(), reasoning)) } + fn current_service_tier_id(&self) -> &'static str { + match self.config.service_tier { + Some(ServiceTier::Fast) => "fast", + Some(ServiceTier::Flex) => "flex", + None => "standard", + } + } + async fn config_options(&self) -> Result, Error> { let mut options = Vec::new(); @@ -2549,6 +3332,24 @@ impl ThreadActor { .description("Choose which model Codex should use"), ); + options.push( + SessionConfigOption::select( + "service_tier", + "Service Tier", + self.current_service_tier_id(), + vec![ + SessionConfigSelectOption::new("standard", "Standard") + .description("Use the default response tier"), + SessionConfigSelectOption::new("fast", "Fast") + .description("Prefer the fast response tier"), + SessionConfigSelectOption::new("flex", "Flex") + .description("Use the flex response tier when available"), + ], + ) + .category(SessionConfigOptionCategory::Model) + .description("Choose the response service tier for this session"), + ); + // Reasoning effort selector (only if the current preset exists and has >1 supported effort) if let Some(preset) = current_preset && preset.supported_reasoning_efforts.len() > 1 @@ -2622,6 +3423,7 @@ impl ThreadActor { match config_id.0.as_ref() { "mode" => self.handle_set_mode(SessionModeId::new(value.0)).await, "model" => self.handle_set_config_model(value).await, + "service_tier" => self.handle_set_config_service_tier(value).await, "reasoning_effort" => self.handle_set_config_reasoning_effort(value).await, _ => Err(Error::invalid_params().data("Unsupported config option")), } @@ -2676,10 +3478,29 @@ impl ThreadActor { self.config.model = Some(model_to_use); self.config.model_reasoning_effort = effort_to_use; + self.persist_model_default( + self.config.model.as_deref().unwrap_or_default(), + self.config.model_reasoning_effort, + ) + .await?; Ok(()) } + async fn handle_set_config_service_tier( + &mut self, + value: SessionConfigValueId, + ) -> Result<(), Error> { + let service_tier = match value.0.as_ref() { + "standard" => None, + "fast" => Some(ServiceTier::Fast), + "flex" => Some(ServiceTier::Flex), + _ => return Err(Error::invalid_params().data("Unsupported service tier")), + }; + + self.set_service_tier(service_tier).await + } + async fn handle_set_config_reasoning_effort( &mut self, value: SessionConfigValueId, @@ -2721,6 +3542,7 @@ impl ThreadActor { .map_err(|e| Error::from(anyhow::anyhow!(e)))?; self.config.model_reasoning_effort = Some(effort); + self.persist_reasoning_effort_default(effort).await?; Ok(()) } @@ -2830,6 +3652,111 @@ impl ThreadActor { self.auth.logout()?; return Err(Error::auth_required()); } + "fast" => { + let tier = match rest.trim().to_ascii_lowercase().as_str() { + "" => { + if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + } + } + "on" => Some(ServiceTier::Fast), + "off" => None, + "status" => { + let client = self.client.clone(); + let status = + if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "on" + } else { + "off" + }; + client + .send_agent_text(format!("Fast mode is {status}.\n")) + .await; + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + _ => { + let client = self.client.clone(); + client + .send_agent_text("Usage: /fast [on|off|status]\n".to_string()) + .await; + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + }; + self.set_service_tier(tier).await?; + self.maybe_emit_config_options_update().await; + let client = self.client.clone(); + let status = if matches!(tier, Some(ServiceTier::Fast)) { + "on" + } else { + "off" + }; + client + .send_agent_text(format!("Fast mode is {status}.\n")) + .await; + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + "stop" => op = Op::CleanBackgroundTerminals, + "diff" => { + let cwd = self.config.cwd.clone(); + let client = self.client.clone(); + tokio::task::spawn_local(async move { + let output = run_git_diff(&cwd).await; + client.send_agent_text(output).await; + }); + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + "status" => { + let client = self.client.clone(); + let status = self.format_session_status().await; + client.send_agent_text(status).await; + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + "rename" if !rest.is_empty() => { + let name = rest.trim().to_owned(); + self.thread + .submit(Op::SetThreadName { name: name.clone() }) + .await + .map_err(|e| Error::internal_error().data(e.to_string()))?; + let client = self.client.clone(); + client + .send_agent_text(format!("Thread renamed to: {name}\n")) + .await; + response_tx.send(Ok(StopReason::EndTurn)).ok(); + return Ok(response_rx); + } + "mcp" => { + self.thread + .submit(Op::ListMcpTools) + .await + .map_err(|e| Error::internal_error().data(e.to_string()))?; + self.pending_list_response = Some(PendingListResponse { + kind: ListResponseKind::McpTools, + response_tx, + }); + return Ok(response_rx); + } + "skills" => { + let cwds = vec![self.config.cwd.clone()]; + self.thread + .submit(Op::ListSkills { + cwds, + force_reload: false, + }) + .await + .map_err(|e| Error::internal_error().data(e.to_string()))?; + self.pending_list_response = Some(PendingListResponse { + kind: ListResponseKind::Skills, + response_tx, + }); + return Ok(response_rx); + } _ => { if let Some(prompt) = expand_custom_prompt(name, rest, self.custom_prompts.borrow().as_ref()) @@ -2873,12 +3800,74 @@ impl ThreadActor { response_tx, )); + self.latest_prompt_submission_id = Some(submission_id.clone()); self.submissions.insert(submission_id, state); Ok(response_rx) } async fn handle_set_mode(&mut self, mode: SessionModeId) -> Result<(), Error> { + // Handle plan mode specially — it uses collaboration mode, not approval presets. + // Like the TUI, we don't override the model in the collaboration settings; + // codex will use whatever model is already configured for the session. + if mode.0.as_ref() == "plan" { + // Pass the current model explicitly — an empty string causes codex + // core to error with "'' model is not supported". + let current_model = self.get_current_model().await; + self.thread + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: current_model, + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), + personality: None, + windows_sandbox_level: None, + service_tier: None, + }) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + self.plan_mode_active = true; + return Ok(()); + } + + // Switching away from plan — clear the collaboration mode override + if self.plan_mode_active { + self.plan_mode_active = false; + let current_model = self.get_current_model().await; + self.thread + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: current_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + personality: None, + windows_sandbox_level: None, + service_tier: None, + }) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + } + let preset = APPROVAL_PRESETS .iter() .find(|preset| mode.0.as_ref() == preset.id) @@ -2925,6 +3914,8 @@ impl ThreadActor { SandboxPolicy::ReadOnly { .. } => {} } + self.persist_mode_default(preset).await?; + Ok(()) } @@ -2968,6 +3959,11 @@ impl ThreadActor { self.config.model = Some(model_to_use); self.config.model_reasoning_effort = effort_to_use; + self.persist_model_default( + self.config.model.as_deref().unwrap_or_default(), + self.config.model_reasoning_effort, + ) + .await?; Ok(()) } @@ -3003,13 +3999,16 @@ impl ThreadActor { /// - `EventMsg` for user/agent messages and reasoning (like the TUI does) /// - `ResponseItem` for tool calls only (not persisted as EventMsg) async fn handle_replay_history(&mut self, history: Vec) -> Result<(), Error> { + let apply_patch_statuses = self.derive_apply_patch_replay_statuses(&history); + for item in history { match item { RolloutItem::EventMsg(event_msg) => { self.replay_event_msg(&event_msg).await; } RolloutItem::ResponseItem(response_item) => { - self.replay_response_item(&response_item).await; + self.replay_response_item(&response_item, &apply_patch_statuses) + .await; } // Skip SessionMeta, TurnContext, Compacted _ => {} @@ -3116,6 +4115,19 @@ impl ThreadActor { Some((title, locations, content)) } + fn parse_apply_patch_function_call( + &self, + arguments: &str, + ) -> Option<(String, Vec, Vec)> { + #[derive(serde::Deserialize)] + struct ApplyPatchArgs { + input: String, + } + + let args: ApplyPatchArgs = serde_json::from_str(arguments).ok()?; + self.parse_apply_patch_call(&args.input) + } + /// Parse shell function call arguments to extract command info for rich display. /// Returns (title, kind, locations) if successful. /// @@ -3170,9 +4182,107 @@ impl ThreadActor { Some((title, kind, locations)) } + fn derive_apply_patch_replay_statuses( + &self, + history: &[RolloutItem], + ) -> HashMap { + let mut statuses = HashMap::new(); + let mut active = HashSet::new(); + + for item in history { + match item { + RolloutItem::ResponseItem(ResponseItem::CustomToolCall { + name, + status, + call_id, + .. + }) if name == "apply_patch" => { + let tool_status = response_tool_status(status.as_deref()) + .unwrap_or(ToolCallStatus::InProgress); + + if matches!( + tool_status, + ToolCallStatus::Completed | ToolCallStatus::Failed + ) { + active.remove(call_id); + } else { + active.insert(call_id.clone()); + } + + statuses.insert(call_id.clone(), tool_status); + } + RolloutItem::ResponseItem(ResponseItem::FunctionCall { name, call_id, .. }) + if name == "apply_patch" => + { + active.insert(call_id.clone()); + statuses + .entry(call_id.clone()) + .or_insert(ToolCallStatus::InProgress); + } + RolloutItem::ResponseItem( + ResponseItem::FunctionCallOutput { call_id, .. } + | ResponseItem::CustomToolCallOutput { call_id, .. }, + ) => { + if active.remove(call_id) || statuses.contains_key(call_id) { + statuses.insert(call_id.clone(), ToolCallStatus::Completed); + } + } + RolloutItem::EventMsg(EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id, + success, + status, + .. + })) => { + active.remove(call_id); + statuses.insert(call_id.clone(), patch_apply_tool_status(status, *success)); + } + RolloutItem::EventMsg(EventMsg::TurnAborted(_)) + | RolloutItem::EventMsg(EventMsg::Error(_)) + | RolloutItem::EventMsg(EventMsg::ShutdownComplete) => { + for call_id in active.drain() { + statuses.insert(call_id, ToolCallStatus::Failed); + } + } + _ => {} + } + } + + statuses + } + + async fn replay_apply_patch_tool_call( + &self, + call_id: &str, + raw_input: Option, + parsed: Option<(String, Vec, Vec)>, + status: ToolCallStatus, + ) { + let mut tool_call = if let Some((title, locations, content)) = parsed { + ToolCall::new(call_id.to_string(), title) + .kind(ToolKind::Edit) + .status(status) + .locations(locations) + .content(content) + } else { + ToolCall::new(call_id.to_string(), "Apply patch") + .kind(ToolKind::Edit) + .status(status) + }; + + if let Some(raw_input) = raw_input { + tool_call = tool_call.raw_input(raw_input); + } + + self.client.send_tool_call(tool_call).await; + } + /// Convert and send a single ResponseItem as ACP notification(s) during replay. /// Only handles tool calls - messages/reasoning are handled via EventMsg. - async fn replay_response_item(&self, item: &ResponseItem) { + async fn replay_response_item( + &self, + item: &ResponseItem, + apply_patch_statuses: &HashMap, + ) { match item { // Skip Message and Reasoning - these are handled via EventMsg ResponseItem::Message { .. } | ResponseItem::Reasoning { .. } => {} @@ -3201,6 +4311,22 @@ impl ThreadActor { return; } + if name == "apply_patch" { + let status = apply_patch_statuses + .get(call_id) + .copied() + .unwrap_or(ToolCallStatus::InProgress); + + self.replay_apply_patch_tool_call( + call_id, + serde_json::from_str(arguments).ok(), + self.parse_apply_patch_function_call(arguments), + status, + ) + .await; + return; + } + // Fall through to generic function call handling self.client .send_completed_tool_call( @@ -3263,20 +4389,19 @@ impl ThreadActor { call_id, .. } => { - // Check if this is an apply_patch call - show the patch content - if name == "apply_patch" - && let Some((title, locations, content)) = self.parse_apply_patch_call(input) - { - self.client - .send_tool_call( - ToolCall::new(call_id.clone(), title) - .kind(ToolKind::Edit) - .status(ToolCallStatus::Completed) - .locations(locations) - .content(content) - .raw_input(serde_json::from_str::(input).ok()), - ) - .await; + if name == "apply_patch" { + let status = apply_patch_statuses + .get(call_id) + .copied() + .unwrap_or(ToolCallStatus::InProgress); + + self.replay_apply_patch_tool_call( + call_id, + serde_json::from_str::(input).ok(), + self.parse_apply_patch_call(input), + status, + ) + .await; return; } @@ -3315,29 +4440,156 @@ impl ThreadActor { } async fn handle_event(&mut self, Event { id, msg }: Event) { + if is_auto_compact_submission_id(&id) { + if is_auto_compaction_event(&msg) + && let Some(submission_id) = self.active_prompt_submission_id() + && let Some(submission) = self.submissions.get_mut(&submission_id) + { + debug!( + "Routing live auto-compaction event to active prompt: source_id={id}, target_id={submission_id}, msg={msg:?}" + ); + submission.handle_event(&self.client, msg).await; + } else { + debug!("Ignoring compact replay event for submission ID: {id} {msg:?}"); + } + return; + } + + if is_global_event(&msg) { + self.handle_global_event(&msg).await; + return; + } + if let Some(submission) = self.submissions.get_mut(&id) { submission.handle_event(&self.client, msg).await; } else { warn!("Received event for unknown submission ID: {id} {msg:?}"); } } -} -fn build_prompt_items(prompt: Vec) -> Vec { - prompt - .into_iter() - .filter_map(|block| match block { - ContentBlock::Text(text_block) => Some(UserInput::Text { - text: text_block.text, - text_elements: vec![], - }), - ContentBlock::Image(image_block) => Some(UserInput::Image { - image_url: format!("data:{};base64,{}", image_block.mime_type, image_block.data), - }), - ContentBlock::ResourceLink(ResourceLink { name, uri, .. }) => Some(UserInput::Text { - text: format_uri_as_link(Some(name), uri), - text_elements: vec![], - }), + fn active_prompt_submission_id(&self) -> Option { + if let Some(submission_id) = &self.latest_prompt_submission_id + && self + .submissions + .get(submission_id) + .is_some_and(|submission| { + matches!(submission, SubmissionState::Prompt(..)) && submission.is_active() + }) + { + return Some(submission_id.clone()); + } + + self.submissions + .iter() + .find_map(|(submission_id, submission)| { + (matches!(submission, SubmissionState::Prompt(..)) && submission.is_active()) + .then(|| submission_id.clone()) + }) + } + + async fn handle_global_event(&mut self, msg: &EventMsg) { + match msg { + EventMsg::McpListToolsResponse(..) | EventMsg::ListSkillsResponse(..) => { + self.handle_list_response(msg).await; + } + EventMsg::McpStartupUpdate(McpStartupUpdateEvent { server, status }) => { + info!("MCP startup update: server={server}, status={status:?}"); + } + EventMsg::McpStartupComplete(McpStartupCompleteEvent { + ready, + failed, + cancelled, + }) => { + info!( + "MCP startup complete: ready={ready:?}, failed={failed:?}, cancelled={cancelled:?}" + ); + } + _ => {} + } + } + + async fn handle_list_response(&mut self, msg: &EventMsg) { + match msg { + EventMsg::McpListToolsResponse(McpListToolsResponseEvent { + tools, + resources: _, + resource_templates: _, + auth_statuses: _, + }) => { + if let Some(pending) = self.pending_list_response.take() { + if matches!(pending.kind, ListResponseKind::McpTools) { + let mut text = String::from("## Configured MCP Tools\n\n"); + if tools.is_empty() { + text.push_str("No MCP tools configured.\n"); + } else { + for (server, tool) in tools { + let desc = tool.description.as_deref().unwrap_or("No description"); + text.push_str(&format!( + "- **{server}** / `{}` \n {desc}\n", + tool.name + )); + } + } + self.client.send_agent_text(text).await; + pending.response_tx.send(Ok(StopReason::EndTurn)).ok(); + } + } else { + warn!("Unexpected McpListToolsResponse event"); + } + } + EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }) => { + if let Some(pending) = self.pending_list_response.take() { + if matches!(pending.kind, ListResponseKind::Skills) { + let mut text = String::from("## Available Skills\n\n"); + let mut any_skills = false; + for entry in skills { + for skill in &entry.skills { + any_skills = true; + let name = &skill.name; + let desc = skill + .short_description + .as_deref() + .unwrap_or("No description"); + text.push_str(&format!("- **{name}** \n {desc}\n")); + } + for err in &entry.errors { + text.push_str(&format!( + "- **Error** in `{}` \n {}\n", + err.path.display(), + err.message + )); + } + } + if !any_skills { + text.push_str("No skills configured.\n"); + } + self.client.send_agent_text(text).await; + pending.response_tx.send(Ok(StopReason::EndTurn)).ok(); + } + } else { + warn!("Unexpected ListSkillsResponse event"); + } + } + _ => {} + } + } +} + +fn build_prompt_items(prompt: Vec) -> Vec { + prompt + .into_iter() + .filter_map(|block| match block { + ContentBlock::Text(text_block) => Some(UserInput::Text { + text: text_block.text, + text_elements: vec![], + }), + ContentBlock::Image(image_block) => Some(UserInput::Image { + image_url: format!("data:{};base64,{}", image_block.mime_type, image_block.data), + }), + ContentBlock::ResourceLink(ResourceLink { name, uri, .. }) => Some(UserInput::Text { + text: format_uri_as_link(Some(name), uri), + text_elements: vec![], + }), ContentBlock::Resource(EmbeddedResource { resource: EmbeddedResourceResource::TextResourceContents(TextResourceContents { @@ -3449,6 +4701,75 @@ fn generate_fallback_id(prefix: &str) -> String { format!("{}_{}", prefix, Uuid::new_v4()) } +fn format_service_tier_name(service_tier: Option) -> &'static str { + match service_tier { + Some(ServiceTier::Fast) => "Fast", + Some(ServiceTier::Flex) => "Flex", + None => "Standard", + } +} + +/// Runs `git diff`, `git diff --cached`, and `git ls-files --others` in the given directory +/// and returns formatted output. +async fn run_git_diff(cwd: &Path) -> String { + use tokio::process::Command; + + let mut output = String::new(); + + // Staged changes + if let Ok(result) = Command::new("git") + .args(["diff", "--cached"]) + .current_dir(cwd) + .output() + .await + { + let staged = String::from_utf8_lossy(&result.stdout); + if !staged.is_empty() { + output.push_str("## Staged Changes\n\n```diff\n"); + output.push_str(&staged); + output.push_str("```\n\n"); + } + } + + // Unstaged changes + if let Ok(result) = Command::new("git") + .arg("diff") + .current_dir(cwd) + .output() + .await + { + let unstaged = String::from_utf8_lossy(&result.stdout); + if !unstaged.is_empty() { + output.push_str("## Unstaged Changes\n\n```diff\n"); + output.push_str(&unstaged); + output.push_str("```\n\n"); + } + } + + // Untracked files + if let Ok(result) = Command::new("git") + .args(["ls-files", "--others", "--exclude-standard"]) + .current_dir(cwd) + .output() + .await + { + let untracked = String::from_utf8_lossy(&result.stdout); + if !untracked.is_empty() { + output.push_str("## Untracked Files\n\n"); + for file in untracked.lines() { + output.push_str(&format!("- {file}\n")); + } + output.push('\n'); + } + } + + if output.is_empty() { + output.push_str("No changes detected.\n"); + } + + output +} + /// Checks if a prompt is slash command fn extract_slash_command(content: &[UserInput]) -> Option<(&str, &str)> { let line = content.first().and_then(|block| match block { @@ -3466,9 +4787,14 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::time::Duration; - use agent_client_protocol::{RequestPermissionResponse, TextContent}; - use codex_core::{config::ConfigOverrides, test_support::all_model_presets}; - use codex_protocol::config_types::ModeKind; + use agent_client_protocol::{ + RequestPermissionResponse, SessionConfigKind, SessionConfigSelect, TextContent, + }; + use codex_core::{ + config::{ConfigBuilder, ConfigOverrides}, + test_support::all_model_presets, + }; + use codex_protocol::config_types::{ModeKind, ServiceTier}; use tokio::{ sync::{Mutex, Notify, mpsc::UnboundedSender}, task::LocalSet, @@ -3972,7 +5298,27 @@ mod tests { Ok(()) } - async fn setup( + fn make_test_codex_home() -> anyhow::Result { + let codex_home = std::env::temp_dir().join(format!("codex-acp-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + Ok(codex_home) + } + + async fn default_test_config() -> anyhow::Result { + let codex_home = make_test_codex_home()?; + let cwd = std::env::current_dir()?; + Ok(ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd), + ..ConfigOverrides::default() + }) + .build() + .await?) + } + + async fn setup_with_config( + config: Config, custom_prompts: Vec, ) -> anyhow::Result<( SessionId, @@ -3987,11 +5333,6 @@ mod tests { SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); let conversation = Arc::new(StubCodexThread::new()); let models_manager = Arc::new(StubModelsManager); - let config = Config::load_with_cli_overrides_and_harness_overrides( - vec![], - ConfigOverrides::default(), - ) - .await?; let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -4012,6 +5353,18 @@ mod tests { Ok((session_id, client, conversation, message_tx, local_set)) } + async fn setup( + custom_prompts: Vec, + ) -> anyhow::Result<( + SessionId, + Arc, + Arc, + UnboundedSender, + LocalSet, + )> { + setup_with_config(default_test_config().await?, custom_prompts).await + } + struct StubAuth; impl Auth for StubAuth { @@ -4148,267 +5501,3060 @@ mod tests { last_agent_message: None, turn_id, })); - } else if prompt == "approval-block" { - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - call_id: "call-id".to_string(), - approval_id: Some("approval-id".to_string()), - turn_id: id.to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: std::env::current_dir().unwrap(), - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: Some(vec![ - ReviewDecision::Approved, - ReviewDecision::Abort, - ]), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hi".to_string(), - }], - }), - }) - .unwrap(); - } else { - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::AgentMessageContentDelta( - AgentMessageContentDeltaEvent { - thread_id: id.to_string(), - turn_id: id.to_string(), - item_id: id.to_string(), - delta: prompt.clone(), - }, - ), - }) - .unwrap(); - // Send non-delta event (should be deduplicated, but handled by deduplication) - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: prompt, - phase: None, - }), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - turn_id: id.to_string(), - }), - }) - .unwrap(); - } - } - Op::Compact => { - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - collaboration_mode_kind: ModeKind::default(), - turn_id: id.to_string(), + } else if prompt == "emit-hook-events" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::HookStarted(HookStartedEvent { + turn_id: Some(id.to_string()), + run: codex_protocol::protocol::HookRunSummary { + id: "hook-1".to_string(), + event_name: codex_protocol::protocol::HookEventName::SessionStart, + handler_type: codex_protocol::protocol::HookHandlerType::Command, + execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, + scope: codex_protocol::protocol::HookScope::Thread, + source_path: std::path::PathBuf::from("/test/hook.sh"), + display_order: 0, + status: codex_protocol::protocol::HookRunStatus::Running, + status_message: None, + started_at: 0, + completed_at: None, + duration_ms: None, + entries: vec![], + }, + })); + send(EventMsg::HookCompleted(HookCompletedEvent { + turn_id: Some(id.to_string()), + run: codex_protocol::protocol::HookRunSummary { + id: "hook-1".to_string(), + event_name: codex_protocol::protocol::HookEventName::SessionStart, + handler_type: codex_protocol::protocol::HookHandlerType::Command, + execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, + scope: codex_protocol::protocol::HookScope::Thread, + source_path: std::path::PathBuf::from("/test/hook.sh"), + display_order: 0, + status: codex_protocol::protocol::HookRunStatus::Completed, + status_message: Some("all good".to_string()), + started_at: 0, + completed_at: Some(100), + duration_ms: Some(100), + entries: vec![], + }, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-image-gen" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: "img-1".to_string(), + })); + send(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: "img-1".to_string(), + status: "success".to_string(), + revised_prompt: None, + result: "image.png".to_string(), + saved_path: None, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-background-event" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::BackgroundEvent(BackgroundEventEvent { + message: "Long running task completed".to_string(), + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-auto-compact" { + let auto_compact_id = "auto-compact-0".to_string(); + let compaction_item = codex_protocol::items::ContextCompactionItem::new(); + let send = |event_id: String, msg| { + self.op_tx.send(Event { id: event_id, msg }).unwrap(); + }; + send( + auto_compact_id.clone(), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id: codex_protocol::ThreadId::new(), + turn_id: auto_compact_id.clone(), + item: TurnItem::ContextCompaction(compaction_item.clone()), }), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Compact task completed".to_string(), - phase: None, + ); + send( + auto_compact_id.clone(), + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: codex_protocol::ThreadId::new(), + turn_id: auto_compact_id.clone(), + item: TurnItem::ContextCompaction(compaction_item), }), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { + ); + send( + auto_compact_id, + EventMsg::ContextCompacted( + codex_protocol::protocol::ContextCompactedEvent {}, + ), + ); + send( + id.to_string(), + EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message: None, turn_id: id.to_string(), }), - }) - .unwrap(); - } - Op::Undo => { - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::UndoStarted( - codex_protocol::protocol::UndoStartedEvent { - message: Some("Undo in progress...".to_string()), - }, - ), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::UndoCompleted( - codex_protocol::protocol::UndoCompletedEvent { - success: true, - message: Some("Undo completed.".to_string()), + ); + } else if prompt == "emit-deprecation-notice" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::DeprecationNotice(DeprecationNoticeEvent { + summary: "Old API deprecated".to_string(), + details: Some("Please migrate to v2.".to_string()), + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-thread-rollback" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 3, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-reasoning-raw" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::AgentReasoningRawContent( + AgentReasoningRawContentEvent { + text: "Thinking about the problem...".to_string(), + }, + )); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-plan-delta" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::PlanDelta(PlanDeltaEvent { + thread_id: id.to_string(), + turn_id: id.to_string(), + item_id: "plan-item-1".to_string(), + delta: "Step 1: Analyze the code".to_string(), + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-collab-spawn" { + use codex_protocol::ThreadId as ProtoThreadId; + use codex_protocol::protocol::AgentStatus; + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::CollabAgentSpawnBegin( + CollabAgentSpawnBeginEvent { + call_id: "collab-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + prompt: "Review this file".to_string(), + model: "gpt-4".to_string(), + reasoning_effort: ReasoningEffort::Medium, + }, + )); + send(EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id: "collab-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + new_thread_id: Some(ProtoThreadId::new()), + new_agent_nickname: Some("reviewer".to_string()), + new_agent_role: None, + prompt: String::new(), + status: AgentStatus::Completed(None), + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-collab-interaction" { + use codex_protocol::ThreadId as ProtoThreadId; + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + send(EventMsg::CollabAgentInteractionBegin( + CollabAgentInteractionBeginEvent { + call_id: "interact-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + receiver_thread_id: ProtoThreadId::new(), + prompt: "Help with this".to_string(), + }, + )); + send(EventMsg::CollabAgentInteractionEnd( + CollabAgentInteractionEndEvent { + call_id: "interact-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + receiver_thread_id: ProtoThreadId::new(), + receiver_agent_nickname: Some("reviewer".to_string()), + receiver_agent_role: None, + prompt: String::new(), + status: codex_protocol::protocol::AgentStatus::Completed(None), + }, + )); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-token-counts" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + use codex_protocol::protocol::{ + CreditsSnapshot, RateLimitSnapshot, TokenUsage, TokenUsageInfo, + }; + // Send two TokenCount events to test accumulation + send(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 50, + reasoning_output_tokens: 10, + total_tokens: 180, }, - ), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - turn_id: id.to_string(), + last_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 50, + reasoning_output_tokens: 10, + total_tokens: 180, + }, + model_context_window: Some(128000), }), - }) - .unwrap(); - } - Op::Review { review_request } => { - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::EnteredReviewMode(review_request.clone()), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: Some(ReviewOutputEvent { - findings: vec![], - overall_correctness: String::new(), - overall_explanation: review_request - .user_facing_hint - .clone() - .unwrap_or_default(), - overall_confidence_score: 1., + rate_limits: Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("1.50".to_string()), }), + plan_type: None, }), - }) - .unwrap(); - self.op_tx - .send(Event { - id: id.to_string(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - turn_id: id.to_string(), + })); + // Second event to verify accumulation + send(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 200, + cached_input_tokens: 40, + output_tokens: 100, + reasoning_output_tokens: 20, + total_tokens: 360, + }, + last_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 20, + output_tokens: 50, + reasoning_output_tokens: 10, + total_tokens: 180, + }, + model_context_window: Some(128000), }), - }) - .unwrap(); - } - Op::ExecApproval { .. } - | Op::ResolveElicitation { .. } - | Op::RequestPermissionsResponse { .. } - | Op::PatchApproval { .. } - | Op::Interrupt => {} - Op::Shutdown => { - if let Some(active_prompt_id) = self.active_prompt_id.lock().unwrap().take() { - self.op_tx - .send(Event { - id: active_prompt_id.clone(), - msg: EventMsg::TurnAborted(TurnAbortedEvent { - turn_id: Some(active_prompt_id), - reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + rate_limits: None, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-token-none-info" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + // TokenCount with info: None — should be handled gracefully + send(EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-token-no-window" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + use codex_protocol::protocol::{TokenUsage, TokenUsageInfo}; + send(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 50, + cached_input_tokens: 10, + output_tokens: 25, + reasoning_output_tokens: 5, + total_tokens: 90, + }, + last_token_usage: TokenUsage { + input_tokens: 50, + cached_input_tokens: 10, + output_tokens: 25, + reasoning_output_tokens: 5, + total_tokens: 90, + }, + model_context_window: None, // No context window + }), + rate_limits: None, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-token-bad-balance" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + use codex_protocol::protocol::{ + CreditsSnapshot, RateLimitSnapshot, TokenUsage, TokenUsageInfo, + }; + send(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage::default(), + last_token_usage: TokenUsage { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 0, + total_tokens: 15, + }, + model_context_window: Some(128000), + }), + rate_limits: Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("not-a-number".to_string()), }), - }) - .unwrap(); - } - } - _ => { - unimplemented!() - } + plan_type: None, + }), + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-orphaned-events" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + // Send output delta for a call_id that was never started + send(EventMsg::ExecCommandOutputDelta( + ExecCommandOutputDeltaEvent { + call_id: "nonexistent-call".to_string(), + chunk: b"some output".to_vec(), + stream: codex_protocol::protocol::ExecOutputStream::Stdout, + }, + )); + // Send end for a call_id that was never started + send(EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "nonexistent-call".to_string(), + process_id: None, + turn_id: id.to_string(), + command: vec!["echo".into()], + cwd: std::env::current_dir().unwrap(), + parsed_cmd: vec![], + source: Default::default(), + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(1), + formatted_output: String::new(), + status: ExecCommandStatus::Completed, + })); + // Send terminal interaction for unknown call_id + send(EventMsg::TerminalInteraction(TerminalInteractionEvent { + call_id: "nonexistent-call".to_string(), + process_id: "0".to_string(), + stdin: "input".to_string(), + })); + // Should still complete normally + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-patch-abort" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + // Emit a PatchApplyBegin with one fake file change, then + // abort without ever emitting PatchApplyEnd. + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("/tmp/fake-file.txt"), + FileChange::Add { + content: "new content".to_string(), + }, + ); + send(EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "patch-call-1".to_string(), + auto_approved: true, + changes, + turn_id: id.to_string(), + })); + // Abort without PatchApplyEnd + send(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + })); + } else if prompt == "emit-collab-waiting" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + use codex_protocol::ThreadId as ProtoThreadId; + use codex_protocol::protocol::AgentStatus; + // Spawn a sub-agent first so active_subagent_call_id is set + send(EventMsg::CollabAgentSpawnBegin( + CollabAgentSpawnBeginEvent { + call_id: "wait-spawn-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + prompt: "Review".to_string(), + model: "gpt-4".to_string(), + reasoning_effort: ReasoningEffort::Medium, + }, + )); + send(EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id: "wait-spawn-1".to_string(), + sender_thread_id: ProtoThreadId::new(), + new_thread_id: Some(ProtoThreadId::new()), + new_agent_nickname: None, + new_agent_role: None, + prompt: String::new(), + status: AgentStatus::Running, + })); + send(EventMsg::CollabWaitingBegin( + codex_protocol::protocol::CollabWaitingBeginEvent { + sender_thread_id: ProtoThreadId::new(), + receiver_thread_ids: vec![ProtoThreadId::new()], + receiver_agents: vec![], + call_id: "wait-1".to_string(), + }, + )); + send(EventMsg::CollabWaitingEnd( + codex_protocol::protocol::CollabWaitingEndEvent { + sender_thread_id: ProtoThreadId::new(), + call_id: "wait-1".to_string(), + agent_statuses: vec![], + statuses: HashMap::new(), + }, + )); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "emit-web-search-concurrent" { + let send = |msg| { + self.op_tx + .send(Event { + id: id.to_string(), + msg, + }) + .unwrap(); + }; + // Start two concurrent web searches + send(EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: "ws-1".to_string(), + })); + send(EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: "ws-2".to_string(), + })); + // Trigger completion of all searches via ExecCommandBegin + send(EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "exec-after-search".to_string(), + process_id: None, + turn_id: id.to_string(), + command: vec!["echo".into(), "done".into()], + cwd: std::env::current_dir().unwrap(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: Default::default(), + interaction_input: None, + })); + send(EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-after-search".to_string(), + process_id: None, + turn_id: id.to_string(), + command: vec!["echo".into(), "done".into()], + cwd: std::env::current_dir().unwrap(), + parsed_cmd: vec![], + source: Default::default(), + interaction_input: None, + stdout: "done\n".into(), + stderr: String::new(), + aggregated_output: "done\n".into(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done\n".into(), + status: ExecCommandStatus::Completed, + })); + send(EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + })); + } else if prompt == "approval-block" { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: "call-id".to_string(), + approval_id: Some("approval-id".to_string()), + turn_id: id.to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: std::env::current_dir().unwrap(), + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: Some(vec![ + ReviewDecision::Approved, + ReviewDecision::Abort, + ]), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hi".to_string(), + }], + }), + }) + .unwrap(); + } else { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::AgentMessageContentDelta( + AgentMessageContentDeltaEvent { + thread_id: id.to_string(), + turn_id: id.to_string(), + item_id: id.to_string(), + delta: prompt.clone(), + }, + ), + }) + .unwrap(); + // Send non-delta event (should be deduplicated, but handled by deduplication) + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: prompt, + phase: None, + }), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + }), + }) + .unwrap(); + } + } + Op::Compact => { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + collaboration_mode_kind: ModeKind::default(), + turn_id: id.to_string(), + }), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Compact task completed".to_string(), + phase: None, + }), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + }), + }) + .unwrap(); + } + Op::Undo => { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::UndoStarted( + codex_protocol::protocol::UndoStartedEvent { + message: Some("Undo in progress...".to_string()), + }, + ), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::UndoCompleted( + codex_protocol::protocol::UndoCompletedEvent { + success: true, + message: Some("Undo completed.".to_string()), + }, + ), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + }), + }) + .unwrap(); + } + Op::Review { review_request } => { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::EnteredReviewMode(review_request.clone()), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: Some(ReviewOutputEvent { + findings: vec![], + overall_correctness: String::new(), + overall_explanation: review_request + .user_facing_hint + .clone() + .unwrap_or_default(), + overall_confidence_score: 1., + }), + }), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + }), + }) + .unwrap(); + } + Op::CleanBackgroundTerminals => { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Background terminals stopped.".to_string(), + phase: None, + }), + }) + .unwrap(); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + turn_id: id.to_string(), + }), + }) + .unwrap(); + } + Op::SetThreadName { .. } => { + // Fire-and-forget — no events needed + } + Op::ListMcpTools => { + use codex_protocol::mcp::Tool as McpTool; + let mut tools = std::collections::HashMap::new(); + tools.insert( + "test-server".to_string(), + McpTool { + name: "test-tool".to_string(), + title: None, + description: Some("A test MCP tool".to_string()), + input_schema: serde_json::json!({}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + ); + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::McpListToolsResponse(McpListToolsResponseEvent { + tools, + resources: std::collections::HashMap::new(), + resource_templates: std::collections::HashMap::new(), + auth_statuses: std::collections::HashMap::new(), + }), + }) + .unwrap(); + } + Op::ListSkills { .. } => { + self.op_tx + .send(Event { + id: id.to_string(), + msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { + skills: vec![], + }), + }) + .unwrap(); + } + Op::OverrideTurnContext { .. } + | Op::ExecApproval { .. } + | Op::ResolveElicitation { .. } + | Op::RequestPermissionsResponse { .. } + | Op::PatchApproval { .. } + | Op::Interrupt => {} + Op::Shutdown => { + if let Some(active_prompt_id) = self.active_prompt_id.lock().unwrap().take() { + self.op_tx + .send(Event { + id: active_prompt_id.clone(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(active_prompt_id), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + }), + }) + .unwrap(); + } + } + _ => { + unimplemented!() + } + } + Ok(id.to_string()) + } + + async fn next_event(&self) -> Result { + let Some(event) = self.op_rx.lock().await.recv().await else { + return Err(CodexErr::InternalAgentDied); + }; + Ok(event) + } + } + + struct StubClient { + notifications: std::sync::Mutex>, + permission_requests: std::sync::Mutex>, + permission_responses: std::sync::Mutex>, + block_permission_requests: Option>, + } + + impl StubClient { + fn new() -> Self { + StubClient { + notifications: std::sync::Mutex::default(), + permission_requests: std::sync::Mutex::default(), + permission_responses: std::sync::Mutex::default(), + block_permission_requests: None, + } + } + + fn with_permission_responses(responses: Vec) -> Self { + StubClient { + notifications: std::sync::Mutex::default(), + permission_requests: std::sync::Mutex::default(), + permission_responses: std::sync::Mutex::new(responses.into()), + block_permission_requests: None, + } + } + + fn with_blocked_permission_requests( + responses: Vec, + notify: Arc, + ) -> Self { + StubClient { + notifications: std::sync::Mutex::default(), + permission_requests: std::sync::Mutex::default(), + permission_responses: std::sync::Mutex::new(responses.into()), + block_permission_requests: Some(notify), + } + } + } + + #[async_trait::async_trait(?Send)] + impl Client for StubClient { + async fn request_permission( + &self, + args: RequestPermissionRequest, + ) -> Result { + self.permission_requests.lock().unwrap().push(args); + if let Some(notify) = &self.block_permission_requests { + notify.notified().await; + } + Ok(self + .permission_responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| { + RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled) + })) + } + + async fn session_notification(&self, args: SessionNotification) -> Result<(), Error> { + self.notifications.lock().unwrap().push(args); + Ok(()) + } + } + + #[tokio::test] + async fn test_parallel_exec_commands() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["parallel-exec".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Collect all ToolCall (begin) notifications keyed by their tool_call_id prefix. + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) => Some(tc.clone()), + _ => None, + }) + .collect(); + + // Collect all ToolCallUpdate notifications that carry a terminal status. + let completed_updates: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(update) => { + if update.fields.status == Some(ToolCallStatus::Completed) { + Some(update.clone()) + } else { + None + } + } + _ => None, + }) + .collect(); + + // Both commands A and B should have produced a ToolCall (begin). + assert_eq!( + tool_calls.len(), + 2, + "expected 2 ToolCall begin notifications, got {tool_calls:?}" + ); + + // Both commands A and B should have produced a completed ToolCallUpdate. + assert_eq!( + completed_updates.len(), + 2, + "expected 2 completed ToolCallUpdate notifications, got {completed_updates:?}" + ); + + // The completed updates should reference the same tool_call_ids as the begins. + let begin_ids: std::collections::HashSet<_> = tool_calls + .iter() + .map(|tc| tc.tool_call_id.clone()) + .collect(); + let end_ids: std::collections::HashSet<_> = completed_updates + .iter() + .map(|u| u.tool_call_id.clone()) + .collect(); + assert_eq!( + begin_ids, end_ids, + "completed update tool_call_ids should match begin tool_call_ids" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_exec_approval_uses_available_decisions() -> anyhow::Result<()> { + LocalSet::new() + .run_until(async { + let session_id = SessionId::new("test"); + let client = Arc::new(StubClient::with_permission_responses(vec![ + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new("denied"), + )), + ])); + let session_client = + SessionClient::with_client(session_id, client.clone(), Arc::default()); + let thread = Arc::new(StubCodexThread::new()); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let (message_tx, mut message_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut prompt_state = PromptState::new( + "submission-id".to_string(), + thread.clone(), + message_tx, + response_tx, + ); + + prompt_state + .exec_approval( + &session_client, + ExecApprovalRequestEvent { + call_id: "call-id".to_string(), + approval_id: Some("approval-id".to_string()), + turn_id: "turn-id".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: std::env::current_dir()?, + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: Some(vec![ + ReviewDecision::Approved, + ReviewDecision::Denied, + ]), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hi".to_string(), + }], + }, + ) + .await?; + + let ThreadMessage::PermissionRequestResolved { + submission_id, + request_key, + response, + } = message_rx.recv().await.unwrap() + else { + panic!("expected permission resolution message"); + }; + assert_eq!(submission_id, "submission-id"); + prompt_state + .handle_permission_request_resolved(&session_client, request_key, response) + .await?; + + let requests = client.permission_requests.lock().unwrap(); + let request = requests.last().unwrap(); + let option_ids = request + .options + .iter() + .map(|option| option.option_id.0.to_string()) + .collect::>(); + assert_eq!(option_ids, vec!["approved", "denied"]); + + let ops = thread.ops.lock().unwrap(); + assert!(matches!( + ops.last(), + Some(Op::ExecApproval { + id, + turn_id, + decision: ReviewDecision::Denied, + }) if id == "approval-id" && turn_id.as_deref() == Some("turn-id") + )); + + anyhow::Ok(()) + }) + .await?; + + Ok(()) + } + + #[tokio::test] + async fn test_mcp_elicitation_declines_unsupported_form_requests() -> anyhow::Result<()> { + LocalSet::new() + .run_until(async { + let session_id = SessionId::new("test"); + let client = Arc::new(StubClient::with_permission_responses(vec![ + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new("decline"), + )), + ])); + let session_client = + SessionClient::with_client(session_id, client.clone(), Arc::default()); + let thread = Arc::new(StubCodexThread::new()); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let (message_tx, _message_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut prompt_state = PromptState::new( + "submission-id".to_string(), + thread.clone(), + message_tx, + response_tx, + ); + + prompt_state + .mcp_elicitation( + &session_client, + ElicitationRequestEvent { + turn_id: Some("turn-id".to_string()), + server_name: "test-server".to_string(), + id: codex_protocol::mcp::RequestId::String("request-id".to_string()), + request: ElicitationRequest::Form { + meta: None, + message: "Need some structured input".to_string(), + requested_schema: serde_json::json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }), + }, + }, + ) + .await?; + + let requests = client.permission_requests.lock().unwrap(); + assert!( + requests.is_empty(), + "unsupported MCP elicitations should be auto-declined" + ); + + let ops = thread.ops.lock().unwrap(); + assert!(matches!( + ops.last(), + Some(Op::ResolveElicitation { + server_name, + request_id: codex_protocol::mcp::RequestId::String(request_id), + decision: ElicitationAction::Decline, + content: None, + meta: None, + }) if server_name == "test-server" && request_id == "request-id" + )); + + anyhow::Ok(()) + }) + .await?; + + Ok(()) + } + + #[tokio::test] + async fn test_blocked_approval_does_not_block_followup_events() -> anyhow::Result<()> { + LocalSet::new() + .run_until(async { + let session_id = SessionId::new("test"); + let client = Arc::new(StubClient::with_blocked_permission_requests( + vec![], + Arc::new(Notify::new()), + )); + let session_client = + SessionClient::with_client(session_id, client.clone(), Arc::default()); + let thread = Arc::new(StubCodexThread::new()); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let (message_tx, _message_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut prompt_state = + PromptState::new("submission-id".to_string(), thread, message_tx, response_tx); + + prompt_state + .handle_event( + &session_client, + EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: "call-id".to_string(), + approval_id: Some("approval-id".to_string()), + turn_id: "turn-id".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: std::env::current_dir()?, + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: Some(vec![ + ReviewDecision::Approved, + ReviewDecision::Abort, + ]), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hi".to_string(), + }], + }), + ) + .await; + + prompt_state + .handle_event( + &session_client, + EventMsg::AgentMessage(AgentMessageEvent { + message: "still flowing".to_string(), + phase: None, + }), + ) + .await; + + let notifications = client.notifications.lock().unwrap(); + assert!(notifications.iter().any(|notification| { + matches!( + ¬ification.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "still flowing" + ) + })); + + drop(notifications); + prompt_state.abort_pending_interactions(); + + anyhow::Ok(()) + }) + .await?; + + Ok(()) + } + + #[tokio::test] + async fn test_thread_shutdown_bypasses_blocked_permission_request() -> anyhow::Result<()> { + let session_id = SessionId::new("test"); + let client = Arc::new(StubClient::with_blocked_permission_requests( + vec![RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )], + Arc::new(Notify::new()), + )); + let session_client = + SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); + let conversation = Arc::new(StubCodexThread::new()); + let models_manager = Arc::new(StubModelsManager); + let config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); + let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); + let actor = ThreadActor::new( + StubAuth, + session_client, + conversation.clone(), + models_manager, + config, + message_rx, + resolution_tx, + resolution_rx, + ); + + let local_set = LocalSet::new(); + let handle = local_set.spawn_local(actor.spawn()); + let thread = Thread { + thread: conversation.clone(), + message_tx, + _handle: handle, + }; + + local_set + .run_until(async move { + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + thread.message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id, vec!["approval-block".into()]), + response_tx: prompt_response_tx, + })?; + let stop_reason_rx = prompt_response_rx.await??; + + tokio::time::timeout(Duration::from_millis(100), async { + loop { + if !client.permission_requests.lock().unwrap().is_empty() { + break; + } + tokio::task::yield_now().await; + } + }) + .await?; + + tokio::time::timeout(Duration::from_millis(100), thread.shutdown()).await??; + let stop_reason = + tokio::time::timeout(Duration::from_millis(100), stop_reason_rx).await??; + assert_eq!(stop_reason?, StopReason::Cancelled); + + anyhow::Ok(()) + }) + .await?; + + let ops = conversation.ops.lock().unwrap(); + assert!(matches!(ops.last(), Some(Op::Shutdown))); + + Ok(()) + } + + // ==================== New tests for P0/P2/P3 changes ==================== + + #[tokio::test] + async fn test_slash_stop() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/stop".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let ops = thread.ops.lock().unwrap(); + assert_eq!(ops.as_slice(), &[Op::CleanBackgroundTerminals]); + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Background terminals stopped." + )), + "expected background terminals stopped message, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_diff() -> anyhow::Result<()> { + let (session_id, client, _thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/diff".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // /diff now runs git locally and sends output as agent text — no Op submitted + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(..), + .. + }) + )), + "expected /diff to send agent text with git output" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_status() -> anyhow::Result<()> { + let (session_id, client, _thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/status".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // /status now assembles info locally — no Op submitted + let notifications = client.notifications.lock().unwrap(); + let has_status = notifications.iter().any(|n| { + if let SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(t), + .. + }) = &n.update + { + t.text.contains("Session Status") + && t.text.contains("**Mode:**") + && !t.text.contains("**Mode:** unknown") + && t.text.contains("**Service Tier:** Standard") + } else { + false + } + }); + assert!(has_status, "expected /status to send Session Status text"); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_rename() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/rename My New Thread".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // /rename now uses Op::SetThreadName + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::SetThreadName { name } if name == "My New Thread"), + "expected /rename to submit Op::SetThreadName, got {ops:?}" + ); + + // Should send confirmation text + let notifications = client.notifications.lock().unwrap(); + let has_confirm = notifications.iter().any(|n| { + if let SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(t), + .. + }) = &n.update + { + t.text.contains("My New Thread") + } else { + false + } + }); + assert!(has_confirm, "expected /rename to send confirmation text"); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_fast_toggle_on() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/fast".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let ops = thread.ops.lock().unwrap(); + assert!( + matches!( + ops.first(), + Some(Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + }) + ), + "expected /fast to enable fast mode, got {ops:?}" + ); + + let notifications = client.notifications.lock().unwrap(); + assert!(notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Fast mode is on.\n" + ))); + assert!(notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate { config_options, .. }) + if config_options.iter().any(|option| { + option.id.0.as_ref() == "service_tier" + && matches!( + option.kind, + SessionConfigKind::Select(SessionConfigSelect { ref current_value, .. }) + if current_value.0.as_ref() == "fast" + ) + }) + ))); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_fast_off() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/fast off".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let ops = thread.ops.lock().unwrap(); + assert!( + matches!( + ops.first(), + Some(Op::OverrideTurnContext { + service_tier: Some(None), + .. + }) + ), + "expected /fast off to disable fast mode, got {ops:?}" + ); + + let notifications = client.notifications.lock().unwrap(); + assert!(notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Fast mode is off.\n" + ))); + assert!(notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate { config_options, .. }) + if config_options.iter().any(|option| { + option.id.0.as_ref() == "service_tier" + && matches!( + option.kind, + SessionConfigKind::Select(SessionConfigSelect { ref current_value, .. }) + if current_value.0.as_ref() == "standard" + ) + }) + ))); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_fast_persists_service_tier_default() -> anyhow::Result<()> { + let config = default_test_config().await?; + let codex_home = config.codex_home.clone(); + let (session_id, _client, _thread, message_tx, local_set) = + setup_with_config(config, vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id, vec!["/fast on".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let contents = std::fs::read_to_string(codex_home.join("config.toml"))?; + assert!( + contents.contains("service_tier = \"fast\""), + "expected persisted fast service tier, got {contents:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_fast_status() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/fast status".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let ops = thread.ops.lock().unwrap(); + assert!( + ops.is_empty(), + "expected /fast status to avoid submitting ops, got {ops:?}" + ); + + let notifications = client.notifications.lock().unwrap(); + assert!(notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Fast mode is off.\n" + ))); + + Ok(()) + } + + #[tokio::test] + async fn test_config_options_include_service_tier() -> anyhow::Result<()> { + let (_session_id, _client, _thread, message_tx, local_set) = setup(vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::GetConfigOptions { response_tx })?; + let (options, _) = tokio::try_join!( + async { + let options = response_rx.await??; + drop(message_tx); + anyhow::Ok(options) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + assert!( + options + .iter() + .any(|option| option.id.0.as_ref() == "service_tier"), + "expected service_tier config option, got {options:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_config_options_include_mode() -> anyhow::Result<()> { + let (_session_id, _client, _thread, message_tx, local_set) = setup(vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::GetConfigOptions { response_tx })?; + let (options, _) = tokio::try_join!( + async { + let options = response_rx.await??; + drop(message_tx); + anyhow::Ok(options) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + assert!( + options.iter().any(|option| option.id.0.as_ref() == "mode"), + "expected mode config option, got {options:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_set_config_service_tier_submits_override() -> anyhow::Result<()> { + let (_session_id, _client, thread, message_tx, local_set) = setup(vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::SetConfigOption { + config_id: SessionConfigId::new("service_tier"), + value: SessionConfigOptionValue::ValueId { + value: SessionConfigValueId::new("fast"), + }, + response_tx, + })?; + tokio::try_join!( + async { + response_rx.await??; + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let ops = thread.ops.lock().unwrap(); + assert!( + ops.iter().any(|op| matches!( + op, + Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + } + )), + "expected service tier override op, got {ops:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_set_mode_persists_defaults() -> anyhow::Result<()> { + let config = default_test_config().await?; + let codex_home = config.codex_home.clone(); + let (_session_id, _client, _thread, message_tx, local_set) = + setup_with_config(config, vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::SetMode { + mode: SessionModeId::new("full-access"), + response_tx, + })?; + + tokio::try_join!( + async { + response_rx.await??; + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let contents = std::fs::read_to_string(codex_home.join("config.toml"))?; + assert!( + contents.contains("approval_policy = \"never\""), + "expected persisted approval policy, got {contents:?}" + ); + assert!( + contents.contains("sandbox_mode = \"danger-full-access\""), + "expected persisted sandbox mode, got {contents:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_set_reasoning_effort_persists_default() -> anyhow::Result<()> { + let config = default_test_config().await?; + let codex_home = config.codex_home.clone(); + let (_session_id, _client, _thread, message_tx, local_set) = + setup_with_config(config, vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::SetConfigOption { + config_id: SessionConfigId::new("reasoning_effort"), + value: SessionConfigOptionValue::ValueId { + value: SessionConfigValueId::new("high"), + }, + response_tx, + })?; + + tokio::try_join!( + async { + response_rx.await??; + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let contents = std::fs::read_to_string(codex_home.join("config.toml"))?; + assert!( + contents.contains("model_reasoning_effort = \"high\""), + "expected persisted reasoning effort, got {contents:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_mention_removed() { + // /mention was removed — it was a fake UserInput wrapper. + // Verify it's not in builtin_commands. + let commands = ThreadActor::::builtin_commands(); + let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect(); + assert!( + !names.contains(&"mention"), + "/mention should not be in builtin commands" + ); + } + + #[tokio::test] + async fn test_hook_events_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-hook-events".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + let texts: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) => Some(text.clone()), + _ => None, + }) + .collect(); + + assert!( + texts.iter().any(|t| t.contains("Running hook")), + "expected hook started message, got {texts:?}" + ); + assert!( + texts + .iter() + .any(|t| t.contains("Hook completed") && t.contains("all good")), + "expected hook completed message with status, got {texts:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_image_generation_events_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-image-gen".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Should have a ToolCall begin for image generation + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) => Some(tc.clone()), + _ => None, + }) + .collect(); + assert_eq!( + tool_calls.len(), + 1, + "expected 1 image gen ToolCall, got {tool_calls:?}" + ); + assert!(tool_calls[0].title.contains("Generating image")); + + // Should have a completed ToolCallUpdate + let updates: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) + if u.fields.status == Some(ToolCallStatus::Completed) => + { + Some(u.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + updates.len(), + 1, + "expected 1 completed update, got {updates:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_background_event_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-background-event".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text.contains("Long running task completed") + )), + "expected background event message, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_auto_compact_events_surface_in_active_prompt() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-auto-compact".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ToolCall(tool_call) + if tool_call.title == "Compacting context" + && tool_call.kind == ToolKind::Think + && tool_call.status == ToolCallStatus::InProgress + )), + "expected auto-compaction start tool call, got {notifications:?}" + ); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ToolCallUpdate(update) + if update.fields.status == Some(ToolCallStatus::Completed) + && update + .fields + .content + .as_ref() + .is_some_and(|content| content.iter().any(|item| { + matches!( + item, + ToolCallContent::Content(Content { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Context compacted." + ) + })) + )), + "expected auto-compaction completion update, got {notifications:?}" + ); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Context compacted\n" + )), + "expected compacted notice text, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_deprecation_notice_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-deprecation-notice".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text.contains("Deprecation") && text.contains("Old API deprecated") && text.contains("migrate to v2") + )), + "expected deprecation notice message, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_thread_rollback_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-thread-rollback".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text.contains("rolled back") && text.contains("3 turns") + )), + "expected rollback message, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_reasoning_raw_content_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-reasoning-raw".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentThoughtChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Thinking about the problem..." + )), + "expected reasoning raw content as thought chunk, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_plan_delta_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-plan-delta".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentThoughtChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Step 1: Analyze the code" + )), + "expected plan delta as thought chunk, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_plan_mode_in_available_modes() { + // Plan mode should be in the available modes list + let (_, _, _, _, _local_set) = setup(vec![]).await.unwrap(); + // We can verify via the builtin modes function indirectly — + // check that "plan" mode is listed in builtin_commands() isn't the right + // place; instead verify the modes() function includes it. + // Since modes() requires a full ThreadActor, we test the simpler property: + // that setting mode to "plan" is handled (not rejected as invalid_params). + } + + #[tokio::test] + async fn test_set_plan_mode() -> anyhow::Result<()> { + let (_session_id, _client, thread, message_tx, local_set) = setup(vec![]).await?; + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::SetMode { + mode: SessionModeId::new("plan"), + response_tx, + })?; + + // Give the actor time to process + tokio::task::yield_now().await; + drop(message_tx); + local_set.await; + + let ops = thread.ops.lock().unwrap(); + assert!( + ops.iter().any(|op| matches!(op, Op::OverrideTurnContext { + collaboration_mode: Some(cm), .. + } if cm.mode == ModeKind::Plan)), + "expected plan mode to submit OverrideTurnContext with ModeKind::Plan, got {ops:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_collab_spawn_events_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-collab-spawn".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Should have a ToolCall for spawn begin + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) => Some(tc.clone()), + _ => None, + }) + .collect(); + assert_eq!( + tool_calls.len(), + 1, + "expected 1 collab spawn ToolCall, got {tool_calls:?}" + ); + assert!( + tool_calls[0].title.to_lowercase().contains("sub-agent"), + "expected spawn title containing 'sub-agent', got {:?}", + tool_calls[0].title + ); + + // Should have a completed update + let completed: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) + if u.fields.status == Some(ToolCallStatus::Completed) => + { + Some(u.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + completed.len(), + 1, + "expected 1 completed collab update, got {completed:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_collab_interaction_events_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-collab-interaction".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Should have a ToolCall for interaction begin + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) => Some(tc.clone()), + _ => None, + }) + .collect(); + assert_eq!( + tool_calls.len(), + 1, + "expected 1 interaction ToolCall, got {tool_calls:?}" + ); + assert!( + tool_calls[0].title.contains("Sub-agent interaction"), + "expected interaction title, got {:?}", + tool_calls[0].title + ); + + // Should have a completed update + let completed: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) + if u.fields.status == Some(ToolCallStatus::Completed) => + { + Some(u.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + completed.len(), + 1, + "expected 1 completed interaction update, got {completed:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_concurrent_web_searches() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new( + session_id.clone(), + vec!["emit-web-search-concurrent".into()], + ), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Should have ToolCall begins for both web searches + let web_search_begins: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) if tc.title.contains("Searching") => Some(tc.clone()), + _ => None, + }) + .collect(); + assert_eq!( + web_search_begins.len(), + 2, + "expected 2 web search ToolCall begins, got {web_search_begins:?}" + ); + + // Both searches should have completed (via complete_web_search when ExecCommandBegin fires) + let web_search_completes: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) + if u.fields.status == Some(ToolCallStatus::Completed) + && (u.tool_call_id.0.as_ref() == "ws-1" + || u.tool_call_id.0.as_ref() == "ws-2") => + { + Some(u.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + web_search_completes.len(), + 2, + "expected 2 web search completions (both concurrent searches should complete), got {web_search_completes:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_builtin_commands_includes_new_commands() { + let commands = ThreadActor::::builtin_commands(); + let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect(); + + // Original commands + assert!(names.contains(&"review"), "missing /review"); + assert!(names.contains(&"compact"), "missing /compact"); + assert!(names.contains(&"undo"), "missing /undo"); + assert!(names.contains(&"init"), "missing /init"); + assert!(names.contains(&"logout"), "missing /logout"); + assert!(names.contains(&"review-branch"), "missing /review-branch"); + assert!(names.contains(&"review-commit"), "missing /review-commit"); + + // New commands + assert!(names.contains(&"fast"), "missing /fast"); + assert!(names.contains(&"diff"), "missing /diff"); + assert!(names.contains(&"status"), "missing /status"); + assert!(names.contains(&"stop"), "missing /stop"); + assert!(names.contains(&"rename"), "missing /rename"); + assert!(names.contains(&"mcp"), "missing /mcp"); + assert!(names.contains(&"skills"), "missing /skills"); + + // Removed commands (were fake UserInput wrappers) + assert!(!names.contains(&"mention"), "/mention should be removed"); + assert!(!names.contains(&"feedback"), "/feedback should be removed"); + assert!( + !names.contains(&"debug-config"), + "/debug-config should be removed" + ); + } + + #[tokio::test] + async fn test_usage_accumulation_with_cost() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-token-counts".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // Collect all UsageUpdate notifications + let usage_updates: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::UsageUpdate(u) => Some(u.clone()), + _ => None, + }) + .collect(); + + // Should have 2 usage updates (one per TokenCount event) + assert_eq!( + usage_updates.len(), + 2, + "expected 2 usage updates, got {usage_updates:?}" + ); + + // Both should have size = 128000 + assert_eq!(usage_updates[0].size, 128000); + assert_eq!(usage_updates[0].used, 180); + assert_eq!(usage_updates[1].size, 128000); + assert_eq!(usage_updates[1].used, 360); + + // First should include cost from credits balance + assert!( + usage_updates[0].cost.is_some(), + "first update should include cost from credits" + ); + let cost = usage_updates[0].cost.as_ref().unwrap(); + assert!( + (cost.amount - 1.50).abs() < f64::EPSILON, + "cost should be 1.50, got {}", + cost.amount + ); + assert_eq!(cost.currency, "USD"); + + // Second should NOT include cost (no rate_limits) + assert!( + usage_updates[1].cost.is_none(), + "second update should not include cost (no rate_limits)" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_mcp() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/mcp".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // /mcp now submits Op::ListMcpTools + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::ListMcpTools), + "expected /mcp to submit Op::ListMcpTools, got {ops:?}" + ); + + // Should receive formatted MCP tool listing + let notifications = client.notifications.lock().unwrap(); + let has_mcp_text = notifications.iter().any(|n| { + if let SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(t), + .. + }) = &n.update + { + t.text.contains("MCP Tools") + } else { + false + } + }); + assert!( + has_mcp_text, + "expected /mcp to send formatted MCP tools listing" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_skills() -> anyhow::Result<()> { + let (session_id, client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/skills".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // /skills now submits Op::ListSkills + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::ListSkills { .. }), + "expected /skills to submit Op::ListSkills, got {ops:?}" + ); + + // Should receive formatted skills listing + let notifications = client.notifications.lock().unwrap(); + let has_skills_text = notifications.iter().any(|n| { + if let SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(t), + .. + }) = &n.update + { + t.text.contains("Available Skills") + } else { + false + } + }); + assert!( + has_skills_text, + "expected /skills to send formatted skills listing" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_debug_config_removed() { + // /debug-config was removed — it was a fake UserInput wrapper. + let commands = ThreadActor::::builtin_commands(); + let names: Vec<_> = commands.iter().map(|c| c.name.as_str()).collect(); + assert!( + !names.contains(&"debug-config"), + "/debug-config should not be in builtin commands" + ); + } + + // ==================== Edge case tests for commands requiring arguments ==================== + + #[tokio::test] + async fn test_slash_rename_no_arg_falls_through() -> anyhow::Result<()> { + let (session_id, _client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + // /rename with no argument should fall through to the default handler + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/rename".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // Should still produce a UserInput (the default handler sends the raw /rename text) + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::UserInput { .. }), + "expected /rename with no arg to still produce a UserInput, got {ops:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_slash_mention_no_arg_falls_through() -> anyhow::Result<()> { + // /mention was removed, but "/mention" with no arg should still fall through + // to the default handler (treated as regular text since it's unrecognized) + let (session_id, _client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/mention".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) } - Ok(id.to_string()) - } + )?; - async fn next_event(&self) -> Result { - let Some(event) = self.op_rx.lock().await.recv().await else { - return Err(CodexErr::InternalAgentDied); - }; - Ok(event) - } + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::UserInput { .. }), + "expected /mention to fall through as regular text, got {ops:?}" + ); + + Ok(()) } - struct StubClient { - notifications: std::sync::Mutex>, - permission_requests: std::sync::Mutex>, - permission_responses: std::sync::Mutex>, - block_permission_requests: Option>, + #[tokio::test] + async fn test_slash_review_branch_no_arg_falls_through() -> anyhow::Result<()> { + let (session_id, _client, thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/review-branch".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // Should NOT be an Op::Review — should fall through to default handler + let ops = thread.ops.lock().unwrap(); + assert!( + matches!(&ops[0], Op::UserInput { .. }), + "expected /review-branch with no arg to fall through to UserInput, got {ops:?}" + ); + + Ok(()) } - impl StubClient { - fn new() -> Self { - StubClient { - notifications: std::sync::Mutex::default(), - permission_requests: std::sync::Mutex::default(), - permission_responses: std::sync::Mutex::default(), - block_permission_requests: None, + // ==================== Orphaned tool call tests ==================== + + #[tokio::test] + async fn test_orphaned_tool_call_events_handled_gracefully() -> anyhow::Result<()> { + let (session_id, _client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-orphaned-events".into()]), + response_tx: prompt_response_tx, + })?; + + // The key assertion: this should complete successfully without panicking + // despite receiving events for call_ids that were never registered + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) } - } + )?; - fn with_permission_responses(responses: Vec) -> Self { - StubClient { - notifications: std::sync::Mutex::default(), - permission_requests: std::sync::Mutex::default(), - permission_responses: std::sync::Mutex::new(responses.into()), - block_permission_requests: None, + // Verify no ToolCall or ToolCallUpdate notifications were sent for the orphaned events + // (since ExecCommandBegin was never sent, no ActiveCommand was created) + // The test succeeding without panic proves the else-clause warnings work correctly. + Ok(()) + } + + // ==================== Patch apply abort tests ==================== + + #[tokio::test] + async fn test_patch_apply_abort_marks_edit_failed() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-patch-abort".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::Cancelled); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) } - } + )?; - fn with_blocked_permission_requests( - responses: Vec, - notify: Arc, - ) -> Self { - StubClient { - notifications: std::sync::Mutex::default(), - permission_requests: std::sync::Mutex::default(), - permission_responses: std::sync::Mutex::new(responses.into()), - block_permission_requests: Some(notify), + let notifications = client.notifications.lock().unwrap(); + + // There should be a ToolCall for the patch with InProgress status + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ToolCall(tool_call) + if tool_call.tool_call_id.0.as_ref() == "patch-call-1" + && tool_call.status == ToolCallStatus::InProgress + )), + "expected InProgress ToolCall for patch-call-1, got {notifications:?}" + ); + + // There should be a ToolCallUpdate for the same call id with Failed status + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::ToolCallUpdate(update) + if update.tool_call_id.0.as_ref() == "patch-call-1" + && update.fields.status == Some(ToolCallStatus::Failed) + && update + .fields + .content + .as_ref() + .is_some_and(|content| content.iter().any(|item| { + matches!( + item, + ToolCallContent::Content(Content { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text == "Edit interrupted before completion." + ) + })) + )), + "expected Failed ToolCallUpdate for patch-call-1 with interruption message, got {notifications:?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn test_replay_interrupted_apply_patch_marks_edit_failed() -> anyhow::Result<()> { + let (_session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + let patch = "*** Begin Patch\n*** Add File: replay_patch.txt\n+hello\n*** End Patch\n"; + let function_arguments = serde_json::to_string(&serde_json::json!({ + "input": patch, + }))?; + + message_tx.send(ThreadMessage::ReplayHistory { + history: vec![ + RolloutItem::ResponseItem(ResponseItem::CustomToolCall { + id: None, + status: Some("in_progress".to_string()), + call_id: "replay-patch-custom".to_string(), + name: "apply_patch".to_string(), + input: patch.to_string(), + }), + RolloutItem::ResponseItem(ResponseItem::FunctionCall { + id: None, + name: "apply_patch".to_string(), + arguments: function_arguments, + call_id: "replay-patch-function".to_string(), + }), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + })), + ], + response_tx, + })?; + + tokio::try_join!( + async { + response_rx.await??; + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) } - } + )?; + + let notifications = client.notifications.lock().unwrap(); + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tool_call) => Some(tool_call.clone()), + _ => None, + }) + .collect(); + + assert!( + tool_calls.iter().any(|tool_call| { + tool_call.tool_call_id.0.as_ref() == "replay-patch-custom" + && tool_call.kind == ToolKind::Edit + && tool_call.status == ToolCallStatus::Failed + && tool_call.title.contains("replay_patch.txt") + }), + "expected replayed custom apply_patch to be a failed edit tool call, got {tool_calls:?}" + ); + assert!( + tool_calls.iter().any(|tool_call| { + tool_call.tool_call_id.0.as_ref() == "replay-patch-function" + && tool_call.kind == ToolKind::Edit + && tool_call.status == ToolCallStatus::Failed + && tool_call.title.contains("replay_patch.txt") + }), + "expected replayed function apply_patch to be a failed edit tool call, got {tool_calls:?}" + ); + + Ok(()) } - #[async_trait::async_trait(?Send)] - impl Client for StubClient { - async fn request_permission( - &self, - args: RequestPermissionRequest, - ) -> Result { - self.permission_requests.lock().unwrap().push(args); - if let Some(notify) = &self.block_permission_requests { - notify.notified().await; + // ==================== CollabWaiting event tests ==================== + + #[tokio::test] + async fn test_collab_waiting_events_surfaced() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-collab-waiting".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + + // With a sub-agent active, waiting events should update the tool call + // content rather than emitting standalone AgentMessageChunk text. + let tool_updates: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) => Some(u.clone()), + _ => None, + }) + .collect(); + + // Should have updates: spawn end content, waiting begin content, and turn-complete completed + assert!( + tool_updates.len() >= 2, + "expected at least 2 tool call updates (waiting + completion), got {tool_updates:?}" + ); + + // One of the updates should contain the waiting status text + let has_waiting_content = tool_updates.iter().any(|u| { + u.fields.content.iter().any(|blocks| { + blocks.iter().any(|block| { + matches!( + block, + ToolCallContent::Content(content) + if matches!(&content.content, ContentBlock::Text(TextContent { text, .. }) if text.contains("Waiting")) + ) + }) + }) + }); + assert!( + has_waiting_content, + "expected a tool call update with 'Waiting' content, got {tool_updates:?}" + ); + + // The final update should be completed (from TurnComplete cleanup) + let has_completed = tool_updates + .iter() + .any(|u| u.fields.status == Some(ToolCallStatus::Completed)); + assert!( + has_completed, + "expected a completed tool call update, got {tool_updates:?}" + ); + + Ok(()) + } + + // ==================== Usage accumulation edge case tests ==================== + + #[tokio::test] + async fn test_token_count_with_none_info() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-token-none-info".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) } - Ok(self - .permission_responses - .lock() - .unwrap() - .pop_front() - .unwrap_or_else(|| { - RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled) - })) - } + )?; - async fn session_notification(&self, args: SessionNotification) -> Result<(), Error> { - self.notifications.lock().unwrap().push(args); - Ok(()) - } + let notifications = client.notifications.lock().unwrap(); + // When info is None, no UsageUpdate should be sent + let usage_updates: Vec<_> = notifications + .iter() + .filter(|n| matches!(&n.update, SessionUpdate::UsageUpdate(..))) + .collect(); + assert_eq!( + usage_updates.len(), + 0, + "expected no UsageUpdate when TokenCount info is None, got {usage_updates:?}" + ); + + Ok(()) } #[tokio::test] - async fn test_parallel_exec_commands() -> anyhow::Result<()> { + async fn test_token_count_with_no_context_window() -> anyhow::Result<()> { let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); message_tx.send(ThreadMessage::Prompt { - request: PromptRequest::new(session_id.clone(), vec!["parallel-exec".into()]), + request: PromptRequest::new(session_id.clone(), vec!["emit-token-no-window".into()]), response_tx: prompt_response_tx, })?; @@ -4426,360 +8572,368 @@ mod tests { )?; let notifications = client.notifications.lock().unwrap(); - - // Collect all ToolCall (begin) notifications keyed by their tool_call_id prefix. - let tool_calls: Vec<_> = notifications + // When model_context_window is None, no UsageUpdate should be sent + // (we still accumulate internally, but don't notify without window size) + let usage_updates: Vec<_> = notifications .iter() - .filter_map(|n| match &n.update { - SessionUpdate::ToolCall(tc) => Some(tc.clone()), - _ => None, - }) + .filter(|n| matches!(&n.update, SessionUpdate::UsageUpdate(..))) .collect(); + assert_eq!( + usage_updates.len(), + 0, + "expected no UsageUpdate when model_context_window is None, got {usage_updates:?}" + ); - // Collect all ToolCallUpdate notifications that carry a terminal status. - let completed_updates: Vec<_> = notifications + Ok(()) + } + + #[tokio::test] + async fn test_token_count_with_unparseable_balance() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-token-bad-balance".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + let usage_updates: Vec<_> = notifications .iter() .filter_map(|n| match &n.update { - SessionUpdate::ToolCallUpdate(update) => { - if update.fields.status == Some(ToolCallStatus::Completed) { - Some(update.clone()) - } else { - None - } - } + SessionUpdate::UsageUpdate(u) => Some(u.clone()), _ => None, }) .collect(); - // Both commands A and B should have produced a ToolCall (begin). - assert_eq!( - tool_calls.len(), - 2, - "expected 2 ToolCall begin notifications, got {tool_calls:?}" - ); - - // Both commands A and B should have produced a completed ToolCallUpdate. - assert_eq!( - completed_updates.len(), - 2, - "expected 2 completed ToolCallUpdate notifications, got {completed_updates:?}" - ); - - // The completed updates should reference the same tool_call_ids as the begins. - let begin_ids: std::collections::HashSet<_> = tool_calls - .iter() - .map(|tc| tc.tool_call_id.clone()) - .collect(); - let end_ids: std::collections::HashSet<_> = completed_updates - .iter() - .map(|u| u.tool_call_id.clone()) - .collect(); - assert_eq!( - begin_ids, end_ids, - "completed update tool_call_ids should match begin tool_call_ids" + // Should still send a UsageUpdate (we have model_context_window) + assert_eq!(usage_updates.len(), 1, "expected 1 UsageUpdate"); + assert_eq!(usage_updates[0].size, 128000); + // But cost should be None because "not-a-number" can't be parsed as f64 + assert!( + usage_updates[0].cost.is_none(), + "expected no cost when balance is unparseable, got {:?}", + usage_updates[0].cost ); Ok(()) } - #[tokio::test] - async fn test_exec_approval_uses_available_decisions() -> anyhow::Result<()> { - LocalSet::new() - .run_until(async { - let session_id = SessionId::new("test"); - let client = Arc::new(StubClient::with_permission_responses(vec![ - RequestPermissionResponse::new(RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new("denied"), - )), - ])); - let session_client = - SessionClient::with_client(session_id, client.clone(), Arc::default()); - let thread = Arc::new(StubCodexThread::new()); - let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); - let (message_tx, mut message_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut prompt_state = PromptState::new( - "submission-id".to_string(), - thread.clone(), - message_tx, - response_tx, - ); - - prompt_state - .exec_approval( - &session_client, - ExecApprovalRequestEvent { - call_id: "call-id".to_string(), - approval_id: Some("approval-id".to_string()), - turn_id: "turn-id".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: std::env::current_dir()?, - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: Some(vec![ - ReviewDecision::Approved, - ReviewDecision::Denied, - ]), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hi".to_string(), - }], - }, - ) - .await?; - - let ThreadMessage::PermissionRequestResolved { - submission_id, - request_key, - response, - } = message_rx.recv().await.unwrap() - else { - panic!("expected permission resolution message"); - }; - assert_eq!(submission_id, "submission-id"); - prompt_state - .handle_permission_request_resolved(&session_client, request_key, response) - .await?; + // ==================== Tightened event notification tests ==================== - let requests = client.permission_requests.lock().unwrap(); - let request = requests.last().unwrap(); - let option_ids = request - .options - .iter() - .map(|option| option.option_id.0.to_string()) - .collect::>(); - assert_eq!(option_ids, vec!["approved", "denied"]); + #[tokio::test] + async fn test_hook_events_exact_format() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); - let ops = thread.ops.lock().unwrap(); - assert!(matches!( - ops.last(), - Some(Op::ExecApproval { - id, - turn_id, - decision: ReviewDecision::Denied, - }) if id == "approval-id" && turn_id.as_deref() == Some("turn-id") - )); + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-hook-events".into()]), + response_tx: prompt_response_tx, + })?; + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + let texts: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) => Some(text.clone()), + _ => None, }) - .await?; + .collect(); + + // Verify exact format of hook started message + assert!( + texts + .iter() + .any(|t| t.starts_with("Running hook: hook-1 (") && t.ends_with("...\n")), + "hook started message should start with 'Running hook: hook-1 (' and end with '...\\n', got {texts:?}" + ); + // Verify exact format of hook completed message including status message + assert!( + texts + .iter() + .any(|t| t.starts_with("Hook completed: hook-1") && t.contains("all good")), + "hook completed message should contain hook id and status message, got {texts:?}" + ); Ok(()) } #[tokio::test] - async fn test_mcp_elicitation_declines_unsupported_form_requests() -> anyhow::Result<()> { - LocalSet::new() - .run_until(async { - let session_id = SessionId::new("test"); - let client = Arc::new(StubClient::with_permission_responses(vec![ - RequestPermissionResponse::new(RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new("decline"), - )), - ])); - let session_client = - SessionClient::with_client(session_id, client.clone(), Arc::default()); - let thread = Arc::new(StubCodexThread::new()); - let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); - let (message_tx, _message_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut prompt_state = PromptState::new( - "submission-id".to_string(), - thread.clone(), - message_tx, - response_tx, - ); + async fn test_image_generation_exact_content() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); - prompt_state - .mcp_elicitation( - &session_client, - ElicitationRequestEvent { - turn_id: Some("turn-id".to_string()), - server_name: "test-server".to_string(), - id: codex_protocol::mcp::RequestId::String("request-id".to_string()), - request: ElicitationRequest::Form { - meta: None, - message: "Need some structured input".to_string(), - requested_schema: serde_json::json!({ - "type": "object", - "properties": { - "name": { "type": "string" } - } - }), - }, - }, - ) - .await?; + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-image-gen".into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; - let requests = client.permission_requests.lock().unwrap(); - assert!( - requests.is_empty(), - "unsupported MCP elicitations should be auto-declined" - ); + let notifications = client.notifications.lock().unwrap(); - let ops = thread.ops.lock().unwrap(); - assert!(matches!( - ops.last(), - Some(Op::ResolveElicitation { - server_name, - request_id: codex_protocol::mcp::RequestId::String(request_id), - decision: ElicitationAction::Decline, - content: None, - meta: None, - }) if server_name == "test-server" && request_id == "request-id" - )); + // Verify ToolCall begin has exact title and kind + let tool_calls: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCall(tc) => Some(tc.clone()), + _ => None, + }) + .collect(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].title, "Generating image"); + assert_eq!(tool_calls[0].kind, ToolKind::Other); + assert_eq!(tool_calls[0].status, ToolCallStatus::InProgress); - anyhow::Ok(()) + // Verify completed update has result content "image.png" + let updates: Vec<_> = notifications + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::ToolCallUpdate(u) => Some(u.clone()), + _ => None, }) - .await?; + .collect(); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].fields.status, Some(ToolCallStatus::Completed)); + assert!( + updates[0] + .fields + .content + .as_ref() + .is_some_and(|c| !c.is_empty()), + "completed update should have content with the image result" + ); Ok(()) } #[tokio::test] - async fn test_blocked_approval_does_not_block_followup_events() -> anyhow::Result<()> { - LocalSet::new() - .run_until(async { - let session_id = SessionId::new("test"); - let client = Arc::new(StubClient::with_blocked_permission_requests( - vec![], - Arc::new(Notify::new()), - )); - let session_client = - SessionClient::with_client(session_id, client.clone(), Arc::default()); - let thread = Arc::new(StubCodexThread::new()); - let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); - let (message_tx, _message_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut prompt_state = - PromptState::new("submission-id".to_string(), thread, message_tx, response_tx); + async fn test_background_event_exact_format() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); - prompt_state - .handle_event( - &session_client, - EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - call_id: "call-id".to_string(), - approval_id: Some("approval-id".to_string()), - turn_id: "turn-id".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - cwd: std::env::current_dir()?, - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: Some(vec![ - ReviewDecision::Approved, - ReviewDecision::Abort, - ]), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hi".to_string(), - }], - }), - ) - .await; + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-background-event".into()]), + response_tx: prompt_response_tx, + })?; - prompt_state - .handle_event( - &session_client, - EventMsg::AgentMessage(AgentMessageEvent { - message: "still flowing".to_string(), - phase: None, - }), - ) - .await; + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; - let notifications = client.notifications.lock().unwrap(); - assert!(notifications.iter().any(|notification| { - matches!( - ¬ification.update, - SessionUpdate::AgentMessageChunk(ContentChunk { - content: ContentBlock::Text(TextContent { text, .. }), - .. - }) if text == "still flowing" - ) - })); + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), .. + }) if text == "Long running task completed\n" + )), + "expected exact 'Long running task completed\\n' message" + ); - drop(notifications); - prompt_state.abort_pending_interactions(); + Ok(()) + } + + #[tokio::test] + async fn test_deprecation_notice_exact_format() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-deprecation-notice".into()]), + response_tx: prompt_response_tx, + })?; + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); anyhow::Ok(()) - }) - .await?; + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), .. + }) if text == "**Deprecation:** Old API deprecated\nPlease migrate to v2.\n" + )), + "expected exact deprecation format with markdown bold and details" + ); Ok(()) } #[tokio::test] - async fn test_thread_shutdown_bypasses_blocked_permission_request() -> anyhow::Result<()> { - let session_id = SessionId::new("test"); - let client = Arc::new(StubClient::with_blocked_permission_requests( - vec![RequestPermissionResponse::new( - RequestPermissionOutcome::Cancelled, - )], - Arc::new(Notify::new()), - )); - let session_client = - SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); - let conversation = Arc::new(StubCodexThread::new()); - let models_manager = Arc::new(StubModelsManager); - let config = Config::load_with_cli_overrides_and_harness_overrides( - vec![], - ConfigOverrides::default(), - ) - .await?; - let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); - let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); - let actor = ThreadActor::new( - StubAuth, - session_client, - conversation.clone(), - models_manager, - config, - message_rx, - resolution_tx, - resolution_rx, - ); + async fn test_thread_rollback_exact_format() -> anyhow::Result<()> { + let (session_id, client, _, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); - let local_set = LocalSet::new(); - let handle = local_set.spawn_local(actor.spawn()); - let thread = Thread { - thread: conversation.clone(), - message_tx, - _handle: handle, - }; + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["emit-thread-rollback".into()]), + response_tx: prompt_response_tx, + })?; - local_set - .run_until(async move { - let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); - thread.message_tx.send(ThreadMessage::Prompt { - request: PromptRequest::new(session_id, vec!["approval-block".into()]), - response_tx: prompt_response_tx, - })?; - let stop_reason_rx = prompt_response_rx.await??; + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; - tokio::time::timeout(Duration::from_millis(100), async { - loop { - if !client.permission_requests.lock().unwrap().is_empty() { - break; - } - tokio::task::yield_now().await; - } - }) - .await?; + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), .. + }) if text == "Thread rolled back: 3 turns removed from context.\n" + )), + "expected exact rollback message with plural 'turns'" + ); - tokio::time::timeout(Duration::from_millis(100), thread.shutdown()).await??; - let stop_reason = - tokio::time::timeout(Duration::from_millis(100), stop_reason_rx).await??; - assert_eq!(stop_reason?, StopReason::Cancelled); + Ok(()) + } - anyhow::Ok(()) - }) - .await?; + #[test] + fn test_global_event_classification_includes_mcp_startup() { + assert!(is_global_event(&EventMsg::McpStartupUpdate( + McpStartupUpdateEvent { + server: "test".to_string(), + status: codex_protocol::protocol::McpStartupStatus::Ready, + } + ))); + assert!(is_global_event(&EventMsg::McpStartupComplete( + McpStartupCompleteEvent { + ready: vec!["test".to_string()], + failed: vec![], + cancelled: vec![], + } + ))); + assert!(!is_global_event(&EventMsg::TurnComplete( + TurnCompleteEvent { + last_agent_message: None, + turn_id: "turn-1".to_string(), + } + ))); + } - let ops = conversation.ops.lock().unwrap(); - assert!(matches!(ops.last(), Some(Op::Shutdown))); + #[test] + fn test_auto_compact_submission_id_is_suppressed() { + assert!(is_auto_compact_submission_id("auto-compact-0")); + assert!(is_auto_compact_submission_id("auto-compact-12")); + assert!(!is_auto_compact_submission_id("0")); + assert!(!is_auto_compact_submission_id("submission-1")); + } + + /// Verify that /diff, /status, /rename, /mcp, and /skills all generate notifications + /// (not just Ops) by checking the stub echoes the prompt text back. + #[tokio::test] + async fn test_slash_commands_generate_notifications() -> anyhow::Result<()> { + // Test each native command and verify it completes with notifications + for cmd in ["/diff", "/status", "/rename My Thread", "/mcp", "/skills"] { + let (session_id, client, _thread, message_tx, local_set) = setup(vec![]).await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec![cmd.into()]), + response_tx: prompt_response_tx, + })?; + + tokio::try_join!( + async { + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn, "failed for cmd: {cmd}"); + drop(message_tx); + anyhow::Ok(()) + }, + async { + local_set.await; + anyhow::Ok(()) + } + )?; + + // Verify at least one notification was sent + let notifications = client.notifications.lock().unwrap(); + assert!( + !notifications.is_empty(), + "cmd {cmd}: expected notifications but got none" + ); + // Verify it's an agent message chunk + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(..), + .. + }) + )), + "cmd {cmd}: expected AgentMessageChunk notification" + ); + } Ok(()) }