Files
codex/codex-rs/tui/src/temporary_structured_request_tests.rs
T
Eric Traut 54b90f98a5 Make TUI thread titles resilient to generation failures (#60842)
## Why

Threads need a visible title while automatic generation is pending or fails. Hidden title-generation requests must also stay out of the foreground conversation, including during skill installation prompts.

## What changed

- Immediately display a Markdown-stripped, single-line excerpt of the first message, capped at 60 characters. Replace it with the generated title, or save the excerpt if generation fails or returns an invalid title.
- Give title threads explicit read-only permissions and request the `never` approval policy, subject to managed policy constraints. Keep blocking input requests on the hidden thread until timeout.
- Interrupt timed-out temporary structured turns before detaching them.

## Testing

Add coverage for provisional title replacement, fallback persistence, managed permission and approval policies, and hidden input requests. Add a terminal integration test that verifies title generation after choosing “Continue anyway” on a skill installation prompt.

GitOrigin-RevId: f408f3061e5e7f0d9d6bc1f2f9674303e9e68400
2026-11-11 18:59:32 +00:00

345 lines
12 KiB
Rust

use super::STRUCTURED_RESPONSE_MAX_BYTES;
use super::TemporaryStructuredThreadOptions;
use super::TemporaryThreadKind;
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 (kind, read_only_allowed, policies, expected_approval) in [
(
TemporaryThreadKind::Recap,
true,
r#""on-request""#,
AskForApproval::OnRequest,
),
(
TemporaryThreadKind::Recap,
false,
r#""on-request""#,
AskForApproval::OnRequest,
),
(
TemporaryThreadKind::Title,
true,
r#""on-request", "never""#,
AskForApproval::Never,
),
(
TemporaryThreadKind::Title,
true,
r#""on-request""#,
AskForApproval::OnRequest,
),
] {
let codex_home = tempdir()?;
let requirements_path = codex_home.path().join("requirements.toml");
std::fs::write(
&requirements_path,
format!(
"allowed_approval_policies = [{policies}]\n\
default_permissions = \":workspace\"\n\n\
[features]\nskill_mcp_dependency_install = true\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 {
kind,
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)
if kind == TemporaryThreadKind::Recap =>
{
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()),
expected_approval,
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 {
kind: TemporaryThreadKind::Recap,
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("system".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"
);
}