wcode_harness/title.rs
1//! Session titles (W031): a bounded, one-shot titler and its sanitiser.
2//!
3//! A session's **title** is the semantic name a human finds it by — a short
4//! phrase ("fix flaky retry backoff") that beats the opaque `{millis}_{uuid8}`
5//! file id and the first user line. It lives in the transcript, not the file
6//! name: [`crate::session::SessionEntry::Title`], last write wins, read back by
7//! [`crate::session::Session::title`].
8//!
9//! # Who calls this module (and who reads it)
10//!
11//! * [`crate::loop_`] — the auto-trigger (W031 **D1**, block-inline): at the turn
12//! boundary immediately after `AgentEvent::TurnEnd` is sent, when the session
13//! has no `Title` entry yet and `title_after_turns` assistant turns exist
14//! (**D2**), and at most once per run (the latch). It appends the entry through
15//! its own `&mut Session` and streams `AgentEvent::SessionTitle`.
16//! * The CLI's `config.rs` reads [`DEFAULT_TITLE_AFTER_TURNS`] to resolve
17//! `[session] title_after_turns`, and `main.rs` reads
18//! [`COMPACTION_SUMMARY_PREFIX`] (re-exported from `message`) to keep the
19//! `--resume` picker from printing a compaction summary as a first user line.
20//! * NOTHING calls [`generate`] to rename explicitly: `/rename` and `--title` go
21//! straight to `Agent::set_title` and never touch a model.
22//!
23//! So this module is a public surface with two external readers plus the loop —
24//! it is not private to the loop.
25
26use futures::StreamExt as _;
27
28use crate::event::LlmStreamEvent;
29use crate::message::{AgentMessage, ContentBlock};
30use crate::streamfn::{LlmOpts, StreamFn};
31
32/// Auto-title once this many assistant turns exist (**W031 D2**). `0` = never
33/// auto-title. The CLI's `[session] title_after_turns` / `WCODE_TITLE_AFTER_TURNS`
34/// override it; absent config resolves to this.
35pub const DEFAULT_TITLE_AFTER_TURNS: usize = 2;
36
37/// Hard cap on a produced title, in **chars** (not bytes). A title is a name, so
38/// it is a hard cut with no ellipsis — see [`sanitize`].
39pub const TITLE_CLAMP: usize = 60;
40
41/// Per-half cap on the input excerpt (chars). §5.2: ~600 chars each ≈ 400 input
42/// tokens, ~15 out, requested ONCE per session. PRIVATE: this is an
43/// implementation detail of [`excerpt`]; nothing outside the module reads it.
44/// (A `pub` const here would invite a caller to "configure" the titler's input,
45/// which is exactly the unbounded-cost path §5.2 rejects.)
46const TITLE_INPUT_CHARS: usize = 600;
47
48/// The prefix [`AgentMessage::summary`] stamps on a compaction summary.
49///
50/// RE-EXPORTED, not owned: it lives in `crates/wcode-harness/src/message.rs` as
51/// `pub const COMPACTION_SUMMARY_PREFIX`, used by `AgentMessage::summary`'s own
52/// `format!` — one literal, one source. A summary is an ordinary `User` message
53/// with no distinguishing field, so the prefix IS the discriminator, and a second
54/// copy here would drift from the string the parser matches on.
55///
56/// Consumers: [`excerpt`] below (a resumed `ctx` starts with a summary —
57/// `Session::messages` prepends one) and the CLI's picker (`main.rs`,
58/// `first_user_line_of`). See W031 §2: without the skip, every compacted
59/// session's picker row reads "Summary of the earlier conversation
60/// (auto-generated by compaction):".
61pub use crate::message::COMPACTION_SUMMARY_PREFIX;
62
63/// System prompt for the titler. An **instruction-following** probe (§9.1): the
64/// live check must run against the configured model, not a small local one, or a
65/// failure here is indistinguishable from a bug in [`sanitize`].
66const TITLE_PROMPT: &str = "\
67You are naming a coding-agent session so its owner can find it again in a list of \
68sessions. Reply with the name only: 3-6 words, no quotes, no trailing period, no \
69'Title:' prefix, nothing else. Name what the session is ABOUT (its subject or \
70goal), not what was said most recently. Prefer concrete nouns and file or \
71component names that appear in the text.";
72
73/// Ask the model for a session title and return the sanitized result.
74///
75/// The single model call is shaped exactly like
76/// [`crate::compaction::summarize`] (`crates/wcode-harness/src/compaction.rs:628`):
77/// one `stream_fn` invocation with an EMPTY tool list, the [`TITLE_PROMPT`] as
78/// `system`, and one `AgentMessage::user_text` carrying both halves; then a fold
79/// of `LlmStreamEvent::TextDelta` → `String` until `Done` (or `Error`).
80///
81/// # Contract
82///
83/// * **Input.** The first user text and the first assistant text in `ctx`, each
84/// clamped to [`TITLE_INPUT_CHARS`], rendered as
85/// `USER:\n<…>\n\nASSISTANT:\n<…>` in ONE user message — the two halves the
86/// test name `generate_collects_text_and_sends_both_halves` asserts on.
87/// `ctx[0]` is SKIPPED when it is a compaction summary (it starts with
88/// [`COMPACTION_SUMMARY_PREFIX`]); see `Session::messages`
89/// (`crates/wcode-harness/src/session.rs:186-188`) which prepends one on a
90/// resume. Half-missing is not an error: a request with only the user half (an
91/// assistant turn that produced no text) still gets asked.
92/// * **Output.** `Ok(title)` where `title` is the output of [`sanitize`] — never
93/// empty, never longer than [`TITLE_CLAMP`] chars.
94/// * **Empty answer** (no `TextDelta` at all, only `Done`, or deltas that
95/// `sanitize` rejects): `Err("titler produced no text")`. Mirrors
96/// `summarize`'s `Err("summarizer produced no text")`
97/// (`compaction.rs:660-662`).
98/// * **Stream error** (`LlmStreamEvent::Error { message, .. }`): `Err(message)`
99/// verbatim, first error wins, the fold stops. Mirrors `compaction.rs:654`.
100/// * **No input at all** (no user text and no assistant text): `Err("no input to
101/// title from")` WITHOUT a model call — a request that cannot help is not sent.
102/// * **Does NOT promise:** a retry of its own (it inherits the adapter's
103/// retry/idle policy exactly as `summarize` does), a usage report (the caller
104/// does not bill it), a cancel path (v1: the call is bounded by the adapter's
105/// idle backstop, and the auto-trigger waits for it — D1's accepted cost), or
106/// that the model follows [`TITLE_PROMPT`] — that is [`sanitize`]'s job.
107/// * **Never panics.**
108pub async fn generate(
109 stream_fn: &StreamFn,
110 opts: &LlmOpts,
111 ctx: &[AgentMessage],
112) -> Result<String, String> {
113 let body = excerpt(ctx);
114 if body.is_empty() {
115 return Err("no input to title from".to_string());
116 }
117
118 let mut stream = stream_fn(&[AgentMessage::user_text(body)], TITLE_PROMPT, &[], opts);
119 let mut text = String::new();
120 while let Some(event) = stream.next().await {
121 match event {
122 LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
123 LlmStreamEvent::Done { .. } => break,
124 LlmStreamEvent::Error { message, .. } => return Err(message),
125 _ => {}
126 }
127 }
128
129 sanitize(&text).ok_or_else(|| "titler produced no text".to_string())
130}
131
132/// Reduce a raw model answer to a storable title, or reject it.
133///
134/// Pure, total, and network-free, so it is the part of this module that a unit
135/// test can pin exactly.
136///
137/// # The rules, in order (each one is load-bearing; the order is the contract)
138///
139/// 1. **Trim** leading/trailing whitespace.
140/// 2. **First line only** — everything from the first `\n` (or `\r`) on is
141/// dropped, so a chatty answer ("Sure!\n\nSession title: …") yields its first
142/// line, not a paragraph.
143/// 3. **Strip quotes and backticks** — remove every `"`, `'` and `` ` `` from
144/// what remains (altogether, not just at the ends: a model often returns
145/// `` `w031 session titles` ``). Interior apostrophes therefore go too; a
146/// title is not prose and "don't" reads fine as "dont" in a one-line label.
147/// 4. **Collapse whitespace** — every run of spaces/tabs becomes one space, so a
148/// tab-separated answer cannot break the single-line picker row.
149/// 5. **Trim again** (step 3/4 can expose new edge whitespace).
150/// 6. **Reject empty** — `None`, never `Some("")`. This is the ONE place an
151/// unusable answer is distinguished from a usable one; a caller that gets
152/// `None` must treat it as "no title", not as an error to surface.
153/// 7. **Clamp to [`TITLE_CLAMP`] chars**, on a char boundary, then trim once
154/// more. **No ellipsis**: a truncated name is still a name, and `…` would be
155/// stored in the transcript forever.
156///
157/// # Does not
158///
159/// * Strip a leading `Title:` / `Session:` label. Not in W031's rule list; a
160/// model that emits one keeps it (see the module friction note in the loop
161/// sketch). Adding it is a later, tested change.
162/// * Reject a long answer — it clamps instead.
163/// * Normalize case, punctuation, or emoji.
164///
165/// # Panics
166///
167/// Never. A string of any content (invalid UTF-8 cannot reach here: `&str`).
168pub fn sanitize(raw: &str) -> Option<String> {
169 // 1-2: trim, then keep only the first line.
170 let first_line = raw.trim().split(['\n', '\r']).next().unwrap_or("");
171 // 3: drop quotes and backticks outright, interior ones included.
172 let unquoted: String = first_line
173 .chars()
174 .filter(|c| !matches!(c, '"' | '\'' | '`'))
175 .collect();
176 // 4-5: collapse whitespace runs to one space, trim what that exposed.
177 let collapsed = unquoted.split_whitespace().collect::<Vec<_>>().join(" ");
178 let collapsed = collapsed.trim();
179 // 6: an unusable answer is `None`, never `Some("")`.
180 if collapsed.is_empty() {
181 return None;
182 }
183 // 7: hard cut on a char boundary, no ellipsis, then trim once more. The cut
184 // can only expose a trailing space (`collapsed` starts with a non-space), so
185 // it cannot resurrect the empty case rule 6 already rejected.
186 let clamped: String = collapsed.chars().take(TITLE_CLAMP).collect();
187 Some(clamped.trim().to_string())
188}
189
190/// Render the two halves of the titler's input: the first user text and the
191/// first assistant text in `ctx`, each clamped to [`TITLE_INPUT_CHARS`].
192///
193/// Skips a leading compaction summary. Empty string when neither half exists
194/// (the caller must not send a request for it).
195fn excerpt(ctx: &[AgentMessage]) -> String {
196 let mut body = String::new();
197 if let Some(user) = first_user_text(ctx) {
198 body.push_str("USER:\n");
199 body.push_str(&user);
200 }
201 if let Some(assistant) = first_assistant_text(ctx) {
202 if !body.is_empty() {
203 body.push_str("\n\n");
204 }
205 body.push_str("ASSISTANT:\n");
206 body.push_str(&assistant);
207 }
208 body
209}
210
211/// The first real user text in `ctx`, clamped. `None` when there is none, or
212/// when the only user message is the synthetic compaction summary.
213fn first_user_text(ctx: &[AgentMessage]) -> Option<String> {
214 for message in ctx {
215 if let AgentMessage::User { .. } = message {
216 let text = message.as_text();
217 let trimmed = text.trim();
218 if !trimmed.is_empty() && !trimmed.starts_with(COMPACTION_SUMMARY_PREFIX) {
219 return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
220 }
221 }
222 }
223 None
224}
225
226/// The first assistant `Text` block in `ctx` (any assistant message, in order),
227/// clamped. `None` when no assistant message carried text — a thinking-only
228/// answer yields `None` (the safe failure mode W031 §11.11 names).
229fn first_assistant_text(ctx: &[AgentMessage]) -> Option<String> {
230 for message in ctx {
231 let AgentMessage::Assistant { content, .. } = message else {
232 continue;
233 };
234 for block in content {
235 if let ContentBlock::Text { text } = block {
236 let trimmed = text.trim();
237 if !trimmed.is_empty() {
238 return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
239 }
240 }
241 }
242 }
243 None
244}
245
246/// Hard-cut `text` to at most `max` chars on a char boundary (never mid-codepoint).
247fn clamp_chars(text: &str, max: usize) -> String {
248 if text.chars().count() <= max {
249 return text.to_string();
250 }
251 text.chars().take(max).collect()
252}
253
254#[cfg(test)]
255mod tests {
256 use std::sync::Arc;
257
258 use crate::message::{ContentBlock, StopReason};
259
260 use super::*;
261
262 /// Every request the double was handed, as `(ctx, system_prompt)`.
263 type Calls = Arc<std::sync::Mutex<Vec<(Vec<AgentMessage>, String)>>>;
264
265 /// A `StreamFn` that replays `script` and records every request it was given
266 /// — the same double shape as `actor.rs`'s `Recorder`.
267 fn scripted(script: Vec<LlmStreamEvent>) -> (StreamFn, Calls) {
268 let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
269 let seen = Arc::clone(&calls);
270 let f: StreamFn = Arc::new(move |ctx, system, _tools, _opts| {
271 seen.lock()
272 .unwrap()
273 .push((ctx.to_vec(), system.to_string()));
274 Box::pin(futures::stream::iter(script.clone())) as crate::streamfn::LlmStream
275 });
276 (f, calls)
277 }
278
279 fn user(text: &str) -> AgentMessage {
280 AgentMessage::user_text(text)
281 }
282
283 fn assistant(text: &str) -> AgentMessage {
284 AgentMessage::Assistant {
285 content: vec![ContentBlock::Text { text: text.into() }],
286 stop_reason: StopReason::Stop,
287 usage: None,
288 model: None,
289 }
290 }
291
292 fn done() -> LlmStreamEvent {
293 LlmStreamEvent::Done {
294 stop_reason: StopReason::Stop,
295 usage: None,
296 }
297 }
298
299 /// GATE (W031 §6 step 2). Both halves reach the model: the captured request's
300 /// single user message contains the user text AND the assistant text, under
301 /// the `USER:` / `ASSISTANT:` headings, in one tool-free call whose `system`
302 /// is [`TITLE_PROMPT`].
303 #[tokio::test]
304 async fn generate_collects_text_and_sends_both_halves() {
305 let (f, calls) = scripted(vec![
306 LlmStreamEvent::TextDelta("retry".into()),
307 LlmStreamEvent::TextDelta(" backoff".into()),
308 done(),
309 ]);
310 let ctx = vec![user("add retry backoff"), assistant("done: 3 retries")];
311
312 let got = generate(&f, &LlmOpts::default(), &ctx).await.unwrap();
313 assert_eq!(got, "retry backoff", "the TextDeltas are folded");
314
315 let calls = calls.lock().unwrap();
316 assert_eq!(calls.len(), 1, "exactly one model call");
317 let (sent, system) = &calls[0];
318 assert_eq!(system, TITLE_PROMPT);
319 assert_eq!(sent.len(), 1, "one user message carries both halves");
320 let body = sent[0].as_text();
321 assert!(body.contains("USER:\nadd retry backoff"), "{body}");
322 assert!(body.contains("ASSISTANT:\ndone: 3 retries"), "{body}");
323 }
324
325 /// GATE (W031 §6 step 2) + amendment. An `excerpt` with nothing usable sends
326 /// NO request at all — the cheapest possible failure.
327 #[tokio::test]
328 async fn generate_refuses_an_empty_excerpt_without_a_call() {
329 let (f, calls) = scripted(vec![]);
330
331 assert_eq!(
332 generate(&f, &LlmOpts::default(), &[]).await.unwrap_err(),
333 "no input to title from"
334 );
335 // A resumed ctx whose only user message is the compaction summary has no
336 // real user half and no assistant half either.
337 let summary_only = vec![AgentMessage::summary("did 1..3")];
338 assert_eq!(
339 generate(&f, &LlmOpts::default(), &summary_only)
340 .await
341 .unwrap_err(),
342 "no input to title from"
343 );
344
345 assert!(
346 calls.lock().unwrap().is_empty(),
347 "no request for an empty excerpt"
348 );
349 }
350
351 /// GATE — the §2 bug, in the titler's half. A resumed `ctx` begins with
352 /// `AgentMessage::summary(..)`; the excerpt must start at the first REAL user
353 /// text, not at the summary's constant prefix.
354 #[test]
355 fn excerpt_skips_a_compaction_summary() {
356 let ctx = vec![
357 AgentMessage::summary("did 1..3"),
358 user("real question"),
359 assistant("answer"),
360 ];
361
362 let body = excerpt(&ctx);
363 assert!(
364 !body.contains(COMPACTION_SUMMARY_PREFIX),
365 "the synthetic summary must never reach the titler: {body}"
366 );
367 assert!(body.contains("real question"), "{body}");
368 assert!(body.contains("answer"), "{body}");
369 assert_eq!(body, "USER:\nreal question\n\nASSISTANT:\nanswer");
370 }
371
372 /// GATE (W031 §6 step 2). The sanitiser's whole rule list, one assertion per
373 /// rule: quotes/backticks stripped, first line only, whitespace collapsed,
374 /// clamped to exactly `TITLE_CLAMP` chars with no ellipsis.
375 #[test]
376 fn sanitize_strips_quotes_and_clamps() {
377 // Quotes and backticks, at the ends and interior; surrounding space goes.
378 assert_eq!(
379 sanitize(" `\"retry backoff\"` ").as_deref(),
380 Some("retry backoff")
381 );
382 assert_eq!(sanitize("don't").as_deref(), Some("dont"));
383 // First line only, trailing period kept (rule list does not strip it).
384 assert_eq!(
385 sanitize("Retry backoff.\nsecond line ignored").as_deref(),
386 Some("Retry backoff.")
387 );
388 // Whitespace runs collapse to one space.
389 assert_eq!(sanitize("a\t\tb c").as_deref(), Some("a b c"));
390 // The cap is a hard cut in chars, with no ellipsis.
391 let long = "x".repeat(TITLE_CLAMP + 20);
392 let got = sanitize(&long).unwrap();
393 assert_eq!(got.chars().count(), TITLE_CLAMP);
394 assert!(!got.ends_with('…'), "no ellipsis: the cap is a hard cut");
395 // A cut that would end on a space is trimmed (no trailing blank).
396 let padded = format!("{} tail", "y".repeat(TITLE_CLAMP - 1));
397 let got = sanitize(&padded).unwrap();
398 assert_eq!(got.chars().count(), TITLE_CLAMP - 1);
399 assert!(!got.ends_with(' '), "{got:?}");
400 }
401
402 /// GATE (W031 §6 step 2). An answer with no usable text is `None`, never an
403 /// empty title: whitespace-only, newlines-only, and quote-only all reject.
404 #[test]
405 fn sanitize_rejects_an_empty_or_multiline_only_answer() {
406 assert_eq!(sanitize(""), None);
407 assert_eq!(sanitize(" \n\t\n "), None);
408 assert_eq!(sanitize("\"\"``''"), None);
409 }
410
411 /// GATE (W031 §6 step 2). A stream error is surfaced verbatim as `Err`, not
412 /// swallowed into a `None` title — the caller (the loop) decides it is
413 /// non-fatal, this module does not pretend the answer was empty.
414 #[tokio::test]
415 async fn generate_surfaces_a_stream_error() {
416 let (f, calls) = scripted(vec![LlmStreamEvent::Error {
417 message: "boom".into(),
418 fatal: true,
419 }]);
420 let ctx = vec![user("q"), assistant("a")];
421
422 assert_eq!(
423 generate(&f, &LlmOpts::default(), &ctx).await.unwrap_err(),
424 "boom"
425 );
426 assert_eq!(calls.lock().unwrap().len(), 1);
427 }
428}