Files
codex/codex-rs/tui/src/app/thread_title.rs
T
felixxia-oai f5b7ca7249 Preserve request-level reasoning effort for memory and title workers (#46531)
## Why

Memory consolidation and ephemeral title generation need to use their selected request-level reasoning effort even when managed settings enable reasoning-effort overrides.

## What changed

- Exempt current and legacy memory consolidation sessions and ephemeral `thread_title` threads from reasoning-effort overrides.
- Tag temporary title threads with `thread_title` and keep them excluded from agents overview refreshes. Recap threads retain the `system` tag.
- Preserve override behavior for persisted `thread_title` threads and other ephemeral threads.

## Testing

Extend regression coverage to verify request effort, omission of effort updates from exempt workers' requests, preservation of inherited history, title thread tagging, and overview filtering.

GitOrigin-RevId: 8dbc2eb13509f9c80f90d5798a56ea33fad2a0bc
2026-09-19 00:19:20 +00:00

442 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Structured-output schema and normalization for generated TUI thread titles.
//!
//! Automatic titles are persisted only after generation, including for inactive threads;
//! the originating thread's saved name takes precedence over an automatic result. Manual
//! renames cancel pending requests; canceled events cannot affect newer requests.
use super::App;
use super::thread_events::ThreadBufferedEvent;
use crate::app_event::AppEvent;
use crate::app_event::ThreadTitleDestination;
use crate::app_server_session::AppServerSession;
use crate::temporary_structured_request::TemporaryStructuredThreadOptions;
use crate::temporary_structured_request::run_temporary_structured_turn;
use crate::temporary_structured_request::start_temporary_thread;
use crate::temporary_structured_request::unsubscribe_temporary_thread;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
use codex_protocol::models::MessagePhase;
use codex_protocol::openai_models::ReasoningEffort;
use serde::Deserialize;
use serde_json::Value;
use serde_json::json;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub(super) const THREAD_TITLE_MAX_CHARS: usize = 36;
const THREAD_TITLE_MODEL: &str = "gpt-5.6-luna";
pub(super) const THREAD_TITLE_PROMPT_MAX_BYTES: usize = 960;
const THREAD_TITLE_RECENT_MESSAGES: usize = 8;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct GeneratedThreadTitle {
title: String,
}
impl App {
pub(super) fn sync_thread_title_progress(&mut self) {
let pending = self.chat_widget.thread_id().is_some_and(|thread_id| {
self.pending_thread_titles
.keys()
.any(|(id, _)| *id == thread_id)
});
self.chat_widget
.set_thread_title_generation_pending(pending);
}
pub(super) fn finish_thread_title_generation(
&mut self,
thread_id: ThreadId,
destination: ThreadTitleDestination,
) {
self.pending_thread_titles.remove(&(thread_id, destination));
self.sync_thread_title_progress();
}
/// A saved manual name supersedes any pending title generation for that thread.
pub(super) fn cancel_thread_title_generation(&mut self, thread_id: ThreadId) {
self.pending_thread_titles.retain(|(id, _), cancellation| {
if *id == thread_id {
cancellation.cancel();
false
} else {
true
}
});
self.sync_thread_title_progress();
}
/// Start a hidden title-generation thread without blocking the UI loop.
pub(super) fn generate_thread_title(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
destination: ThreadTitleDestination,
prompt: String,
) {
let std::collections::hash_map::Entry::Vacant(entry) =
self.pending_thread_titles.entry((thread_id, destination))
else {
return;
};
let cancellation = entry.insert(CancellationToken::new()).clone();
self.sync_thread_title_progress();
let request_handle = app_server.request_handle();
let model = if self.chat_widget.config_ref().model_provider_id == "openai"
&& self.chat_widget.has_chatgpt_account()
&& self
.chat_widget
.model_catalog()
.try_list_models()
.is_ok_and(|models| models.iter().any(|model| model.model == THREAD_TITLE_MODEL))
{
THREAD_TITLE_MODEL.to_string()
} else {
self.chat_widget.current_model().to_string()
};
let effort = (model == THREAD_TITLE_MODEL).then_some(ReasoningEffort::Low);
let config = self.chat_widget.config_ref();
let options = TemporaryStructuredThreadOptions {
thread_source: ThreadSource::Feature("thread_title".to_string()),
model,
model_provider: config.model_provider_id.clone(),
cwd: config.cwd.display().to_string(),
active_permission_profile: config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
mcp_server_names: config.mcp_servers.get().keys().cloned().collect(),
};
let event_sender = self.app_event_tx.clone();
tokio::spawn(async move {
let result = start_temporary_thread(&request_handle, options)
.await
.map(|thread| thread.thread.id)
.map_err(|error| error.to_string());
event_sender.send(AppEvent::ThreadTitleStarted {
cancellation,
thread_id,
destination,
prompt,
effort,
result,
});
});
}
/// Register a started hidden thread and generate its structured title.
#[allow(clippy::too_many_arguments)]
pub(super) fn on_thread_title_started(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
destination: ThreadTitleDestination,
prompt: String,
effort: Option<ReasoningEffort>,
result: Result<String, String>,
cancellation: CancellationToken,
) {
if cancellation.is_cancelled() {
if let Ok(temporary_thread_id) = result {
let request_handle = app_server.request_handle();
tokio::spawn(async move {
unsubscribe_temporary_thread(&request_handle, temporary_thread_id).await;
});
}
return;
}
let temporary_thread_id_text = match result {
Ok(thread_id) => thread_id,
Err(error) => {
tracing::debug!(%error, "failed to start title-generation thread");
self.finish_thread_title_generation(thread_id, destination);
if let ThreadTitleDestination::RenameSuggestion { request_id } = destination {
self.chat_widget.apply_thread_name_suggestion(
thread_id, request_id, /*suggestion*/ None,
);
}
return;
}
};
let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else {
self.finish_thread_title_generation(thread_id, destination);
if let ThreadTitleDestination::RenameSuggestion { request_id } = destination {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
}
return;
};
let (sender, receiver) = mpsc::unbounded_channel();
self.temporary_structured_requests
.insert(temporary_thread_id, sender);
let request_handle = app_server.request_handle();
let event_sender = self.app_event_tx.clone();
tokio::spawn(async move {
let result = run_temporary_structured_turn(
request_handle,
temporary_thread_id_text,
prompt,
thread_title_output_schema(),
effort,
receiver,
cancellation.clone(),
)
.await
.map_err(|error| error.to_string());
event_sender.send(AppEvent::GeneratedThreadTitle {
cancellation,
thread_id,
temporary_thread_id,
destination,
result,
});
});
}
/// Suggest a title from stored and live conversation items without persisting it.
pub(super) async fn suggest_thread_name(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
request_id: uuid::Uuid,
) {
if self.chat_widget.thread_id() != Some(thread_id) {
return;
}
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
return;
};
let conversation = {
let store = channel.store.lock().await;
let mut seen = std::collections::HashSet::new();
recent_conversation_messages(
store
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.chain(store.buffer.iter().filter_map(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return None;
};
let ServerNotification::ItemCompleted(notification) = notification.as_ref()
else {
return None;
};
Some(&notification.item)
}))
.filter(|item| seen.insert(item.id().to_string())),
)
};
let Some(conversation) = conversation else {
self.chat_widget
.apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None);
return;
};
self.generate_thread_title(
app_server,
thread_id,
ThreadTitleDestination::RenameSuggestion { request_id },
recent_conversation_thread_title_prompt(&conversation),
);
}
}
/// Constrain generated metadata to one nonempty title within the display limit.
pub(super) fn thread_title_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": THREAD_TITLE_MAX_CHARS,
},
},
"required": ["title"],
"additionalProperties": false,
})
}
fn thread_title_instructions() -> String {
format!(
"Generate a concise, single-line task title of at most \
{THREAD_TITLE_MAX_CHARS} characters and under five words where possible. \
Start with an imperative verb. Capitalize only the first word unless the \
user's language, proper nouns, acronyms, or code terms require otherwise. \
Preserve ticket references exactly. Write in the user's language. \
Do not use quotes, markdown, or trailing punctuation. \
Do not answer the request."
)
}
/// Build a bounded title request without truncating a Unicode character.
pub(super) fn thread_title_prompt(user_message: &str) -> String {
let instructions = thread_title_instructions();
let prefix = format!("{instructions}\n\nUser prompt:\n");
let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES.saturating_sub(prefix.len());
let user_message = user_message
.trim()
.char_indices()
.take_while(|(index, character)| index + character.len_utf8() <= remaining_bytes)
.map(|(_, character)| character)
.collect::<String>();
format!("{prefix}{user_message}")
}
/// Format recent substantive messages chronologically without trusting their markup.
pub(super) fn recent_conversation_messages<'a, I>(items: I) -> Option<String>
where
I: IntoIterator<Item = &'a ThreadItem>,
I::IntoIter: DoubleEndedIterator,
{
let mut messages = items
.into_iter()
.rev()
.filter_map(|item| match item {
ThreadItem::UserMessage { content, .. } => {
let text = content
.iter()
.filter_map(|input| match input {
UserInput::Text { text, .. } => {
Some(crate::ide_context::extract_prompt_request_with_offset(text).0)
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(("user", text))
}
ThreadItem::AgentMessage { text, phase, .. }
if !matches!(phase, Some(MessagePhase::Commentary)) && !text.trim().is_empty() =>
{
Some(("assistant", text.clone()))
}
_ => None,
})
.take(THREAD_TITLE_RECENT_MESSAGES)
.map(|(role, text)| {
let escaped = text
.trim()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
(role, escaped)
})
.collect::<Vec<_>>();
if messages.is_empty() {
return None;
}
messages.reverse();
let conversation_bytes = THREAD_TITLE_PROMPT_MAX_BYTES
.saturating_sub(recent_conversation_thread_title_prompt("").len());
let markup_bytes = "<conversation>\n".len()
+ "\n</conversation>".len()
+ messages.len().saturating_sub(/*rhs*/ 1)
+ messages
.iter()
.map(|(role, _)| "<message role=\"\"></message>".len() + role.len())
.sum::<usize>();
let message_bytes = conversation_bytes.saturating_sub(markup_bytes) / messages.len();
let content_bytes = messages.iter().map(|(_, text)| text.len()).sum::<usize>();
let should_truncate = content_bytes > conversation_bytes.saturating_sub(markup_bytes);
let messages = messages
.into_iter()
.map(|(role, mut text)| {
if should_truncate && text.len() > message_bytes {
let mut end = message_bytes;
while !text.is_char_boundary(end) {
end -= 1;
}
if let Some(entity_start) = text[..end].rfind('&')
&& !text[entity_start..end].contains(';')
{
end = entity_start;
}
text.truncate(end);
}
format!("<message role=\"{role}\">{text}</message>")
})
.collect::<Vec<_>>();
Some(format!(
"<conversation>\n{}\n</conversation>",
messages.join("\n")
))
}
/// Bound the entire suggestion prompt while preserving complete Unicode characters.
pub(super) fn recent_conversation_thread_title_prompt(conversation: &str) -> String {
let instructions = thread_title_instructions();
let prefix = format!(
"{instructions}\n\
Prioritize the current task and latest substantive user request.\n\n\
Recent conversation messages:\n"
);
let conversation = conversation.trim();
let remaining_bytes = THREAD_TITLE_PROMPT_MAX_BYTES.saturating_sub(prefix.len());
let mut start = conversation.len().saturating_sub(remaining_bytes);
while !conversation.is_char_boundary(start) {
start += 1;
}
let conversation = &conversation[start..];
format!("{prefix}{conversation}")
}
/// Normalize a generated title and truncate it without splitting Unicode characters.
pub(super) fn parse_thread_title(response: &str) -> Option<String> {
if !response.trim_start().starts_with('{') {
return None;
}
let title = serde_json::from_str::<GeneratedThreadTitle>(response)
.ok()?
.title;
let normalized = title
.trim()
.trim_matches(|character| matches!(character, '"' | '\'' | '`' | '“' | '”' | '‘' | '’'))
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_end_matches(['.', '?', '!'])
.trim_end()
.to_string();
if normalized.is_empty() {
return None;
}
Some(normalized.chars().take(THREAD_TITLE_MAX_CHARS).collect())
}
#[cfg(test)]
#[path = "thread_title_tests.rs"]
mod tests;