supercode-harness 0.4.6

The optional native Supercode agent and tool harness
Documentation
//! 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());
    }
}