use crate::message::ChatMessage;
pub trait SessionTitler {
fn title(&self, transcript_preview: &str) -> crate::Result<String>;
fn model_id(&self) -> &str;
}
pub const PROMPT_VERSION: &str = "session-title-v1";
pub const MAX_TITLE_CHARS: usize = 80;
pub fn render_transcript_preview(history: &[ChatMessage], max_chars: usize) -> String {
let mut out = String::new();
for msg in history.iter().skip(1) {
if out.len() >= max_chars {
break;
}
let role = match msg.role {
crate::message::Role::User => "user",
crate::message::Role::Assistant => "assistant",
crate::message::Role::System => "system",
crate::message::Role::Tool => continue, };
if let Some(content) = &msg.content {
out.push_str(role);
out.push_str(": ");
out.push_str(content);
out.push('\n');
}
}
out.truncate(out.floor_char_boundary_compat(max_chars));
out
}
trait FloorCharBoundary {
fn floor_char_boundary_compat(&self, max: usize) -> usize;
}
impl FloorCharBoundary for str {
fn floor_char_boundary_compat(&self, max: usize) -> usize {
if max >= self.len() {
return self.len();
}
let mut end = max;
while end > 0 && !self.is_char_boundary(end) {
end -= 1;
}
end
}
}
pub fn render_prompt(transcript_preview: &str) -> String {
format!(
"You are naming an AI coding agent's session. Write a short (3-8 word) \
descriptive title for the conversation below. Do not use quotes or a \
trailing period. Do not editorialize.\n\n\
--- BEGIN TRANSCRIPT ---\n\
{transcript_preview}\n\
--- END TRANSCRIPT ---\n"
)
}
pub fn auto_title(history: &[ChatMessage], titler: &dyn SessionTitler) -> Option<String> {
let preview = render_transcript_preview(history, 4000);
if preview.trim().is_empty() {
return None;
}
let prompt = render_prompt(&preview);
let title = titler.title(&prompt).ok()?;
let cleaned: String = title
.trim()
.trim_matches(['"', '\''])
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
return None;
}
let mut out = cleaned;
if out.len() > MAX_TITLE_CHARS {
let cut = out.floor_char_boundary_compat(MAX_TITLE_CHARS);
out.truncate(cut);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::ChatMessage;
struct FakeTitler {
response: crate::Result<String>,
}
impl SessionTitler for FakeTitler {
fn title(&self, _preview: &str) -> crate::Result<String> {
match &self.response {
Ok(s) => Ok(s.clone()),
Err(_) => Err(crate::Error::Other("fake titler error".to_string())),
}
}
fn model_id(&self) -> &str {
"fake-titler-model"
}
}
fn history_with(user: &str, assistant: &str) -> Vec<ChatMessage> {
vec![
ChatMessage::system("sys"),
ChatMessage::user(user),
ChatMessage::assistant(assistant),
]
}
#[test]
fn render_prompt_embeds_the_preview_verbatim() {
let p = render_prompt("user: fix the bug\nassistant: done\n");
assert!(p.contains("user: fix the bug"));
assert!(p.contains("BEGIN TRANSCRIPT"));
}
#[test]
fn render_transcript_preview_skips_system_and_tool_roles() {
let history = vec![
ChatMessage::system("sys prompt"),
ChatMessage::user("hello"),
ChatMessage::tool_result("id1".to_string(), "bash".to_string(), "output".to_string()),
ChatMessage::assistant("hi there"),
];
let preview = render_transcript_preview(&history, 4000);
assert!(!preview.contains("sys prompt"));
assert!(!preview.contains("output"));
assert!(preview.contains("hello"));
assert!(preview.contains("hi there"));
}
#[test]
fn happy_path_produces_a_trimmed_title() {
let history = history_with("please fix the login bug", "fixed it");
let titler = FakeTitler {
response: Ok(" \"Fix login bug\" ".to_string()),
};
let title = auto_title(&history, &titler);
assert_eq!(title.as_deref(), Some("Fix login bug"));
}
struct AlwaysErrors;
impl SessionTitler for AlwaysErrors {
fn title(&self, _: &str) -> crate::Result<String> {
Err(crate::Error::Other("boom".to_string()))
}
fn model_id(&self) -> &str {
"n/a"
}
}
#[test]
fn titler_error_falls_back_to_none_never_panics() {
let history = history_with("hello", "hi");
assert!(auto_title(&history, &AlwaysErrors).is_none());
}
#[test]
fn empty_or_blank_title_falls_back_to_none() {
let history = history_with("hello", "hi");
let titler = FakeTitler {
response: Ok(" ".to_string()),
};
assert!(auto_title(&history, &titler).is_none());
}
#[test]
fn overlong_title_is_capped_at_max_chars() {
let history = history_with("hello", "hi");
let long = "word ".repeat(50);
let titler = FakeTitler { response: Ok(long) };
let title = auto_title(&history, &titler).unwrap();
assert!(title.len() <= MAX_TITLE_CHARS);
}
#[test]
fn empty_history_produces_no_title() {
let history = vec![ChatMessage::system("sys")];
let titler = FakeTitler {
response: Ok("Should not be reached".to_string()),
};
assert!(auto_title(&history, &titler).is_none());
}
}