use crate::{
agent::cancellation::{AgentCancellation, is_run_canceled},
config::{McPaths, SessionTitleConfig, load_effective_provider_selection},
providers::{ChatMessage, Provider, ProviderEvent, ProviderRequest, ProviderSelection},
sessions::{
Session, SessionEventKind, record_session_title, session_title_metadata,
try_record_session_event,
},
};
use std::{
path::PathBuf,
sync::{Arc, mpsc},
thread,
time::Duration,
};
pub(crate) const SESSION_TITLE_MAX_CHARS: usize = 50;
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) first_prompt: String,
pub(crate) cancellation: AgentCancellation,
pub(crate) notifier: Option<SessionTitleNotifier>,
}
#[cfg(test)]
pub(crate) fn should_start_title_generation(session: &Session) -> bool {
should_start_title_generation_metadata(session)
.map(|metadata| metadata.latest_title.is_none() && metadata.user_input_count == 1)
.unwrap_or(false)
}
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 settings = crate::config::read_settings(&job.paths)?;
let selection = ProviderSelection::from_config(&config)?;
let provider = crate::agent::runner::provider_from_selection(&config, &selection, &job.cwd)?;
job.cancellation.check()?;
let Some(title) = generate_title_cancellable_with_verbosity(
provider.as_ref(),
&selection.provider,
&selection.model,
title_context(&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 _ = try_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
}
}
#[cfg(test)]
pub(crate) fn generate_title_cancellable(
provider: &dyn Provider,
model: &str,
first_prompt: &str,
cancellation: &AgentCancellation,
) -> anyhow::Result<Option<String>> {
generate_title_cancellable_with_verbosity(
provider,
crate::providers::OPENAI_CODEX_PROVIDER,
model,
first_prompt,
None,
cancellation,
)
}
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))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::ProviderRequest;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
struct CapturingProvider {
request: Mutex<Option<ProviderRequest>>,
deltas: Vec<&'static str>,
}
impl Provider for CapturingProvider {
fn stream_cancellable(
&self,
request: ProviderRequest,
_cancellation: &AgentCancellation,
on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
*self.request.lock().unwrap() = Some(request);
for delta in &self.deltas {
on_event(ProviderEvent::TextDelta((*delta).to_string()))?;
}
on_event(ProviderEvent::Done)?;
Ok(())
}
}
fn test_job(session: Session, root: &std::path::Path, first_prompt: &str) -> SessionTitleJob {
SessionTitleJob {
paths: McPaths::from_root(root.join("mc")),
session,
cwd: root.to_path_buf(),
config: SessionTitleConfig {
provider: "title-provider".to_string(),
model: "title-model".to_string(),
},
first_prompt: first_prompt.to_string(),
cancellation: AgentCancellation::default(),
notifier: None,
}
}
#[test]
fn should_start_title_generation_only_after_first_user_input() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
assert!(!should_start_title_generation(&session));
crate::sessions::record_session_event(
Some(&session),
temp.path(),
"user_input",
serde_json::json!({"text":"one"}),
)
.unwrap();
assert!(should_start_title_generation(&session));
crate::sessions::record_session_event(
Some(&session),
temp.path(),
"user_input",
serde_json::json!({"text":"two"}),
)
.unwrap();
assert!(!should_start_title_generation(&session));
}
#[test]
fn should_not_start_title_generation_when_title_exists() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
crate::sessions::record_session_event(
Some(&session),
temp.path(),
"user_input",
serde_json::json!({"text":"one"}),
)
.unwrap();
crate::sessions::record_session_title(
&session,
temp.path(),
"Existing title",
"provider",
"model",
)
.unwrap();
assert!(!should_start_title_generation(&session));
}
#[test]
fn title_context_uses_job_prompt_prefilled_by_caller() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
crate::sessions::record_session_event(
Some(&session),
temp.path(),
"user_input",
serde_json::json!({"text":"persisted prompt"}),
)
.unwrap();
let job = test_job(session, temp.path(), "fallback prompt");
assert_eq!(title_context(&job), "fallback prompt");
}
#[test]
fn title_context_falls_back_to_job_prompt_without_persisted_user_input() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let job = test_job(session, temp.path(), "fallback prompt");
assert_eq!(title_context(&job), "fallback prompt");
}
#[test]
fn title_generation_diagnostic_is_sanitized_and_non_fatal() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let job = test_job(session.clone(), temp.path(), "fallback prompt");
record_title_generation_diagnostic(
&job,
"failed",
"api_key=sentinelTitleSecret123\nprovider failed",
);
let events = session.read_events().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind(), Some(SessionEventKind::Diagnostic));
assert_eq!(events[0].payload["reason"], "failed");
let message = events[0].payload["message"].as_str().unwrap();
assert!(message.contains("session title generation failed"));
assert!(message.contains("api_key=<redacted>"));
assert!(!message.contains("sentinelTitleSecret123"));
}
#[test]
fn canceled_background_title_generation_writes_no_diagnostic() {
let temp = tempfile::TempDir::new().unwrap();
let manager = crate::sessions::SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let canceled = Arc::new(AtomicBool::new(true));
let mut job = test_job(session.clone(), temp.path(), "fallback prompt");
job.cancellation = AgentCancellation::new(canceled);
spawn_background(job).join().unwrap();
assert!(session.read_events().unwrap().is_empty());
}
#[test]
fn title_request_is_bounded_and_sanitized() {
let provider = CapturingProvider {
request: Mutex::new(None),
deltas: vec![" \"Fix parser bug\"\nextra"],
};
let title = generate_title_cancellable(
&provider,
"title-model",
&"a".repeat(3000),
&AgentCancellation::default(),
)
.unwrap()
.unwrap();
assert_eq!(title, "Fix parser bug extra");
let request = provider.request.lock().unwrap().clone().unwrap();
assert_eq!(request.thinking_level, crate::thinking::ThinkingLevel::Low);
assert!(request.send_default_reasoning_summary());
let body = crate::providers::codex_responses_body("title-model", &request);
assert_eq!(body["reasoning"]["effort"], "low");
assert_eq!(request.model, "title-model");
assert!(
request.messages()[0]
.content
.contains("maximum 50 characters")
);
assert!(request.messages()[1].content.len() < 2200);
assert!(request.tool_results().is_empty());
assert!(!request.tools_enabled());
}
#[test]
fn non_codex_title_request_does_not_add_reasoning_fields() {
let provider = CapturingProvider {
request: Mutex::new(None),
deltas: vec!["Custom provider title"],
};
let title = generate_title_cancellable_with_timeout(
&provider,
"custom-provider",
"title-model",
"initial prompt",
None,
&AgentCancellation::default(),
Duration::from_secs(1),
)
.unwrap();
assert_eq!(title.as_deref(), Some("Custom provider title"));
let request = provider.request.lock().unwrap().clone().unwrap();
assert_eq!(
request.thinking_level,
crate::thinking::ThinkingLevel::Default
);
assert!(!request.send_default_reasoning_summary());
let body =
crate::providers::openai_compatible_chat_completions_body("title-model", &request);
assert!(body.get("reasoning_effort").is_none());
}
#[test]
fn title_timeout_is_distinct_from_parent_cancellation() {
struct WaitForCancellation;
impl Provider for WaitForCancellation {
fn stream_cancellable(
&self,
_request: ProviderRequest,
cancellation: &AgentCancellation,
_on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
while !cancellation.is_canceled() {
std::thread::sleep(Duration::from_millis(1));
}
cancellation.check()
}
}
let error = generate_title_cancellable_with_timeout(
&WaitForCancellation,
crate::providers::OPENAI_CODEX_PROVIDER,
"title-model",
"initial prompt",
None,
&AgentCancellation::default(),
Duration::from_millis(10),
)
.unwrap_err();
assert!(error.downcast_ref::<SessionTitleTimeout>().is_some());
assert!(!is_run_canceled(&error));
}
#[test]
fn cancelled_title_generation_does_not_stream_or_append_text() {
struct PanicProvider;
impl Provider for PanicProvider {
fn stream_cancellable(
&self,
_request: ProviderRequest,
_cancellation: &AgentCancellation,
_on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
panic!("stream should not run after cancellation");
}
}
let canceled = Arc::new(AtomicBool::new(true));
let error = generate_title_cancellable(
&PanicProvider,
"title-model",
"initial prompt",
&AgentCancellation::new(canceled),
)
.unwrap_err();
assert!(crate::agent::cancellation::is_run_canceled(&error));
}
#[test]
fn title_generation_checks_cancellation_between_deltas() {
struct CancelAfterFirstDelta {
flag: Arc<AtomicBool>,
}
impl Provider for CancelAfterFirstDelta {
fn stream_cancellable(
&self,
_request: ProviderRequest,
_cancellation: &AgentCancellation,
on_event: &mut dyn FnMut(ProviderEvent) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
on_event(ProviderEvent::TextDelta("stale".to_string()))?;
self.flag.store(true, Ordering::SeqCst);
on_event(ProviderEvent::TextDelta(" title".to_string()))
}
}
let flag = Arc::new(AtomicBool::new(false));
let error = generate_title_cancellable(
&CancelAfterFirstDelta {
flag: Arc::clone(&flag),
},
"title-model",
"initial prompt",
&AgentCancellation::new(flag),
)
.unwrap_err();
assert!(crate::agent::cancellation::is_run_canceled(&error));
}
}