Files
codex/codex-rs/tui/src/temporary_structured_request_tests.rs
T
Eric Traut 2df465732a Use app-server MCP configuration and startup events in the TUI (#60622)
## Why

Locally configured MCP servers may differ from those reported by the app server. Waiting for unreported servers can leave startup progress busy after the observed servers have finished.

## What changed

- Track startup progress from received server status updates, settling when no observed starts remain pending. Handle standalone outcomes after attachment or lag, deduplicate repeated diagnostics, and reactivate progress on new starts.
- Read effective MCP configuration through `config/read` to prevent task tools from shadowing the `codex_tui` namespace, including when configuration changes after startup.
- Use the same configuration reader to disable MCP servers for temporary structured threads.
- Remove client-side MCP requirement checks for the task-tools server and MCP requirements from debug configuration output, leaving enforcement to the app server.

## Testing

Update regression coverage for unreported local servers, startup rounds after lag, diagnostic handling, namespace conflicts, and server-enforced managed requirements.

GitOrigin-RevId: 370767b90ee55f8b0b1403872ef437d53257586b
2026-11-10 16:44:23 +00:00

316 lines
11 KiB
Rust

use super::STRUCTURED_RESPONSE_MAX_BYTES;
use super::TemporaryStructuredThreadOptions;
use super::collect_structured_response;
use super::start_temporary_thread;
use crate::legacy_core::config::ConfigBuilder;
use crate::test_support::PathBufExt;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_config::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::mpsc::unbounded_channel;
fn agent_message_notification(turn_id: &str, text: &str) -> ServerNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
"thread-1".to_string(),
turn_id.to_string(),
ThreadItemPayload::AgentMessage {
moderation: None,
id: "message-1".to_string(),
text: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
))
}
fn turn_completed_notification(turn_id: &str, status: TurnStatus) -> ServerNotification {
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: turn_id.to_string(),
root_turn_id: None,
items: Vec::new(),
items_view: Default::default(),
status,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
})
}
#[tokio::test]
async fn managed_workspace_default_respects_read_only_availability() -> color_eyre::Result<()> {
for read_only_allowed in [true, false] {
let codex_home = tempdir()?;
let requirements_path = codex_home.path().join("requirements.toml");
std::fs::write(
&requirements_path,
format!(
"allowed_approval_policies = [\"on-request\"]\n\
default_permissions = \":workspace\"\n\n\
[allowed_permission_profiles]\n\
\":read-only\" = {read_only_allowed}\n\
\":workspace\" = true\n"
),
)?;
let loader_overrides = LoaderOverrides {
system_requirements_path: Some(requirements_path),
ignore_project_config: true,
..LoaderOverrides::without_managed_config_for_tests()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let options = TemporaryStructuredThreadOptions {
thread_source: ThreadSource::Feature("thread_title".to_string()),
model: "gpt-5.2".to_string(),
model_provider: config.model_provider_id.clone(),
cwd: config.cwd.to_string_lossy().into_owned(),
active_permission_profile: config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
};
let client = crate::start_embedded_app_server(
Default::default(),
config,
Vec::new(),
loader_overrides,
/*strict_config*/ false,
Default::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
/*state_db*/ None,
Arc::new(EnvironmentManager::default_for_tests()),
Default::default(),
)
.await?;
let mut app_server = AppServerClient::InProcess(client);
let result = start_temporary_thread(&app_server.request_handle(), options).await;
// Thread/start warnings precede thread/started on the connection.
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), async {
loop {
let AppServerEvent::ServerNotification(notification) =
app_server.next_event().await.expect("thread startup event")
else {
continue;
};
match *notification {
ServerNotification::ConfigWarning(warning) => {
assert!(
!warning.summary.contains("`approval_policy`"),
"{warning:?}"
);
}
ServerNotification::ThreadStarted(_) => break,
_ => {}
}
}
})
.await?;
app_server.shutdown().await?;
if read_only_allowed {
let response = result?;
assert_eq!(
(
response.active_permission_profile.map(|profile| profile.id),
response.approval_policy,
response.sandbox,
),
(
Some(":read-only".to_string()),
AskForApproval::OnRequest,
SandboxPolicy::ReadOnly {
network_access: false,
},
),
);
} else {
assert_eq!(
result.expect_err("read-only must be allowed").to_string(),
"temporary structured thread did not start with read-only permissions",
);
}
}
Ok(())
}
#[tokio::test]
async fn preserves_custom_permissions_and_disables_required_mcp_servers() -> color_eyre::Result<()>
{
let (chat_widget, _, _, _) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
let mut config = chat_widget.config_ref().clone();
let codex_home = tempdir()?;
let denied_path = codex_home.path().join("denied");
let denied_key = toml::Value::String(denied_path.display().to_string());
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"default_permissions = \"title-restricted\"\n\n\
[permissions.title-restricted.filesystem]\n\
\":root\" = \"read\"\n\
{denied_key} = \"deny\"\n\n\
[mcp_servers.forbidden]\n\
command = \"codex-auto-title-missing-mcp\"\n\
required = true\n"
),
)?;
config.codex_home = codex_home.path().to_path_buf().abs();
config.sqlite =
codex_state::SqliteConfig::new_for_testing(codex_home.path().to_path_buf().abs());
let app_server = crate::start_embedded_app_server_for_picker(&config).await?;
let response = start_temporary_thread(
&app_server.request_handle(),
TemporaryStructuredThreadOptions {
thread_source: ThreadSource::Feature("thread_title".to_string()),
model: "gpt-5.2".to_string(),
model_provider: config.model_provider_id.clone(),
cwd: config.cwd.display().to_string(),
active_permission_profile: Some("title-restricted".to_string()),
},
)
.await?;
assert_eq!(
(
response.active_permission_profile.map(|profile| profile.id),
response.model_provider,
response.thread.ephemeral,
response.thread.thread_source,
),
(
Some("title-restricted".to_string()),
config.model_provider_id,
true,
Some(ThreadSource::Feature("thread_title".to_string())),
)
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn returns_latest_matching_assistant_message() {
let (sender, receiver) = unbounded_channel();
sender
.send(agent_message_notification("other-turn", "ignore me"))
.expect("send unrelated assistant message");
sender
.send(turn_completed_notification(
"other-turn",
TurnStatus::Completed,
))
.expect("send unrelated completion");
sender
.send(agent_message_notification("turn-1", "first"))
.expect("send first assistant message");
sender
.send(agent_message_notification("turn-1", "final"))
.expect("send final assistant message");
sender
.send(turn_completed_notification("turn-1", TurnStatus::Completed))
.expect("send matching completion");
let response = collect_structured_response(receiver, "turn-1")
.await
.expect("collect structured response");
assert_eq!(response, "final");
}
#[tokio::test]
async fn rejects_failed_turn() {
let (sender, receiver) = unbounded_channel();
sender
.send(turn_completed_notification("turn-1", TurnStatus::Failed))
.expect("send failed completion");
let error = collect_structured_response(receiver, "turn-1")
.await
.expect_err("failed turn should not produce a response");
assert_eq!(
error.to_string(),
"temporary structured turn ended with status Failed"
);
}
#[tokio::test]
async fn rejects_completion_without_assistant_message() {
let (sender, receiver) = unbounded_channel();
sender
.send(turn_completed_notification("turn-1", TurnStatus::Completed))
.expect("send completion");
let error = collect_structured_response(receiver, "turn-1")
.await
.expect_err("completion without assistant message should fail");
assert_eq!(
error.to_string(),
"temporary structured turn completed without a response"
);
}
#[tokio::test]
async fn rejects_closed_notification_channel() {
let (sender, receiver) = unbounded_channel();
drop(sender);
let error = collect_structured_response(receiver, "turn-1")
.await
.expect_err("closed notification channel should fail");
assert_eq!(
error.to_string(),
"temporary structured turn notification channel closed"
);
}
#[tokio::test]
async fn rejects_oversized_assistant_message() {
let (sender, receiver) = unbounded_channel();
let oversized = "x".repeat(STRUCTURED_RESPONSE_MAX_BYTES + 1);
sender
.send(agent_message_notification("turn-1", &oversized))
.expect("send oversized assistant message");
sender
.send(turn_completed_notification("turn-1", TurnStatus::Completed))
.expect("send matching completion");
let error = collect_structured_response(receiver, "turn-1")
.await
.expect_err("oversized assistant message should be rejected");
assert_eq!(
error.to_string(),
"temporary structured response exceeds 8192 bytes"
);
}