1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1
//! `core.session.auto_title`, catalog:150, D-9): auto-title / session
//! summary — a small-model side-call that titles a session, mirroring
//! [`crate::reduce::summarize`]'s plumbing exactly: an injectable trait
//! (real implementations call out to a model; this crate's own tests only
//! ever inject deterministic fakes — no real network/model call anywhere in
//! this crate, same posture as [`crate::reduce::summarize::SpanSummarizer`]),
//! a fixed versioned prompt, and a "never blocks, never fails the caller"
//! contract.
//!
//! **Small-model routing (D-9).** `Config::small_model` (P4a) is "a knob a
//! caller reads, not a routing loop this crate runs" — the same is true
//! here: [`auto_title`] takes an already-constructed [`SessionTitler`], and
//! it is the CALLER's job to have built that titler against
//! `config.small_model.clone().unwrap_or_else(|| config.model.clone())`
//! (the D-9 main-model fallback) before installing it via
//! [`crate::Agent::set_session_titler`].
//!
//! **Persistence.** The title TEXT this module produces is handed to
//! [`crate::store::SessionStore::set_title`] (already existing, S14) by the
//! caller — this module only produces the string; it never touches the
//! filesystem itself.
use crate::message::ChatMessage;
/// Injectable session-titling side-call (mirrors
/// [`crate::reduce::summarize::SpanSummarizer`] exactly). `Err` — for any
/// reason, including a caller-modeled timeout or budget exhaustion — means
/// the caller must fall back to no title (or the session's existing one);
/// this call must never block or fail the surrounding session-save path.
pub trait SessionTitler {
/// Produce a short title from `transcript_preview` (the rendering
/// [`render_transcript_preview`] produces).
fn title(&self, transcript_preview: &str) -> crate::Result<String>;
/// Identifier of the model behind this titler (e.g.
/// `"claude-haiku-4-5"`), for callers that want to record provenance
/// alongside the title.
fn model_id(&self) -> &str;
}
/// The fixed, in-repo, VERSIONED titling prompt template (mirrors
/// `reduce::summarize::PROMPT_VERSION`'s precedent — bump this any time
/// [`render_prompt`]'s wording changes).
pub const PROMPT_VERSION: &str = "session-title-v1";
/// A produced title is trimmed and capped at this many characters — a
/// runaway/uncooperative model response must not become an unreasonably
/// long session name.
pub const MAX_TITLE_CHARS: usize = 80;
/// Render the first `max_chars` characters of the conversation (skipping the
/// system prompt at index 0) as the titling input — bounded so a huge
/// session doesn't blow up the side-call's own request size.
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, // tool output is noise for a title
};
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
}
/// Char-boundary-safe truncation helper (stable Rust has no
/// `floor_char_boundary` yet) — walk back from `max` to the nearest valid
/// UTF-8 boundary so we never panic mid-codepoint.
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
}
}
/// Render the fixed prompt for titling `transcript_preview`.
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"
)
}
/// Produce an auto-title for `history` via `titler`, or `None` if the
/// side-call errors, returns empty text, or `titler` is unavailable.
/// Trimmed and capped at [`MAX_TITLE_CHARS`]; never panics, never blocks
/// longer than `titler.title` itself does.
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());
}
}