mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## What changed - Give unnamed TUI threads an immediate provisional title from the first user message, then replace it asynchronously with a normalized generated title. - Preserve manual renames by applying a generated title only while the provisional title is still current, and keep automatic rename notifications out of the transcript. - Run title generation in a bounded ephemeral structured request that preserves custom permission profiles, otherwise uses read-only access, and disables tools and MCP servers. ## Testing - Cover prompt and response bounds, Unicode-safe truncation, title normalization, manual-rename races, silent automatic updates, permission preservation, and structured-response failures. GitOrigin-RevId: 7ed73522f4f6c65a005f65f3a903974d8419e312
200 lines
6.4 KiB
Rust
200 lines
6.4 KiB
Rust
use super::STRUCTURED_RESPONSE_MAX_BYTES;
|
|
use super::TemporaryStructuredThreadOptions;
|
|
use super::collect_structured_response;
|
|
use super::start_temporary_thread;
|
|
use crate::test_support::PathBufExt;
|
|
use codex_app_server_protocol::ItemCompletedNotification;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::tempdir;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
|
|
fn agent_message_notification(turn_id: &str, text: &str) -> ServerNotification {
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
|
item: ThreadItem::AgentMessage {
|
|
id: "message-1".to_string(),
|
|
text: text.to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
},
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
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(),
|
|
items: Vec::new(),
|
|
items_view: Default::default(),
|
|
status,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[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 {
|
|
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()),
|
|
mcp_server_names: vec!["forbidden".to_string()],
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
(
|
|
response.active_permission_profile.map(|profile| profile.id),
|
|
response.model_provider,
|
|
response.thread.ephemeral,
|
|
),
|
|
(
|
|
Some("title-restricted".to_string()),
|
|
config.model_provider_id,
|
|
true,
|
|
)
|
|
);
|
|
|
|
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"
|
|
);
|
|
}
|