use futures::StreamExt as _;
use crate::event::LlmStreamEvent;
use crate::message::{AgentMessage, ContentBlock};
use crate::streamfn::{LlmOpts, StreamFn};
pub const DEFAULT_TITLE_AFTER_TURNS: usize = 2;
pub const TITLE_CLAMP: usize = 60;
const TITLE_INPUT_CHARS: usize = 600;
pub use crate::message::COMPACTION_SUMMARY_PREFIX;
const TITLE_PROMPT: &str = "\
You are naming a coding-agent session so its owner can find it again in a list of \
sessions. Reply with the name only: 3-6 words, no quotes, no trailing period, no \
'Title:' prefix, nothing else. Name what the session is ABOUT (its subject or \
goal), not what was said most recently. Prefer concrete nouns and file or \
component names that appear in the text.";
pub async fn generate(
stream_fn: &StreamFn,
opts: &LlmOpts,
ctx: &[AgentMessage],
) -> Result<String, String> {
let body = excerpt(ctx);
if body.is_empty() {
return Err("no input to title from".to_string());
}
let mut stream = stream_fn(&[AgentMessage::user_text(body)], TITLE_PROMPT, &[], opts);
let mut text = String::new();
while let Some(event) = stream.next().await {
match event {
LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
LlmStreamEvent::Done { .. } => break,
LlmStreamEvent::Error { message, .. } => return Err(message),
_ => {}
}
}
sanitize(&text).ok_or_else(|| "titler produced no text".to_string())
}
pub fn sanitize(raw: &str) -> Option<String> {
let first_line = raw.trim().split(['\n', '\r']).next().unwrap_or("");
let unquoted: String = first_line
.chars()
.filter(|c| !matches!(c, '"' | '\'' | '`'))
.collect();
let collapsed = unquoted.split_whitespace().collect::<Vec<_>>().join(" ");
let collapsed = collapsed.trim();
if collapsed.is_empty() {
return None;
}
let clamped: String = collapsed.chars().take(TITLE_CLAMP).collect();
Some(clamped.trim().to_string())
}
fn excerpt(ctx: &[AgentMessage]) -> String {
let mut body = String::new();
if let Some(user) = first_user_text(ctx) {
body.push_str("USER:\n");
body.push_str(&user);
}
if let Some(assistant) = first_assistant_text(ctx) {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str("ASSISTANT:\n");
body.push_str(&assistant);
}
body
}
fn first_user_text(ctx: &[AgentMessage]) -> Option<String> {
for message in ctx {
if let AgentMessage::User { .. } = message {
let text = message.as_text();
let trimmed = text.trim();
if !trimmed.is_empty() && !trimmed.starts_with(COMPACTION_SUMMARY_PREFIX) {
return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
}
}
}
None
}
fn first_assistant_text(ctx: &[AgentMessage]) -> Option<String> {
for message in ctx {
let AgentMessage::Assistant { content, .. } = message else {
continue;
};
for block in content {
if let ContentBlock::Text { text } = block {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
}
}
}
}
None
}
fn clamp_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
text.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::message::{ContentBlock, StopReason};
use super::*;
type Calls = Arc<std::sync::Mutex<Vec<(Vec<AgentMessage>, String)>>>;
fn scripted(script: Vec<LlmStreamEvent>) -> (StreamFn, Calls) {
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = Arc::clone(&calls);
let f: StreamFn = Arc::new(move |ctx, system, _tools, _opts| {
seen.lock()
.unwrap()
.push((ctx.to_vec(), system.to_string()));
Box::pin(futures::stream::iter(script.clone())) as crate::streamfn::LlmStream
});
(f, calls)
}
fn user(text: &str) -> AgentMessage {
AgentMessage::user_text(text)
}
fn assistant(text: &str) -> AgentMessage {
AgentMessage::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
stop_reason: StopReason::Stop,
usage: None,
model: None,
}
}
fn done() -> LlmStreamEvent {
LlmStreamEvent::Done {
stop_reason: StopReason::Stop,
usage: None,
}
}
#[tokio::test]
async fn generate_collects_text_and_sends_both_halves() {
let (f, calls) = scripted(vec![
LlmStreamEvent::TextDelta("retry".into()),
LlmStreamEvent::TextDelta(" backoff".into()),
done(),
]);
let ctx = vec![user("add retry backoff"), assistant("done: 3 retries")];
let got = generate(&f, &LlmOpts::default(), &ctx).await.unwrap();
assert_eq!(got, "retry backoff", "the TextDeltas are folded");
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "exactly one model call");
let (sent, system) = &calls[0];
assert_eq!(system, TITLE_PROMPT);
assert_eq!(sent.len(), 1, "one user message carries both halves");
let body = sent[0].as_text();
assert!(body.contains("USER:\nadd retry backoff"), "{body}");
assert!(body.contains("ASSISTANT:\ndone: 3 retries"), "{body}");
}
#[tokio::test]
async fn generate_refuses_an_empty_excerpt_without_a_call() {
let (f, calls) = scripted(vec![]);
assert_eq!(
generate(&f, &LlmOpts::default(), &[]).await.unwrap_err(),
"no input to title from"
);
let summary_only = vec![AgentMessage::summary("did 1..3")];
assert_eq!(
generate(&f, &LlmOpts::default(), &summary_only)
.await
.unwrap_err(),
"no input to title from"
);
assert!(
calls.lock().unwrap().is_empty(),
"no request for an empty excerpt"
);
}
#[test]
fn excerpt_skips_a_compaction_summary() {
let ctx = vec![
AgentMessage::summary("did 1..3"),
user("real question"),
assistant("answer"),
];
let body = excerpt(&ctx);
assert!(
!body.contains(COMPACTION_SUMMARY_PREFIX),
"the synthetic summary must never reach the titler: {body}"
);
assert!(body.contains("real question"), "{body}");
assert!(body.contains("answer"), "{body}");
assert_eq!(body, "USER:\nreal question\n\nASSISTANT:\nanswer");
}
#[test]
fn sanitize_strips_quotes_and_clamps() {
assert_eq!(
sanitize(" `\"retry backoff\"` ").as_deref(),
Some("retry backoff")
);
assert_eq!(sanitize("don't").as_deref(), Some("dont"));
assert_eq!(
sanitize("Retry backoff.\nsecond line ignored").as_deref(),
Some("Retry backoff.")
);
assert_eq!(sanitize("a\t\tb c").as_deref(), Some("a b c"));
let long = "x".repeat(TITLE_CLAMP + 20);
let got = sanitize(&long).unwrap();
assert_eq!(got.chars().count(), TITLE_CLAMP);
assert!(!got.ends_with('…'), "no ellipsis: the cap is a hard cut");
let padded = format!("{} tail", "y".repeat(TITLE_CLAMP - 1));
let got = sanitize(&padded).unwrap();
assert_eq!(got.chars().count(), TITLE_CLAMP - 1);
assert!(!got.ends_with(' '), "{got:?}");
}
#[test]
fn sanitize_rejects_an_empty_or_multiline_only_answer() {
assert_eq!(sanitize(""), None);
assert_eq!(sanitize(" \n\t\n "), None);
assert_eq!(sanitize("\"\"``''"), None);
}
#[tokio::test]
async fn generate_surfaces_a_stream_error() {
let (f, calls) = scripted(vec![LlmStreamEvent::Error {
message: "boom".into(),
fatal: true,
}]);
let ctx = vec![user("q"), assistant("a")];
assert_eq!(
generate(&f, &LlmOpts::default(), &ctx).await.unwrap_err(),
"boom"
);
assert_eq!(calls.lock().unwrap().len(), 1);
}
}