Add TUI tools for managing Codex tasks (#40308)

## Why

The TUI rejected app-server dynamic tool calls as unsupported, so agents could
not inspect or manage other Codex tasks through a TUI session.

## What changed

- Add the `codex_tui` tool namespace for listing, reading, waiting on, creating,
  forking, messaging, renaming, archiving, and restoring tasks.
- Handle tool calls through existing app-server thread operations, with bounded
  inputs and responses and cleanup when calls complete or the TUI disconnects.
- Route delegation tools through an authenticated local MCP server with explicit
  approval prompts, while respecting configured and managed MCP policies.
- Fall back to starting threads without dynamic tools when connected to an older
  app server that does not support them.

## Testing

Add coverage for tool schemas and response bounds, task lifecycle operations,
approval-gated MCP transport, namespace dispatch, cancellation, and legacy
app-server fallback behavior.

GitOrigin-RevId: c782c11f0a3edceb3fa6909a4b92d6ce01a6eed3
This commit is contained in:
Eric Traut authored and copyberry committed 2026-08-24 01:50:53 +00:00
1 parent fb0781b9ee
commit a8468330bb
18 files changed
+3722 -62

No files matched your search

+120 -27
View File
@@ -12,7 +12,10 @@ pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
pub(crate) use history::HistoryHydrationScope;
pub(crate) use history::thread_items_page_params;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::FeedbackAudience;
use crate::dynamic_tools_mcp::DynamicToolMcpServer;
use crate::dynamic_tools_mcp::ThreadToolTransport;
use crate::legacy_core::config::Config;
use crate::service_tier_resolution;
use crate::session_state::MessageHistoryMetadata;
@@ -103,6 +106,7 @@ use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
@@ -137,6 +141,7 @@ use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -162,7 +167,7 @@ enum ForkPresentation {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ThreadHistorySupport {
pub(crate) enum ThreadHistorySupport {
Paginated,
LegacyOnly,
}
@@ -200,35 +205,50 @@ pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> b
.any(|error| message.contains(error)))
}
async fn request_thread_start_with_history_fallback(
pub(crate) async fn request_thread_start_with_history_fallback(
request_handle: &AppServerRequestHandle,
request_id: RequestId,
mut request_id: RequestId,
mut params: ThreadStartParams,
) -> std::result::Result<(ThreadStartResponse, ThreadHistorySupport), TypedRequestError> {
match request_handle
.request_typed(ClientRequest::ThreadStart {
request_id,
params: params.clone(),
})
.await
{
Ok(response) => Ok((response, ThreadHistorySupport::Paginated)),
Err(TypedRequestError::Server { source, .. })
if params.history_mode.is_some() && is_history_pagination_unsupported(&source) =>
let mut history_support = ThreadHistorySupport::Paginated;
loop {
match request_handle
.request_typed(ClientRequest::ThreadStart {
request_id,
params: params.clone(),
})
.await
{
params.history_mode = None;
let response = request_handle
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::String(format!(
"legacy-thread-start-{}",
Uuid::new_v4()
)),
params,
})
.await?;
Ok((response, ThreadHistorySupport::LegacyOnly))
Ok(response) => return Ok((response, history_support)),
Err(TypedRequestError::Server { source, .. })
if params.history_mode.is_some() && is_history_pagination_unsupported(&source) =>
{
params.history_mode = None;
history_support = ThreadHistorySupport::LegacyOnly;
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
}
Err(TypedRequestError::Server { source, .. })
if params.dynamic_tools.is_some()
&& matches!(
source.code,
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
)
&& {
let message = source.message.to_ascii_lowercase();
["dynamictools", "dynamic tool", "namespace", "inputschema"]
.into_iter()
.any(|field| message.contains(field))
} =>
{
tracing::warn!(
error = %source.message,
"app server does not support TUI dynamic tools; starting without them"
);
params.dynamic_tools = None;
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
}
Err(err) => return Err(err),
}
Err(err) => Err(err),
}
}
@@ -273,6 +293,7 @@ pub(crate) struct AppServerSession {
available_models: Vec<ModelPreset>,
managed_new_thread_defaults: Option<NewThreadModelDefaults>,
external_agent_config_import_completion_pending: AtomicBool,
dynamic_tool_mcp: Option<Arc<DynamicToolMcpServer>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -356,6 +377,72 @@ impl AppServerSession {
available_models: Vec::new(),
managed_new_thread_defaults: None,
external_agent_config_import_completion_pending: AtomicBool::new(false),
dynamic_tool_mcp: None,
}
}
pub(crate) async fn start_dynamic_tool_mcp(
&mut self,
config: Config,
app_event_tx: AppEventSender,
status_updates: tokio::sync::broadcast::Sender<ThreadStatusChangedNotification>,
) -> std::io::Result<()> {
if self.uses_embedded_app_server() {
return Ok(());
}
if config
.mcp_servers
.get()
.contains_key(crate::dynamic_tools::NAMESPACE)
{
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"a user-configured MCP server already owns the codex_tui namespace",
));
}
let managed_requirement = config
.config_layer_stack
.requirements()
.mcp_servers
.as_ref()
.map(|requirements| {
requirements
.value
.get(crate::dynamic_tools::NAMESPACE)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"managed MCP requirements do not permit the TUI task-tools server",
)
})
})
.transpose()?;
let thread_start_params = thread_start_params_from_config(
&config,
self.thread_params_mode(),
self.remote_cwd_override(),
/*session_start_source*/ None,
);
self.dynamic_tool_mcp = Some(Arc::new(
DynamicToolMcpServer::start(
self.request_handle(),
thread_start_params,
app_event_tx,
status_updates,
managed_requirement,
)
.await?,
));
Ok(())
}
pub(crate) fn thread_tool_transport(&self) -> ThreadToolTransport {
if self.uses_embedded_app_server() {
ThreadToolTransport::Disabled
} else if let Some(server) = self.dynamic_tool_mcp.as_ref() {
ThreadToolTransport::Mcp(Arc::clone(server))
} else {
ThreadToolTransport::Dynamic
}
}
@@ -631,6 +718,7 @@ impl AppServerSession {
if self.history_support == ThreadHistorySupport::LegacyOnly {
params.history_mode = None;
}
self.thread_tool_transport().configure(&mut params);
let request_handle = self.request_handle();
let (response, history_support) =
request_thread_start_with_history_fallback(&request_handle, request_id, params)
@@ -729,6 +817,8 @@ impl AppServerSession {
self.remote_cwd_override.as_deref(),
)
};
self.thread_tool_transport()
.configure_mcp(&mut params.config);
let response: ThreadForkResponse = match self
.client
.request_typed(ClientRequest::ThreadFork {
@@ -1429,14 +1519,16 @@ pub(crate) async fn start_thread_with_request_handle(
config: Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<PathBuf>,
thread_tool_transport: ThreadToolTransport,
) -> Result<AppServerStartedThread> {
let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4()));
let params = thread_start_params_from_config(
let mut params = thread_start_params_from_config(
&config,
thread_params_mode,
remote_cwd_override.as_deref(),
/*session_start_source*/ None,
);
thread_tool_transport.configure(&mut params);
let (response, _history_support) =
request_thread_start_with_history_fallback(&request_handle, request_id, params)
.await
@@ -1686,7 +1778,7 @@ fn permissions_selection_from_config(
.map(permission_profile_id_from_active_profile)
}
fn thread_start_params_from_config(
pub(crate) fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
@@ -2516,6 +2608,7 @@ mod tests {
);
assert_eq!(params.model_provider, Some(config.model_provider_id));
assert_eq!(params.thread_source, Some(ThreadSource::User));
assert_eq!(params.dynamic_tools, None);
}
#[tokio::test]