Skip to main content

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}