supercode_harness/
session_title.rs1use crate::message::ChatMessage;
25
26pub trait SessionTitler {
32 fn title(&self, transcript_preview: &str) -> crate::Result<String>;
35
36 fn model_id(&self) -> &str;
40}
41
42pub const PROMPT_VERSION: &str = "session-title-v1";
46
47pub const MAX_TITLE_CHARS: usize = 80;
51
52pub fn render_transcript_preview(history: &[ChatMessage], max_chars: usize) -> String {
56 let mut out = String::new();
57 for msg in history.iter().skip(1) {
58 if out.len() >= max_chars {
59 break;
60 }
61 let role = match msg.role {
62 crate::message::Role::User => "user",
63 crate::message::Role::Assistant => "assistant",
64 crate::message::Role::System => "system",
65 crate::message::Role::Tool => continue, };
67 if let Some(content) = &msg.content {
68 out.push_str(role);
69 out.push_str(": ");
70 out.push_str(content);
71 out.push('\n');
72 }
73 }
74 out.truncate(out.floor_char_boundary_compat(max_chars));
75 out
76}
77
78trait FloorCharBoundary {
82 fn floor_char_boundary_compat(&self, max: usize) -> usize;
83}
84impl FloorCharBoundary for str {
85 fn floor_char_boundary_compat(&self, max: usize) -> usize {
86 if max >= self.len() {
87 return self.len();
88 }
89 let mut end = max;
90 while end > 0 && !self.is_char_boundary(end) {
91 end -= 1;
92 }
93 end
94 }
95}
96
97pub fn render_prompt(transcript_preview: &str) -> String {
99 format!(
100 "You are naming an AI coding agent's session. Write a short (3-8 word) \
101 descriptive title for the conversation below. Do not use quotes or a \
102 trailing period. Do not editorialize.\n\n\
103 --- BEGIN TRANSCRIPT ---\n\
104 {transcript_preview}\n\
105 --- END TRANSCRIPT ---\n"
106 )
107}
108
109pub fn auto_title(history: &[ChatMessage], titler: &dyn SessionTitler) -> Option<String> {
114 let preview = render_transcript_preview(history, 4000);
115 if preview.trim().is_empty() {
116 return None;
117 }
118 let prompt = render_prompt(&preview);
119 let title = titler.title(&prompt).ok()?;
120 let cleaned: String = title
121 .trim()
122 .trim_matches(['"', '\''])
123 .split_whitespace()
124 .collect::<Vec<_>>()
125 .join(" ");
126 if cleaned.is_empty() {
127 return None;
128 }
129 let mut out = cleaned;
130 if out.len() > MAX_TITLE_CHARS {
131 let cut = out.floor_char_boundary_compat(MAX_TITLE_CHARS);
132 out.truncate(cut);
133 }
134 Some(out)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::message::ChatMessage;
141
142 struct FakeTitler {
143 response: crate::Result<String>,
144 }
145 impl SessionTitler for FakeTitler {
146 fn title(&self, _preview: &str) -> crate::Result<String> {
147 match &self.response {
148 Ok(s) => Ok(s.clone()),
149 Err(_) => Err(crate::Error::Other("fake titler error".to_string())),
150 }
151 }
152 fn model_id(&self) -> &str {
153 "fake-titler-model"
154 }
155 }
156
157 fn history_with(user: &str, assistant: &str) -> Vec<ChatMessage> {
158 vec![
159 ChatMessage::system("sys"),
160 ChatMessage::user(user),
161 ChatMessage::assistant(assistant),
162 ]
163 }
164
165 #[test]
166 fn render_prompt_embeds_the_preview_verbatim() {
167 let p = render_prompt("user: fix the bug\nassistant: done\n");
168 assert!(p.contains("user: fix the bug"));
169 assert!(p.contains("BEGIN TRANSCRIPT"));
170 }
171
172 #[test]
173 fn render_transcript_preview_skips_system_and_tool_roles() {
174 let history = vec![
175 ChatMessage::system("sys prompt"),
176 ChatMessage::user("hello"),
177 ChatMessage::tool_result("id1".to_string(), "bash".to_string(), "output".to_string()),
178 ChatMessage::assistant("hi there"),
179 ];
180 let preview = render_transcript_preview(&history, 4000);
181 assert!(!preview.contains("sys prompt"));
182 assert!(!preview.contains("output"));
183 assert!(preview.contains("hello"));
184 assert!(preview.contains("hi there"));
185 }
186
187 #[test]
188 fn happy_path_produces_a_trimmed_title() {
189 let history = history_with("please fix the login bug", "fixed it");
190 let titler = FakeTitler {
191 response: Ok(" \"Fix login bug\" ".to_string()),
192 };
193 let title = auto_title(&history, &titler);
194 assert_eq!(title.as_deref(), Some("Fix login bug"));
195 }
196
197 struct AlwaysErrors;
198 impl SessionTitler for AlwaysErrors {
199 fn title(&self, _: &str) -> crate::Result<String> {
200 Err(crate::Error::Other("boom".to_string()))
201 }
202 fn model_id(&self) -> &str {
203 "n/a"
204 }
205 }
206
207 #[test]
208 fn titler_error_falls_back_to_none_never_panics() {
209 let history = history_with("hello", "hi");
210 assert!(auto_title(&history, &AlwaysErrors).is_none());
211 }
212
213 #[test]
214 fn empty_or_blank_title_falls_back_to_none() {
215 let history = history_with("hello", "hi");
216 let titler = FakeTitler {
217 response: Ok(" ".to_string()),
218 };
219 assert!(auto_title(&history, &titler).is_none());
220 }
221
222 #[test]
223 fn overlong_title_is_capped_at_max_chars() {
224 let history = history_with("hello", "hi");
225 let long = "word ".repeat(50);
226 let titler = FakeTitler { response: Ok(long) };
227 let title = auto_title(&history, &titler).unwrap();
228 assert!(title.len() <= MAX_TITLE_CHARS);
229 }
230
231 #[test]
232 fn empty_history_produces_no_title() {
233 let history = vec![ChatMessage::system("sys")];
234 let titler = FakeTitler {
235 response: Ok("Should not be reached".to_string()),
236 };
237 assert!(auto_title(&history, &titler).is_none());
238 }
239}