use crate::{
cancellation::{AgentCancellation, is_run_canceled},
config::{McPaths, SessionTitleConfig, load_effective_provider_selection},
providers::{
ChatMessage, Provider, ProviderEvent, ProviderRequest, ProviderSelection,
provider_from_selection_with_settings_for_workload,
},
sessions::{
SESSION_TITLE_MAX_CHARS, Session, SessionEventKind, record_session_event,
record_session_title, session_title_metadata,
},
};
use std::{
path::PathBuf,
sync::{Arc, mpsc},
thread,
time::Duration,
};
const TITLE_CONTEXT_MAX_CHARS: usize = 2000;
const TITLE_OUTPUT_MAX_CHARS: usize = 512;
const TITLE_OVERALL_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug)]
struct SessionTitleTimeout(Duration);
impl std::fmt::Display for SessionTitleTimeout {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"session title provider timed out after {:?}",
self.0
)
}
}
impl std::error::Error for SessionTitleTimeout {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionTitleUpdate {
pub(crate) session_id: String,
pub(crate) title: String,
}
pub(crate) type SessionTitleNotifier = Arc<dyn Fn(SessionTitleUpdate) + Send + Sync + 'static>;
#[derive(Clone)]
pub(crate) struct SessionTitleJob {
pub(crate) paths: McPaths,
pub(crate) session: Session,
pub(crate) cwd: PathBuf,
pub(crate) config: SessionTitleConfig,
pub(crate) settings: crate::config::Settings,
pub(crate) first_prompt: String,
pub(crate) cancellation: AgentCancellation,
pub(crate) notifier: Option<SessionTitleNotifier>,
}
pub(crate) fn should_start_title_generation_metadata(
session: &Session,
) -> anyhow::Result<crate::sessions::SessionTitleMetadata> {
session_title_metadata(session)
}
pub(crate) fn spawn_background(job: SessionTitleJob) -> thread::JoinHandle<()> {
thread::spawn(move || match generate_and_persist(job.clone()) {
Ok(Some(update)) => {
if let Some(notifier) = job.notifier.as_ref() {
notifier(update);
}
}
Ok(None) => record_title_generation_diagnostic(
&job,
"empty_output",
"provider returned no title text after sanitization",
),
Err(error) if is_run_canceled(&error) => {}
Err(error) if error.downcast_ref::<SessionTitleTimeout>().is_some() => {
record_title_generation_diagnostic(&job, "timeout", &safe_error_message(&error))
}
Err(error) => {
record_title_generation_diagnostic(&job, "failed", &safe_error_message(&error))
}
})
}
fn generate_and_persist(job: SessionTitleJob) -> anyhow::Result<Option<SessionTitleUpdate>> {
job.cancellation.check()?;
let config = load_effective_provider_selection(
&job.paths,
job.config.provider.as_str(),
job.config.model.as_str(),
)?;
let selection = ProviderSelection::from_config(&config)?;
let provider = provider_from_selection_with_settings_for_workload(
&config,
&selection,
&job.cwd,
&job.settings,
crate::fast::FastWorkload::SessionTitle,
)?;
job.cancellation.check()?;
let Some(title) = generate_title_cancellable_with_verbosity(
provider.as_ref(),
&selection.provider,
&selection.model,
title_context(&job),
job.settings.text_verbosity_for(&selection.provider),
&job.cancellation,
)?
else {
return Ok(None);
};
job.cancellation.check()?;
record_session_title(
&job.session,
&job.cwd,
&title,
&selection.provider,
&selection.model,
)?;
Ok(Some(SessionTitleUpdate {
session_id: job.session.id().to_string(),
title,
}))
}
fn title_context(job: &SessionTitleJob) -> &str {
&job.first_prompt
}
fn record_title_generation_diagnostic(job: &SessionTitleJob, reason: &str, message: &str) {
let sanitized = sanitize_title_diagnostic(message);
let _ = record_session_event(
Some(&job.session),
&job.cwd,
SessionEventKind::Diagnostic,
serde_json::json!({
"level": "warning",
"category": "session_title_generation",
"reason": reason,
"message": format!("session title generation {reason}: {sanitized}"),
}),
);
}
fn safe_error_message(error: &anyhow::Error) -> String {
error
.chain()
.next()
.map(ToString::to_string)
.unwrap_or_else(|| "unknown error".to_string())
}
fn sanitize_title_diagnostic(message: &str) -> String {
let redacted = crate::output::redact_sensitive_text(message);
let single_line = redacted
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
let mut chars = single_line.chars();
let mut bounded = chars.by_ref().take(500).collect::<String>();
if chars.next().is_some() {
bounded.push('…');
}
if bounded.is_empty() {
"unknown error".to_string()
} else {
bounded
}
}
fn generate_title_cancellable_with_verbosity(
provider: &dyn Provider,
provider_id: &str,
model: &str,
first_prompt: &str,
text_verbosity: Option<crate::config::TextVerbosity>,
cancellation: &AgentCancellation,
) -> anyhow::Result<Option<String>> {
generate_title_cancellable_with_timeout(
provider,
provider_id,
model,
first_prompt,
text_verbosity,
cancellation,
TITLE_OVERALL_TIMEOUT,
)
}
fn generate_title_cancellable_with_timeout(
provider: &dyn Provider,
provider_id: &str,
model: &str,
first_prompt: &str,
text_verbosity: Option<crate::config::TextVerbosity>,
cancellation: &AgentCancellation,
overall_timeout: Duration,
) -> anyhow::Result<Option<String>> {
cancellation.check()?;
let bounded_context = first_prompt
.chars()
.take(TITLE_CONTEXT_MAX_CHARS)
.collect::<String>();
let mut request = ProviderRequest::new_without_tools(
model.to_string(),
vec![
ChatMessage::system(format!(
"Return one concise friendly session title, maximum {SESSION_TITLE_MAX_CHARS} characters. No quotes. No markdown. No explanation."
)),
ChatMessage::user(format!(
"Infer a title from this initial user request:\n\n{bounded_context}"
)),
],
)
.with_text_verbosity(text_verbosity);
if provider_id == crate::providers::OPENAI_CODEX_PROVIDER {
request = request
.with_thinking_level(crate::thinking::ThinkingLevel::Low)
.with_default_reasoning_summary(true);
}
let (title_cancellation, timeout_handle) = cancellation.child_token();
let (done_sender, done_receiver) = mpsc::channel();
let timeout_thread = thread::spawn(move || {
if matches!(
done_receiver.recv_timeout(overall_timeout),
Err(mpsc::RecvTimeoutError::Timeout)
) {
timeout_handle.cancel();
true
} else {
false
}
});
let mut raw = String::new();
let result = provider.stream_cancellable(request, &title_cancellation, &mut |event| {
title_cancellation.check()?;
if let ProviderEvent::TextDelta(delta) = event {
let remaining = TITLE_OUTPUT_MAX_CHARS.saturating_sub(raw.chars().count());
raw.extend(delta.chars().take(remaining));
}
title_cancellation.check()?;
Ok(())
});
let _ = done_sender.send(());
let timed_out = timeout_thread.join().unwrap_or(false);
if timed_out && !cancellation.is_canceled() {
return Err(SessionTitleTimeout(overall_timeout).into());
}
result?;
cancellation.check()?;
Ok(crate::sessions::sanitize_session_title(&raw))
}