Skip to main content

supercode_harness/
session_title.rs

1//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1
2//! `core.session.auto_title`, catalog:150, D-9): auto-title / session
3//! summary — a small-model side-call that titles a session, mirroring
4//! [`crate::reduce::summarize`]'s plumbing exactly: an injectable trait
5//! (real implementations call out to a model; this crate's own tests only
6//! ever inject deterministic fakes — no real network/model call anywhere in
7//! this crate, same posture as [`crate::reduce::summarize::SpanSummarizer`]),
8//! a fixed versioned prompt, and a "never blocks, never fails the caller"
9//! contract.
10//!
11//! **Small-model routing (D-9).** `Config::small_model` (P4a) is "a knob a
12//! caller reads, not a routing loop this crate runs" — the same is true
13//! here: [`auto_title`] takes an already-constructed [`SessionTitler`], and
14//! it is the CALLER's job to have built that titler against
15//! `config.small_model.clone().unwrap_or_else(|| config.model.clone())`
16//! (the D-9 main-model fallback) before installing it via
17//! [`crate::Agent::set_session_titler`].
18//!
19//! **Persistence.** The title TEXT this module produces is handed to
20//! [`crate::store::SessionStore::set_title`] (already existing, S14) by the
21//! caller — this module only produces the string; it never touches the
22//! filesystem itself.
23
24use crate::message::ChatMessage;
25
26/// Injectable session-titling side-call (mirrors
27/// [`crate::reduce::summarize::SpanSummarizer`] exactly). `Err` — for any
28/// reason, including a caller-modeled timeout or budget exhaustion — means
29/// the caller must fall back to no title (or the session's existing one);
30/// this call must never block or fail the surrounding session-save path.
31pub trait SessionTitler {
32    /// Produce a short title from `transcript_preview` (the rendering
33    /// [`render_transcript_preview`] produces).
34    fn title(&self, transcript_preview: &str) -> crate::Result<String>;
35
36    /// Identifier of the model behind this titler (e.g.
37    /// `"claude-haiku-4-5"`), for callers that want to record provenance
38    /// alongside the title.
39    fn model_id(&self) -> &str;
40}
41
42/// The fixed, in-repo, VERSIONED titling prompt template (mirrors
43/// `reduce::summarize::PROMPT_VERSION`'s precedent — bump this any time
44/// [`render_prompt`]'s wording changes).
45pub const PROMPT_VERSION: &str = "session-title-v1";
46
47/// A produced title is trimmed and capped at this many characters — a
48/// runaway/uncooperative model response must not become an unreasonably
49/// long session name.
50pub const MAX_TITLE_CHARS: usize = 80;
51
52/// Render the first `max_chars` characters of the conversation (skipping the
53/// system prompt at index 0) as the titling input — bounded so a huge
54/// session doesn't blow up the side-call's own request size.
55pub 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, // tool output is noise for a title
66        };
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
78/// Char-boundary-safe truncation helper (stable Rust has no
79/// `floor_char_boundary` yet) — walk back from `max` to the nearest valid
80/// UTF-8 boundary so we never panic mid-codepoint.
81trait 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
97/// Render the fixed prompt for titling `transcript_preview`.
98pub 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
109/// Produce an auto-title for `history` via `titler`, or `None` if the
110/// side-call errors, returns empty text, or `titler` is unavailable.
111/// Trimmed and capped at [`MAX_TITLE_CHARS`]; never panics, never blocks
112/// longer than `titler.title` itself does.
113pub 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}