Skip to main content

whycodes_session/
session.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use whycodes_core::types::{
4    ContentBlock, LlmRequest, Message, MessageContent, Role, SessionInfo, ToolDefinition,
5};
6
7use whycodes_core::tokens::estimate_tokens;
8
9use crate::error::Result;
10
11/// Soft cap on tool result bodies kept in the transcript (Unicode scalars).
12///
13/// Large dumps (test output, full file reads) dominate context; the agent can
14/// re-read files if it needs more. ~32 KiB ≈ 8k tokens under the char/4 heuristic.
15pub const TOOL_RESULT_MAX_CHARS: usize = 32_768;
16
17/// Cap for *older* tool results during compact (prune to ~2k chars).
18/// Recent tool dumps stay at [`TOOL_RESULT_MAX_CHARS`] so the model can still
19/// use the last step's output; older steps shrink hard for TTFT.
20pub const TOOL_RESULT_PRUNE_CHARS: usize = 2_048;
21/// Harder shake when the session is still over ~¾ of the compact threshold.
22pub const TOOL_RESULT_SHAKE_CHARS: usize = 512;
23/// What stays in the transcript when an old tool body is spilled to disk:
24/// this many leading chars, then a pointer line. Under the shake cap, so
25/// neither prune nor shake cuts the pointer away.
26pub const SPILL_HEAD_CHARS: usize = 400;
27/// Marks a spilled body; a body that carries it is not spilled again.
28pub const SPILL_MARK: &str = "full output saved: ";
29
30/// How many recent tool-role messages keep the large cap when pruning.
31const PRUNE_KEEP_RECENT_TOOLS: usize = 4;
32
33/// Minimum number of tail messages retained by [`Session::compact`].
34const MIN_KEEP_MESSAGES: usize = 4;
35
36/// Reserved heuristic tokens for the summary line prepended by compact.
37/// Raised so the richer local summary (goals + paths) fits without thrashing.
38const SUMMARY_TOKEN_SLACK: usize = 256;
39
40/// Max chars of dropped-message transcript retained for optional LLM summary.
41const DROPPED_TRANSCRIPT_MAX_CHARS: usize = 12_000;
42
43/// Whole-session transcript cap for full-replace compact.
44const FULL_TRANSCRIPT_MAX_CHARS: usize = 48_000;
45
46/// Preamble prepended to the post-compact summary carrier (full-replace).
47pub const COMPACT_CONTINUATION_PREAMBLE: &str = "This session is being continued from a previous conversation that ran out of context. \
48     The summary below covers the earlier portion of the conversation.";
49
50/// Outcome of [`Session::compact`] for autocompact circuit breakers.
51#[derive(Debug, Clone, Default)]
52pub struct CompactOutcome {
53    pub tokens_before: usize,
54    pub tokens_after: usize,
55    pub messages_before: usize,
56    pub messages_after: usize,
57    /// Text of messages that were dropped (capped). Empty when nothing trimmed.
58    /// Used by the agent LLM-compact path so the summary covers *history*, not
59    /// the already-kept tail.
60    pub dropped_transcript: String,
61}
62
63impl CompactOutcome {
64    /// True when compact dropped tokens or messages.
65    pub fn reduced(&self) -> bool {
66        self.tokens_after < self.tokens_before || self.messages_after < self.messages_before
67    }
68
69    /// Still above the autocompact threshold after the pass.
70    pub fn still_over(&self, threshold: usize) -> bool {
71        threshold > 0 && self.tokens_after > threshold
72    }
73
74    /// Failed to relieve pressure: still over threshold and no reduction.
75    pub fn failed(&self, threshold: usize) -> bool {
76        self.still_over(threshold) && !self.reduced()
77    }
78
79    /// True when at least one prefix message was removed (not just tool caps).
80    pub fn dropped_messages(&self) -> bool {
81        !self.dropped_transcript.is_empty()
82    }
83}
84
85fn message_tokens(msg: &Message) -> usize {
86    match &msg.content {
87        MessageContent::Text(t) => estimate_tokens(t),
88        MessageContent::Blocks(b) => b
89            .iter()
90            .map(|block| match block {
91                ContentBlock::Text { text } => estimate_tokens(text),
92                ContentBlock::ToolResult { content, .. } => estimate_tokens(content),
93                ContentBlock::ToolUse { name, input, .. } => {
94                    estimate_tokens(name) + estimate_tokens(&input.to_string())
95                }
96                ContentBlock::Image { .. } => 100,
97                ContentBlock::Thinking { text, signature } => {
98                    estimate_tokens(text)
99                        + signature.as_ref().map(|s| estimate_tokens(s)).unwrap_or(0)
100                }
101                ContentBlock::RedactedThinking { data } => estimate_tokens(data),
102                ContentBlock::Compaction { content, .. } => estimate_tokens(content),
103            })
104            .sum(),
105    }
106}
107
108/// Running token estimate kept index-aligned with `Session::messages`.
109///
110/// Skipped on serde so export JSON stays stable; rebuilt after load or when
111/// `valid` is false (deserialize default).
112#[derive(Debug, Clone, Default)]
113struct SessionTokenCache {
114    system: usize,
115    per_msg: Vec<usize>,
116    total: usize,
117    valid: bool,
118}
119
120impl SessionTokenCache {
121    fn from_parts(system_prompt: &str, messages: &[Message]) -> Self {
122        let system = estimate_tokens(system_prompt);
123        let per_msg: Vec<usize> = messages.iter().map(message_tokens).collect();
124        let total = system + per_msg.iter().sum::<usize>();
125        Self {
126            system,
127            per_msg,
128            total,
129            valid: true,
130        }
131    }
132
133    fn ensure(&mut self, system_prompt: &str, messages: &[Message]) {
134        if self.valid && self.per_msg.len() == messages.len() {
135            return;
136        }
137        *self = Self::from_parts(system_prompt, messages);
138    }
139
140    fn push_msg(&mut self, msg: &Message) {
141        if !self.valid {
142            return;
143        }
144        let t = message_tokens(msg);
145        self.per_msg.push(t);
146        self.total = self.total.saturating_add(t);
147    }
148
149    fn set_system(&mut self, system_prompt: &str) {
150        if !self.valid {
151            return;
152        }
153        let system = estimate_tokens(system_prompt);
154        self.total = self
155            .total
156            .saturating_sub(self.system)
157            .saturating_add(system);
158        self.system = system;
159    }
160
161    fn rebuild(&mut self, system_prompt: &str, messages: &[Message]) {
162        *self = Self::from_parts(system_prompt, messages);
163    }
164
165    #[cfg(test)]
166    fn invalidate(&mut self) {
167        self.valid = false;
168        self.per_msg.clear();
169        self.system = 0;
170        self.total = 0;
171    }
172}
173
174/// Parse `WHYCODES_IMAGE_B64:image/png\n<base64>` payloads from the read tool.
175fn split_whycodes_image_payload(content: &str) -> Option<(String, String, String)> {
176    const MARK: &str = "WHYCODES_IMAGE_B64:";
177    let idx = content.find(MARK)?;
178    let after = &content[idx + MARK.len()..];
179    let (media, rest) = after.split_once('\n')?;
180    let media = media.trim();
181    let b64 = rest.trim();
182    if media.is_empty() || b64.is_empty() {
183        return None;
184    }
185    let preface = content[..idx].trim().to_string();
186    let preface = if preface.is_empty() {
187        format!("[image {media}]")
188    } else {
189        preface
190    };
191    Some((media.to_string(), b64.to_string(), preface))
192}
193
194/// The transcript stand-in for a spilled body, or `None` when the body is
195/// short, already spilled, or `store` declined it.
196fn spill_body(
197    text: &str,
198    id: &str,
199    store: &mut dyn FnMut(&str, &str) -> Option<String>,
200) -> Option<String> {
201    if text.chars().count() <= TOOL_RESULT_PRUNE_CHARS || text.contains(SPILL_MARK) {
202        return None;
203    }
204    let pointer = store(id, text)?;
205    let head: String = text.chars().take(SPILL_HEAD_CHARS).collect();
206    let omitted = text.chars().count() - head.chars().count();
207    Some(format!(
208        "{head}\n[{omitted} more chars; {SPILL_MARK}{pointer}]"
209    ))
210}
211
212/// Cap a tool result string; no-op when already under the limit.
213fn cap_tool_text(text: String) -> String {
214    cap_tool_text_to(text, TOOL_RESULT_MAX_CHARS)
215}
216
217/// Footer paid on every capped tool dump. `{n}` is the omitted scalar count.
218///
219/// Kept short on purpose: this string sits in the model transcript, not just
220/// the TUI. cl100k encodes `\n[1302 chars truncated]` in 7 tokens versus 12
221/// for the older `characters truncated for context management` sentence.
222fn truncation_notice(omitted: usize) -> String {
223    format!("\n[{omitted} chars truncated]")
224}
225
226/// Split a trailing cap footer (current or legacy) from `text`.
227///
228/// Returns `(body, Some(omitted))` when a notice is present so a later, tighter
229/// cap can keep the original omitted count instead of reporting the footer
230/// itself as truncated payload.
231fn split_truncation_notice(text: &str) -> (&str, Option<usize>) {
232    const CURRENT: &str = " chars truncated]";
233    if let Some(rest) = text.strip_suffix(CURRENT)
234        && let Some(idx) = rest.rfind("\n[")
235    {
236        let num = &rest[idx + 2..];
237        if let Ok(n) = num.parse::<usize>() {
238            return (&text[..idx], Some(n));
239        }
240    }
241
242    const LEGACY: &str = " characters truncated for context management]";
243    if let Some(rest) = text.strip_suffix(LEGACY)
244        && let Some(idx) = rest.rfind("\n\n[... ")
245    {
246        let num = &rest[idx + 7..];
247        if let Ok(n) = num.parse::<usize>() {
248            return (&text[..idx], Some(n));
249        }
250    }
251
252    (text, None)
253}
254
255fn with_truncation_notice(text: String, body_len: usize, omitted: usize) -> String {
256    if omitted == 0 {
257        return text;
258    }
259    let notice = truncation_notice(omitted);
260    if text.len() == body_len + notice.len() && text.ends_with(&notice) {
261        return text;
262    }
263    let mut out = String::with_capacity(body_len + notice.len());
264    out.push_str(&text[..body_len]);
265    out.push_str(&notice);
266    out
267}
268
269fn cap_tool_text_to(text: String, max_chars: usize) -> String {
270    let (body, prior_omitted) = split_truncation_notice(&text);
271    let omitted_tail = prior_omitted.unwrap_or(0);
272    let body_len = body.len();
273
274    // Byte length is a cheap upper bound (UTF-8 ≥ 1 byte/scalar).
275    if omitted_tail == 0 && body_len <= max_chars {
276        return text;
277    }
278
279    let body_chars = if body.is_ascii() {
280        body_len
281    } else {
282        body.chars().count()
283    };
284
285    if body_chars <= max_chars {
286        return with_truncation_notice(text, body_len, omitted_tail);
287    }
288
289    let omitted = body_chars - max_chars + omitted_tail;
290    let mut out: String = body.chars().take(max_chars).collect();
291    out.push_str(&truncation_notice(omitted));
292    out
293}
294
295/// True when a user message is a compact stub, not a real user turn.
296pub fn is_compact_summary_text(text: &str) -> bool {
297    let t = text.trim_start();
298    t.starts_with("[Compacted") || t.starts_with("This session is being continued")
299}
300
301/// A user-role compact-summary carrier left by an earlier compaction.
302fn is_carrier(m: &Message) -> bool {
303    m.role == Role::User && m.content.as_text().is_some_and(is_compact_summary_text)
304}
305
306/// Clean a compaction-model reply into a plain summary body.
307pub fn format_compact_summary(raw: &str) -> String {
308    let mut result = raw.trim().to_string();
309
310    if let Some(start) = result.find("<analysis>")
311        && result[..start].trim().is_empty()
312    {
313        match result[start..].find("</analysis>") {
314            Some(rel) => {
315                result = result[start + rel + "</analysis>".len()..]
316                    .trim()
317                    .to_string();
318            }
319            None => {
320                let drop_to = result[start..]
321                    .find("<summary>")
322                    .map_or(result.len(), |rel| start + rel);
323                result = result[drop_to..].trim().to_string();
324            }
325        }
326    }
327
328    if let Some(start) = result.find("<summary>") {
329        if let Some(end) = result.rfind("</summary>")
330            && end > start
331        {
332            let inner = result[start + "<summary>".len()..end].trim();
333            result = format!("Summary:\n{inner}");
334        } else {
335            result = result.replacen("<summary>", "Summary:\n", 1);
336        }
337    }
338
339    while result.contains("\n\n\n") {
340        result = result.replace("\n\n\n", "\n\n");
341    }
342    result.trim().to_string()
343}
344
345/// Continuation carrier that replaces compacted history (full-replace).
346pub fn format_compact_summary_content(raw: &str) -> String {
347    let cleaned = format_compact_summary(raw);
348    if cleaned.is_empty() {
349        return String::new();
350    }
351    if cleaned.starts_with("This session is being continued") {
352        return cleaned;
353    }
354    format!("{COMPACT_CONTINUATION_PREAMBLE}\n\n{cleaned}")
355}
356
357/// Human-facing compact card: drop the model preamble, keep the summary body.
358pub fn compact_summary_display_text(text: &str) -> String {
359    let t = text.trim();
360    let mut rest = t;
361    for prefix in [
362        COMPACT_CONTINUATION_PREAMBLE,
363        "[Compacted earlier conversation]",
364    ] {
365        if let Some(stripped) = t.strip_prefix(prefix) {
366            rest = stripped.trim();
367            break;
368        }
369    }
370    if rest.is_empty() {
371        "Conversation compacted".into()
372    } else if rest.starts_with("Conversation compacted") {
373        rest.to_string()
374    } else {
375        format!("Conversation compacted\n{rest}")
376    }
377}
378
379fn summarize_trimmed(trimmed: &[Message]) -> String {
380    let mut users = 0usize;
381    let mut assistants = 0usize;
382    let mut tools = 0usize;
383    let mut other = 0usize;
384    let mut goals: Vec<String> = Vec::new();
385    let mut paths: Vec<String> = Vec::new();
386    let mut tool_names: Vec<String> = Vec::new();
387
388    for m in trimmed {
389        match m.role {
390            Role::User => {
391                users += 1;
392                if goals.len() < 3
393                    && let Some(t) = m.content.as_text()
394                {
395                    let snippet = first_line_snippet(t, 120);
396                    if !snippet.is_empty()
397                        && !snippet.starts_with('[')
398                        && !goals.iter().any(|g| g == &snippet)
399                    {
400                        goals.push(snippet);
401                    }
402                }
403            }
404            Role::Assistant => {
405                assistants += 1;
406                collect_paths_from_message(m, &mut paths, 8);
407            }
408            Role::Tool => {
409                tools += 1;
410                if let Some(name) = m.name.as_deref()
411                    && tool_names.len() < 8
412                    && !tool_names.iter().any(|n| n == name)
413                {
414                    tool_names.push(name.to_string());
415                }
416                collect_paths_from_message(m, &mut paths, 8);
417            }
418            Role::System => other += 1,
419        }
420    }
421
422    let mut out = format!(
423        "[{n} earlier messages trimmed for context management \
424         (user={users}, assistant={assistants}, tool={tools}{other_part})]",
425        n = trimmed.len(),
426        other_part = if other > 0 {
427            format!(", other={other}")
428        } else {
429            String::new()
430        }
431    );
432    if !goals.is_empty() {
433        out.push_str("\nGoals: ");
434        out.push_str(&goals.join(" | "));
435    }
436    if !paths.is_empty() {
437        out.push_str("\nFiles: ");
438        out.push_str(&paths.join(", "));
439    }
440    if !tool_names.is_empty() {
441        out.push_str("\nTools used: ");
442        out.push_str(&tool_names.join(", "));
443    }
444    out
445}
446
447fn first_line_snippet(text: &str, max_chars: usize) -> String {
448    let line = text
449        .lines()
450        .map(str::trim)
451        .find(|l| !l.is_empty())
452        .unwrap_or("");
453    if line.chars().count() <= max_chars {
454        return line.to_string();
455    }
456    let mut s: String = line.chars().take(max_chars.saturating_sub(1)).collect();
457    s.push('…');
458    s
459}
460
461/// Pull a few path-like tokens from message text / tool dumps for local summary.
462fn collect_paths_from_message(m: &Message, paths: &mut Vec<String>, max: usize) {
463    if paths.len() >= max {
464        return;
465    }
466    let text = match &m.content {
467        MessageContent::Text(t) => t.as_str(),
468        MessageContent::Blocks(blocks) => {
469            for b in blocks {
470                match b {
471                    ContentBlock::Text { text }
472                    | ContentBlock::ToolResult { content: text, .. } => {
473                        push_paths_from_text(text, paths, max)
474                    }
475                    ContentBlock::ToolUse { input, .. } => {
476                        if let Some(p) = input.get("path").and_then(|v| v.as_str()) {
477                            push_path(p, paths, max);
478                        }
479                    }
480                    _ => {}
481                }
482                if paths.len() >= max {
483                    return;
484                }
485            }
486            return;
487        }
488    };
489    push_paths_from_text(text, paths, max);
490}
491
492fn push_paths_from_text(text: &str, paths: &mut Vec<String>, max: usize) {
493    // Prefer header lines from the `read` tool: `# path/to/file`
494    for line in text.lines().take(40) {
495        if paths.len() >= max {
496            return;
497        }
498        let t = line.trim();
499        if let Some(rest) = t.strip_prefix("# ") {
500            let candidate = rest.split_whitespace().next().unwrap_or("");
501            if looks_like_path(candidate) {
502                push_path(candidate, paths, max);
503            }
504        }
505    }
506    // Fallback: scan tokens with a path separator + extension-ish shape.
507    for token in text.split_whitespace().take(200) {
508        if paths.len() >= max {
509            return;
510        }
511        let tok = token.trim_matches(|c: char| {
512            matches!(c, ',' | ';' | ')' | '(' | '"' | '\'' | '`' | '[' | ']')
513        });
514        if looks_like_path(tok) {
515            push_path(tok, paths, max);
516        }
517    }
518}
519
520fn looks_like_path(s: &str) -> bool {
521    if s.len() < 3 || s.len() > 200 {
522        return false;
523    }
524    if s.starts_with("http://") || s.starts_with("https://") {
525        return false;
526    }
527    let has_sep = s.contains('/') || s.contains('\\');
528    let has_dot = s.contains('.');
529    has_sep && has_dot && !s.contains("://")
530}
531
532fn push_path(path: &str, paths: &mut Vec<String>, max: usize) {
533    if paths.len() >= max || path.is_empty() {
534        return;
535    }
536    if paths.iter().any(|p| p == path) {
537        return;
538    }
539    paths.push(path.to_string());
540}
541
542/// Build a capped plain-text transcript from a message slice (for LLM compact).
543fn messages_transcript(messages: &[Message], max_chars: usize) -> String {
544    let mut out = String::new();
545    for m in messages {
546        let role = match m.role {
547            Role::User => "User",
548            Role::Assistant => "Assistant",
549            Role::Tool => "Tool",
550            Role::System => "System",
551        };
552        let body = match &m.content {
553            MessageContent::Text(t) => t.clone(),
554            MessageContent::Blocks(blocks) => blocks
555                .iter()
556                .filter_map(|b| match b {
557                    ContentBlock::Text { text } => Some(text.as_str()),
558                    ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
559                    ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
560                    ContentBlock::Compaction { content, .. } => Some(content.as_str()),
561                    _ => None,
562                })
563                .collect::<Vec<_>>()
564                .join("\n"),
565        };
566        let piece = format!("{role}: {body}\n\n");
567        if out.len() + piece.len() > max_chars {
568            let remain = max_chars.saturating_sub(out.len());
569            if remain > 32 {
570                out.push_str(&piece.chars().take(remain).collect::<String>());
571                out.push_str("\n…");
572            }
573            break;
574        }
575        out.push_str(&piece);
576    }
577    out
578}
579
580/// A conversation session
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct Session {
583    pub id: String,
584    pub title: String,
585    /// How `title` was chosen. Gates auto-title so manual renames stick.
586    #[serde(default)]
587    pub title_source: crate::title::TitleSource,
588    /// The active path: the transcript shown and sent to the model.
589    pub messages: Vec<Message>,
590    /// Every other stored path of this conversation (`/branch`, `/tree`).
591    /// Compaction, spill and undo work on `messages` only.
592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
593    pub branches: Vec<crate::tree::Branch>,
594    pub system_prompt: String,
595    pub project_path: PathBuf,
596    pub created_at: chrono::DateTime<chrono::Utc>,
597    pub updated_at: chrono::DateTime<chrono::Utc>,
598    /// Token usage across every turn, as the provider reported it.
599    ///
600    /// `#[serde(default)]` so sessions exported before this existed still load.
601    #[serde(default)]
602    pub usage: whycodes_core::types::Usage,
603    /// Estimated USD for this run (`/cost`). Not stored in the database, so a
604    /// resumed session starts from zero.
605    #[serde(default)]
606    pub cost: SessionCost,
607    /// Lines added / removed by this session's `write` / `edit` /
608    /// `apply_patch` results (`/info`). Rebuilt from the transcript on load.
609    #[serde(default)]
610    pub edit_stats: crate::EditStats,
611    /// Incremental char/4 estimate (system + per-message). Not persisted.
612    #[serde(skip)]
613    token_cache: SessionTokenCache,
614    /// Active exploratory checkpoint (conversation only; not a file snapshot).
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub checkpoint: Option<CheckpointState>,
617    /// Last rewind report, used to reject a second rewind with no new checkpoint.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub last_rewind_report: Option<String>,
620    /// Model-facing context for the current user turn (memory recall).
621    /// Appended to the latest user message at request time, never stored in
622    /// `messages`, so the system prompt and history stay byte-stable for the
623    /// provider prompt cache. Not persisted.
624    #[serde(skip)]
625    pub turn_context: Option<String>,
626    /// Directories below the project whose instruction files (`AGENTS.md`
627    /// …) were already attached to a tool result this session. Not persisted:
628    /// a resumed session sees each once more.
629    #[serde(skip)]
630    pub instruction_dirs: std::collections::BTreeSet<PathBuf>,
631    /// Stream rules that fired this session (`repeat` limits). Not stored in
632    /// the database, so a resumed session may fire each `once` rule again.
633    #[serde(default, skip_serializing_if = "crate::StreamRuleLedger::is_empty")]
634    pub stream_rules: crate::StreamRuleLedger,
635    /// Requests by model and calls by tool for `/stats`. In memory only,
636    /// like `cost`: a resumed session counts from zero.
637    #[serde(skip)]
638    pub run_stats: crate::RunStats,
639}
640
641/// Running USD estimate. Steps whose model has no known price are counted,
642/// not guessed.
643#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
644pub struct SessionCost {
645    pub usd: f64,
646    pub unpriced_steps: u32,
647}
648
649impl SessionCost {
650    /// One step: `Some(usd)` when the model was priced, else counted as unpriced.
651    pub fn add(&mut self, usd: Option<f64>) {
652        match usd {
653            Some(c) => self.usd += c,
654            None => self.unpriced_steps += 1,
655        }
656    }
657
658    pub fn merge(&mut self, other: &SessionCost) {
659        self.usd += other.usd;
660        self.unpriced_steps += other.unpriced_steps;
661    }
662
663    pub fn is_empty(&self) -> bool {
664        self.usd == 0.0 && self.unpriced_steps == 0
665    }
666}
667
668#[cfg(test)]
669mod cost_tests {
670    use super::*;
671
672    #[test]
673    fn session_cost_adds_priced_steps_and_counts_the_rest() {
674        let mut c = SessionCost::default();
675        assert!(c.is_empty());
676        c.add(Some(0.25));
677        c.add(None);
678        assert!(!c.is_empty());
679        let mut total = SessionCost::default();
680        total.add(Some(1.0));
681        total.merge(&c);
682        assert_eq!(
683            total,
684            SessionCost {
685                usd: 1.25,
686                unpriced_steps: 1
687            }
688        );
689    }
690
691    #[test]
692    fn sessions_without_a_cost_field_still_load() {
693        let s = Session::new("/tmp/p".into(), "sys".into());
694        let mut v = serde_json::to_value(&s).unwrap();
695        v.as_object_mut().unwrap().remove("cost");
696        let back: Session = serde_json::from_value(v).unwrap();
697        assert!(back.cost.is_empty());
698    }
699}
700
701/// Boundary recorded by the `checkpoint` tool for a later `rewind`.
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
703pub struct CheckpointState {
704    /// Inclusive last message index to keep when rewinding.
705    pub keep_until: usize,
706    pub goal: String,
707    pub started_at: chrono::DateTime<chrono::Utc>,
708}
709
710impl Session {
711    /// Build a session from imported messages (new id, current project).
712    pub fn from_imported(
713        project_path: PathBuf,
714        messages: Vec<whycodes_core::types::Message>,
715        title_hint: Option<&str>,
716    ) -> Self {
717        let mut s = Self::new(project_path, String::new());
718        s.messages = messages;
719        if let Some(t) = title_hint {
720            s.set_title_manual(t);
721        } else {
722            let first = s
723                .messages
724                .iter()
725                .find_map(|m| {
726                    (m.role == whycodes_core::types::Role::User)
727                        .then(|| m.content.as_text().map(str::to_string))
728                })
729                .flatten();
730            if let Some(first) = first {
731                s.apply_heuristic_title(&first);
732            }
733        }
734        s.token_cache = SessionTokenCache::from_parts(&s.system_prompt, &s.messages);
735        s
736    }
737
738    /// Create a new session
739    pub fn new(project_path: PathBuf, system_prompt: String) -> Self {
740        let now = chrono::Utc::now();
741        let id = uuid::Uuid::new_v4().to_string();
742        let title = crate::title::default_title(&project_path, &id);
743        let token_cache = SessionTokenCache::from_parts(&system_prompt, &[]);
744        Self {
745            id,
746            title,
747            title_source: crate::title::TitleSource::Default,
748            messages: Vec::new(),
749            branches: Vec::new(),
750            system_prompt,
751            project_path,
752            created_at: now,
753            updated_at: now,
754            usage: Default::default(),
755            cost: SessionCost::default(),
756            edit_stats: Default::default(),
757            token_cache,
758            checkpoint: None,
759            last_rewind_report: None,
760            turn_context: None,
761            instruction_dirs: Default::default(),
762            stream_rules: Default::default(),
763            run_stats: Default::default(),
764        }
765    }
766
767    /// Copy of this conversation under a new id, so it can diverge while the
768    /// original stays as saved. Usage restarts at zero (the history's tokens
769    /// were spent by the original); the title gets a `(fork)` suffix.
770    pub fn fork(&self) -> Self {
771        let now = chrono::Utc::now();
772        let mut forked = self.clone();
773        forked.id = uuid::Uuid::new_v4().to_string();
774        forked.title = crate::title::sanitize_title(&format!("{} (fork)", self.title));
775        forked.created_at = now;
776        forked.updated_at = now;
777        forked.usage = Default::default();
778        forked
779    }
780
781    /// User-facing rename; locks the title against further auto updates.
782    pub fn set_title_manual(&mut self, title: impl Into<String>) {
783        let t = crate::title::sanitize_title(&title.into());
784        if t.is_empty() {
785            return;
786        }
787        self.title = t;
788        self.title_source = crate::title::TitleSource::Manual;
789        self.touch();
790    }
791
792    /// Instant offline title from the first user prompt (if still default).
793    ///
794    /// Returns `true` when the title changed.
795    pub fn apply_heuristic_title(&mut self, first_user_text: &str) -> bool {
796        if !self.title_source.allows_heuristic() {
797            return false;
798        }
799        let t = crate::title::heuristic_title(first_user_text);
800        if t.is_empty() || t == self.title {
801            return false;
802        }
803        self.title = t;
804        self.title_source = crate::title::TitleSource::Heuristic;
805        self.touch();
806        true
807    }
808
809    /// Upgrade a placeholder title from the transcript's first user message.
810    ///
811    /// Used on resume and when listing sessions so legacy `New session - …`
812    /// rows (and unfinished `project-ab` placeholders) pick up a real name
813    /// without waiting for another turn.
814    ///
815    /// Returns `true` when the title changed.
816    pub fn maybe_upgrade_title_from_history(&mut self) -> bool {
817        let Some(text) = self.first_user_text() else {
818            return false;
819        };
820        self.apply_heuristic_title(&text)
821    }
822
823    /// Apply a small-model title when still auto-titleable.
824    ///
825    /// Returns `true` when the title changed.
826    pub fn apply_generated_title(&mut self, title: impl Into<String>) -> bool {
827        if !self.title_source.allows_llm() {
828            return false;
829        }
830        let t = crate::title::sanitize_title(&title.into());
831        if t.is_empty() || t == self.title {
832            // Mark generated even on no-op so we do not keep re-calling the model.
833            if t == self.title && !t.is_empty() {
834                self.title_source = crate::title::TitleSource::Generated;
835            }
836            return false;
837        }
838        self.title = t;
839        self.title_source = crate::title::TitleSource::Generated;
840        self.touch();
841        true
842    }
843
844    /// Count of user-role messages (used to gate first-turn auto-title).
845    pub fn user_message_count(&self) -> usize {
846        self.messages
847            .iter()
848            .filter(|m| m.role == Role::User)
849            .count()
850    }
851
852    /// Text of the first user message, if any.
853    pub fn first_user_text(&self) -> Option<String> {
854        self.messages
855            .iter()
856            .find(|m| m.role == Role::User)
857            .and_then(|m| m.content.as_text().map(str::to_owned))
858            .filter(|s| !s.trim().is_empty())
859    }
860
861    /// Short assistant excerpt for title refine (first text block, capped).
862    pub fn first_assistant_snippet(&self, max_chars: usize) -> Option<String> {
863        for m in &self.messages {
864            if m.role != Role::Assistant {
865                continue;
866            }
867            let text = match &m.content {
868                MessageContent::Text(t) => t.clone(),
869                MessageContent::Blocks(blocks) => blocks
870                    .iter()
871                    .filter_map(|b| match b {
872                        ContentBlock::Text { text } => Some(text.as_str()),
873                        _ => None,
874                    })
875                    .collect::<Vec<_>>()
876                    .join(" "),
877            };
878            let trimmed = text.trim();
879            if trimmed.is_empty() {
880                continue;
881            }
882            let snippet: String = trimmed.chars().take(max_chars).collect();
883            return Some(snippet);
884        }
885        None
886    }
887
888    /// Prompts the user sent: user-role text, minus agent notes and
889    /// compaction summaries.
890    pub fn prompt_count(&self) -> usize {
891        self.messages
892            .iter()
893            .filter(|m| m.role == Role::User && !m.is_agent_note())
894            .filter(|m| match &m.content {
895                MessageContent::Text(t) => !t.trim().is_empty() && !is_compact_summary_text(t),
896                MessageContent::Blocks(blocks) => blocks.iter().any(|b| {
897                    matches!(b, ContentBlock::Text { text } if !text.trim().is_empty())
898                        || matches!(b, ContentBlock::Image { .. })
899                }),
900            })
901            .count()
902    }
903
904    /// Fold a turn''s reported usage into the session total.
905    pub fn add_usage(&mut self, usage: &whycodes_core::types::Usage) {
906        self.usage.add(usage);
907        self.touch();
908    }
909
910    /// Fold the line counts of file-mutating tool results into
911    /// [`Self::edit_stats`]; each result is matched to its call by id.
912    pub fn record_edit_results(
913        &mut self,
914        calls: &[whycodes_core::types::ToolCall],
915        results: &[whycodes_core::types::ToolResult],
916    ) {
917        for result in results {
918            if result.is_error {
919                continue;
920            }
921            if let Some(call) = calls.iter().find(|c| c.id == result.tool_call_id) {
922                self.edit_stats.record(&call.name, &result.content);
923            }
924        }
925    }
926
927    /// Add a user message
928    pub fn add_user_message(&mut self, content: &str) {
929        self.push_user_text(content, None);
930    }
931
932    /// Append a user-role note the agent wrote itself (recovery hint,
933    /// reminder, job notice). Compaction never treats it as the user's turn.
934    pub fn add_agent_note(&mut self, content: &str) {
935        let name = whycodes_core::types::AGENT_NOTE_NAME.to_string();
936        self.push_user_text(content, Some(name));
937    }
938
939    fn push_user_text(&mut self, content: &str, name: Option<String>) {
940        let msg = Message {
941            role: Role::User,
942            content: MessageContent::Text(content.to_string()),
943            tool_call_id: None,
944            name,
945            created_at: None,
946        }
947        .stamp();
948        self.token_cache.push_msg(&msg);
949        self.messages.push(msg);
950        self.touch();
951    }
952
953    /// Add a user message with structured content blocks (text + images, etc.).
954    pub fn add_user_message_blocks(&mut self, blocks: Vec<ContentBlock>) {
955        let msg = Message {
956            role: Role::User,
957            content: MessageContent::Blocks(blocks),
958            tool_call_id: None,
959            name: None,
960            created_at: None,
961        }
962        .stamp();
963        self.token_cache.push_msg(&msg);
964        self.messages.push(msg);
965        self.touch();
966    }
967
968    /// Add an assistant message with content blocks
969    pub fn add_assistant_message(&mut self, blocks: Vec<ContentBlock>) {
970        let msg = Message {
971            role: Role::Assistant,
972            content: MessageContent::Blocks(blocks),
973            tool_call_id: None,
974            name: None,
975            created_at: None,
976        }
977        .stamp();
978        self.token_cache.push_msg(&msg);
979        self.messages.push(msg);
980        self.touch();
981    }
982
983    /// Add tool results (oversized bodies are capped for context economy).
984    pub fn add_tool_results(&mut self, results: Vec<whycodes_core::types::ToolResult>) {
985        for result in results {
986            let content = if let Some((media, b64, preface)) =
987                split_whycodes_image_payload(&result.content)
988            {
989                // Vision path: short text + image block (A6).
990                MessageContent::Blocks(vec![
991                    ContentBlock::Text {
992                        text: cap_tool_text(preface),
993                    },
994                    ContentBlock::Image {
995                        source: whycodes_core::types::ImageSource::Base64 {
996                            media_type: media,
997                            data: b64,
998                        },
999                    },
1000                ])
1001            } else {
1002                MessageContent::Text(cap_tool_text(result.content))
1003            };
1004            let tool_call_id = if result.tool_call_id.is_empty() {
1005                whycodes_core::fallback_tool_call_id(self.messages.len())
1006            } else {
1007                result.tool_call_id
1008            };
1009            let msg = Message {
1010                role: Role::Tool,
1011                content,
1012                tool_call_id: Some(tool_call_id),
1013                name: None,
1014                created_at: None,
1015            }
1016            .stamp();
1017            self.token_cache.push_msg(&msg);
1018            self.messages.push(msg);
1019        }
1020        self.touch();
1021    }
1022
1023    /// Build an LLM request from the current conversation
1024    pub fn build_request(
1025        &self,
1026        tools: impl Into<std::sync::Arc<[ToolDefinition]>>,
1027        max_tokens: Option<u32>,
1028        temperature: Option<f32>,
1029        _thinking: Option<bool>,
1030    ) -> LlmRequest {
1031        // Repair an unpaired tool call / result in the request copy only;
1032        // a paired transcript (the common case) is not copied.
1033        let messages = match crate::pairing::paired_messages(&self.messages) {
1034            Some(fixed) => std::sync::Arc::from(fixed),
1035            None => std::sync::Arc::from(self.messages.as_slice()),
1036        };
1037        LlmRequest {
1038            system: self.system_prompt.clone(),
1039            messages,
1040            tools: tools.into(),
1041            max_tokens,
1042            temperature,
1043            top_p: None,
1044            top_k: None,
1045            stop_sequences: None,
1046            thinking: None,
1047            use_prompt_cache: true,
1048        }
1049    }
1050
1051    /// Get session info
1052    pub fn info(&self) -> SessionInfo {
1053        SessionInfo {
1054            id: self.id.clone(),
1055            title: self.title.clone(),
1056            created_at: self.created_at,
1057            updated_at: self.updated_at,
1058            message_count: self.messages.len(),
1059            project_path: self.project_path.clone(),
1060        }
1061    }
1062
1063    /// Exit summary printed after the terminal is restored.
1064    ///
1065    /// `model_label` is typically `provider/model`. `binary` is the CLI name
1066    /// used in the resume hint (e.g. `"whycodes"`).
1067    pub fn format_exit_summary(
1068        &self,
1069        duration: std::time::Duration,
1070        model_label: &str,
1071        binary: &str,
1072    ) -> String {
1073        // Labels padded to 8 so values line up.
1074        let mut out = String::from("\nSession Summary\n");
1075        out.push_str(&format!("  {:8}  {}\n", "ID", self.id));
1076        out.push_str(&format!("  {:8}  {}s\n", "Duration", duration.as_secs()));
1077        out.push_str(&format!("  {:8}  {}\n", "Model", model_label));
1078        out.push_str(&format!("  {:8}  {}\n", "CWD", self.project_path.display()));
1079        out.push_str(&format!("  {:8}  {}\n", "Messages", self.messages.len()));
1080        out.push_str(&format!(
1081            "  {:8}  {} --resume {}\n",
1082            "Continue", binary, self.id
1083        ));
1084        out
1085    }
1086
1087    /// Get conversation as a readable string (for display)
1088    pub fn conversation_text(&self) -> String {
1089        let mut out = String::new();
1090        for msg in &self.messages {
1091            let role = match msg.role {
1092                Role::User => "User",
1093                Role::Assistant => "Assistant",
1094                Role::System => "System",
1095                Role::Tool => "Tool",
1096            };
1097            if let Some(text) = msg.content.as_text() {
1098                out.push_str(&format!("{}: {}\n", role, text));
1099            }
1100        }
1101        out
1102    }
1103
1104    /// Update the system prompt
1105    pub fn set_system_prompt(&mut self, prompt: &str) {
1106        self.system_prompt = prompt.to_string();
1107        self.token_cache.set_system(&self.system_prompt);
1108        self.touch();
1109    }
1110
1111    /// Estimate token count (Unicode chars / 4, same family as the LLM fallback).
1112    ///
1113    /// Not provider BPE — good enough for compaction thresholds and the context
1114    /// meter when usage has not been reported yet. O(1) when the running cache
1115    /// is valid; rebuilds after load or bulk mutation.
1116    pub fn token_count(&self) -> usize {
1117        if self.token_cache.valid && self.token_cache.per_msg.len() == self.messages.len() {
1118            return self.token_cache.total;
1119        }
1120        // `token_count` is `&self` on the hot path; rebuild without interior
1121        // mutability by recomputing. Callers that mutate should keep the cache
1122        // warm via push/rebuild helpers.
1123        let system = estimate_tokens(&self.system_prompt);
1124        system + self.messages.iter().map(message_tokens).sum::<usize>()
1125    }
1126
1127    /// Like [`token_count`] but refreshes the running cache when stale.
1128    ///
1129    /// Prefer this on mutating paths (agent loop) so a prune/shake that
1130    /// rebuilt the cache stays O(1) for the rest of the step.
1131    pub fn token_count_cached(&mut self) -> usize {
1132        self.token_cache.ensure(&self.system_prompt, &self.messages);
1133        self.token_cache.total
1134    }
1135
1136    fn rebuild_token_cache(&mut self) {
1137        self.token_cache
1138            .rebuild(&self.system_prompt, &self.messages);
1139    }
1140
1141    /// Cap oversized tool result bodies already in the transcript.
1142    ///
1143    /// Returns how many messages were modified.
1144    pub fn truncate_large_tool_results(&mut self) -> usize {
1145        let mut n = 0;
1146        for msg in &mut self.messages {
1147            if msg.role != Role::Tool {
1148                // Also shrink ToolResult blocks inside assistant content.
1149                if let MessageContent::Blocks(blocks) = &mut msg.content {
1150                    for block in blocks.iter_mut() {
1151                        if let ContentBlock::ToolResult { content, .. } = block {
1152                            let before = content.len();
1153                            *content = cap_tool_text(std::mem::take(content));
1154                            if content.len() != before {
1155                                n += 1;
1156                            }
1157                        }
1158                    }
1159                }
1160                continue;
1161            }
1162            match &mut msg.content {
1163                MessageContent::Text(t) => {
1164                    let before = t.len();
1165                    *t = cap_tool_text(std::mem::take(t));
1166                    if t.len() != before {
1167                        n += 1;
1168                    }
1169                }
1170                MessageContent::Blocks(blocks) => {
1171                    for block in blocks.iter_mut() {
1172                        if let ContentBlock::Text { text }
1173                        | ContentBlock::ToolResult { content: text, .. } = block
1174                        {
1175                            let before = text.len();
1176                            *text = cap_tool_text(std::mem::take(text));
1177                            if text.len() != before {
1178                                n += 1;
1179                            }
1180                        }
1181                    }
1182                }
1183            }
1184        }
1185        if n > 0 {
1186            self.rebuild_token_cache();
1187            self.touch();
1188        }
1189        n
1190    }
1191
1192    /// Compact the conversation toward a token budget.
1193    ///
1194    /// 1. Cap oversized tool results in place.
1195    /// 2. If still over `¾ · max_tokens`, drop oldest messages until under
1196    ///    budget or only [`MIN_KEEP_MESSAGES`] remain in the tail.
1197    /// 3. Prepend a short stub summary of what was dropped.
1198    ///
1199    /// When already under budget after tool caps, the message list is not
1200    /// reshuffled (avoids churn on `/compact` for small sessions).
1201    /// Shrink older tool outputs more aggressively.
1202    ///
1203    /// Keeps the last [`PRUNE_KEEP_RECENT_TOOLS`] tool-role messages at the
1204    /// normal cap; everything older is cut to [`TOOL_RESULT_PRUNE_CHARS`].
1205    pub fn prune_old_tool_results(&mut self) -> usize {
1206        let tool_indices: Vec<usize> = self
1207            .messages
1208            .iter()
1209            .enumerate()
1210            .filter(|(_, m)| m.role == Role::Tool)
1211            .map(|(i, _)| i)
1212            .collect();
1213        if tool_indices.len() <= PRUNE_KEEP_RECENT_TOOLS {
1214            return 0;
1215        }
1216        let keep_from = tool_indices.len() - PRUNE_KEEP_RECENT_TOOLS;
1217        let prune_set: std::collections::HashSet<usize> =
1218            tool_indices[..keep_from].iter().copied().collect();
1219        let mut n = 0;
1220        for (i, msg) in self.messages.iter_mut().enumerate() {
1221            if !prune_set.contains(&i) {
1222                continue;
1223            }
1224            match &mut msg.content {
1225                MessageContent::Text(t) => {
1226                    let before = t.len();
1227                    *t = cap_tool_text_to(std::mem::take(t), TOOL_RESULT_PRUNE_CHARS);
1228                    if t.len() != before {
1229                        n += 1;
1230                    }
1231                }
1232                MessageContent::Blocks(blocks) => {
1233                    for block in blocks.iter_mut() {
1234                        if let ContentBlock::Text { text }
1235                        | ContentBlock::ToolResult { content: text, .. } = block
1236                        {
1237                            let before = text.len();
1238                            *text = cap_tool_text_to(std::mem::take(text), TOOL_RESULT_PRUNE_CHARS);
1239                            if text.len() != before {
1240                                n += 1;
1241                            }
1242                        }
1243                    }
1244                }
1245            }
1246        }
1247        if n > 0 {
1248            self.rebuild_token_cache();
1249            self.touch();
1250        }
1251        n
1252    }
1253
1254    /// Shrink older tool results to [`TOOL_RESULT_SHAKE_CHARS`] when prune was
1255    /// not enough to relieve context pressure.
1256    pub fn shake_old_tool_results(&mut self) -> usize {
1257        let tool_indices: Vec<usize> = self
1258            .messages
1259            .iter()
1260            .enumerate()
1261            .filter(|(_, m)| m.role == Role::Tool)
1262            .map(|(i, _)| i)
1263            .collect();
1264        if tool_indices.len() <= PRUNE_KEEP_RECENT_TOOLS {
1265            return 0;
1266        }
1267        let keep_from = tool_indices.len() - PRUNE_KEEP_RECENT_TOOLS;
1268        let prune_set: std::collections::HashSet<usize> =
1269            tool_indices[..keep_from].iter().copied().collect();
1270        let mut n = 0;
1271        for (i, msg) in self.messages.iter_mut().enumerate() {
1272            if !prune_set.contains(&i) {
1273                continue;
1274            }
1275            match &mut msg.content {
1276                MessageContent::Text(t) => {
1277                    let before = t.len();
1278                    *t = cap_tool_text_to(std::mem::take(t), TOOL_RESULT_SHAKE_CHARS);
1279                    if t.len() != before {
1280                        n += 1;
1281                    }
1282                }
1283                MessageContent::Blocks(blocks) => {
1284                    for block in blocks.iter_mut() {
1285                        if let ContentBlock::Text { text }
1286                        | ContentBlock::ToolResult { content: text, .. } = block
1287                        {
1288                            let before = text.len();
1289                            *text = cap_tool_text_to(std::mem::take(text), TOOL_RESULT_SHAKE_CHARS);
1290                            if text.len() != before {
1291                                n += 1;
1292                            }
1293                        }
1294                    }
1295                }
1296            }
1297        }
1298        if n > 0 {
1299            self.rebuild_token_cache();
1300            self.touch();
1301        }
1302        n
1303    }
1304
1305    /// Move old tool bodies longer than [`TOOL_RESULT_PRUNE_CHARS`] out of
1306    /// the transcript through `store(id, body) -> Some(pointer)`: the message
1307    /// keeps the first [`SPILL_HEAD_CHARS`] chars plus a pointer line, so the
1308    /// model can `read` the full output instead of re-running the tool. The
1309    /// last [`PRUNE_KEEP_RECENT_TOOLS`] tool messages, short bodies, bodies
1310    /// already spilled and bodies `store` declines are left alone. Returns
1311    /// how many bodies were spilled.
1312    pub fn spill_old_tool_results(
1313        &mut self,
1314        store: &mut dyn FnMut(&str, &str) -> Option<String>,
1315    ) -> usize {
1316        let tool_indices: Vec<usize> = self
1317            .messages
1318            .iter()
1319            .enumerate()
1320            .filter(|(_, m)| m.role == Role::Tool)
1321            .map(|(i, _)| i)
1322            .collect();
1323        if tool_indices.len() <= PRUNE_KEEP_RECENT_TOOLS {
1324            return 0;
1325        }
1326        let keep_from = tool_indices.len() - PRUNE_KEEP_RECENT_TOOLS;
1327        let mut n = 0;
1328        for &i in &tool_indices[..keep_from] {
1329            let fallback_id = self.messages[i]
1330                .tool_call_id
1331                .clone()
1332                .unwrap_or_else(|| format!("msg-{i}"));
1333            match &mut self.messages[i].content {
1334                MessageContent::Text(t) => {
1335                    if let Some(spilled) = spill_body(t, &fallback_id, store) {
1336                        *t = spilled;
1337                        n += 1;
1338                    }
1339                }
1340                MessageContent::Blocks(blocks) => {
1341                    for block in blocks.iter_mut() {
1342                        let (text, id) = match block {
1343                            ContentBlock::ToolResult {
1344                                content,
1345                                tool_use_id,
1346                                ..
1347                            } => (content, tool_use_id.as_str()),
1348                            ContentBlock::Text { text } => (text, fallback_id.as_str()),
1349                            _ => continue,
1350                        };
1351                        if let Some(spilled) = spill_body(text, id, store) {
1352                            *text = spilled;
1353                            n += 1;
1354                        }
1355                    }
1356                }
1357            }
1358        }
1359        if n > 0 {
1360            self.rebuild_token_cache();
1361            self.touch();
1362        }
1363        n
1364    }
1365
1366    /// Insert or replace the leading compact summary user message (LLM path).
1367    pub fn prepend_compact_summary(&mut self, summary: &str) {
1368        let text = summary.trim();
1369        if text.is_empty() {
1370            return;
1371        }
1372        let body = if text.starts_with('[') {
1373            text.to_string()
1374        } else {
1375            format!("[Compacted earlier conversation]\n{text}")
1376        };
1377        if let Some(first) = self.messages.first_mut()
1378            && first.role == Role::User
1379            && let MessageContent::Text(t) = &first.content
1380            && is_compact_summary_text(t)
1381        {
1382            first.content = MessageContent::Text(body);
1383            self.rebuild_token_cache();
1384            self.touch();
1385            return;
1386        }
1387        self.messages.insert(
1388            0,
1389            Message {
1390                role: Role::User,
1391                content: MessageContent::Text(body),
1392                tool_call_id: None,
1393                name: None,
1394                created_at: None,
1395            }
1396            .stamp(),
1397        );
1398        self.rebuild_token_cache();
1399        self.touch();
1400    }
1401
1402    /// Concatenate older messages for LLM summarization (capped).
1403    ///
1404    /// Prefer [`CompactOutcome::dropped_transcript`] from a just-run
1405    /// [`Session::compact`] — that captures the *actual* dropped prefix.
1406    /// This helper is a fallback when no drop has happened yet.
1407    pub fn transcript_for_compact_summary(&self, max_chars: usize) -> String {
1408        let keep_tail = MIN_KEEP_MESSAGES.min(self.messages.len());
1409        let end = self.messages.len().saturating_sub(keep_tail);
1410        messages_transcript(&self.messages[..end], max_chars)
1411    }
1412
1413    /// Whole-session transcript for full-replace compact.
1414    pub fn transcript_for_full_summary(&self, max_chars: usize) -> String {
1415        let cap = if max_chars == 0 {
1416            FULL_TRANSCRIPT_MAX_CHARS
1417        } else {
1418            max_chars
1419        };
1420        messages_transcript(&self.messages, cap)
1421    }
1422
1423    /// Where [`Self::apply_full_replace`] would cut now: the index of the
1424    /// last real user turn, or `None` when there is none.
1425    pub fn compaction_prefix_end(&self) -> Option<usize> {
1426        self.last_real_user_index()
1427    }
1428
1429    /// The messages a full-replace compaction would summarize (everything
1430    /// before the last real user turn), rendered like
1431    /// [`Self::transcript_for_full_summary`], with the index that bounds
1432    /// them. `None` when nothing precedes that turn.
1433    pub fn prefix_transcript_for_summary(&self) -> Option<(usize, String)> {
1434        let end = self.last_real_user_index().filter(|&end| end > 0)?;
1435        Some((
1436            end,
1437            messages_transcript(&self.messages[..end], FULL_TRANSCRIPT_MAX_CHARS),
1438        ))
1439    }
1440
1441    /// Index of the last real user turn (skips compact-summary carriers and
1442    /// notes the agent wrote itself).
1443    fn last_real_user_index(&self) -> Option<usize> {
1444        self.messages.iter().enumerate().rev().find_map(|(i, m)| {
1445            if m.role != Role::User || m.is_agent_note() {
1446                return None;
1447            }
1448            match &m.content {
1449                MessageContent::Text(t) if is_compact_summary_text(t) => None,
1450                _ => Some(i),
1451            }
1452        })
1453    }
1454
1455    /// Local stub used when the compact LLM is off or fails.
1456    pub fn local_full_replace_summary(&self) -> String {
1457        let end = self.last_real_user_index().unwrap_or(self.messages.len());
1458        let slice = if end == 0 {
1459            &self.messages[..]
1460        } else {
1461            &self.messages[..end]
1462        };
1463        summarize_trimmed(slice)
1464    }
1465
1466    /// Replace history with last user query + recent tail + summary carrier.
1467    ///
1468    /// Full-replace: the model-visible conversation becomes
1469    /// `[last_user?, recent…, continuation_summary]`. System prompt stays
1470    /// on the session, not in `messages`.
1471    pub fn apply_full_replace(&mut self, summary: &str) -> CompactOutcome {
1472        let tokens_before = self.token_count_cached();
1473        let messages_before = self.messages.len();
1474        if self.messages.is_empty() {
1475            return CompactOutcome {
1476                tokens_before,
1477                tokens_after: tokens_before,
1478                messages_before,
1479                messages_after: 0,
1480                dropped_transcript: String::new(),
1481            };
1482        }
1483
1484        let last_idx = self.last_real_user_index();
1485        let prefix_end = last_idx.unwrap_or(self.messages.len());
1486        let dropped_transcript =
1487            messages_transcript(&self.messages[..prefix_end], DROPPED_TRANSCRIPT_MAX_CHARS);
1488        let last_user_query =
1489            last_idx.and_then(|i| self.messages[i].content.as_text().map(str::to_string));
1490        // An earlier pass's carrier is superseded by the one appended below.
1491        let recent: Vec<Message> = last_idx
1492            .map(|i| {
1493                self.messages[i + 1..]
1494                    .iter()
1495                    .filter(|m| !is_carrier(m))
1496                    .cloned()
1497                    .collect()
1498            })
1499            .unwrap_or_default();
1500
1501        let mut body = format_compact_summary_content(summary);
1502        if body.is_empty() {
1503            body = format_compact_summary_content(&self.local_full_replace_summary());
1504        }
1505
1506        let mut new_messages = Vec::with_capacity(1 + recent.len() + 1);
1507        if let Some(q) = last_user_query {
1508            new_messages.push(
1509                Message {
1510                    role: Role::User,
1511                    content: MessageContent::Text(q),
1512                    tool_call_id: None,
1513                    name: None,
1514                    created_at: None,
1515                }
1516                .stamp(),
1517            );
1518        }
1519        new_messages.extend(recent);
1520        if !body.is_empty() {
1521            new_messages.push(
1522                Message {
1523                    role: Role::User,
1524                    content: MessageContent::Text(body),
1525                    tool_call_id: None,
1526                    name: None,
1527                    created_at: None,
1528                }
1529                .stamp(),
1530            );
1531        }
1532        self.messages = new_messages;
1533        self.token_cache
1534            .rebuild(&self.system_prompt, &self.messages);
1535        self.touch();
1536        CompactOutcome {
1537            tokens_before,
1538            tokens_after: self.token_count_cached(),
1539            messages_before,
1540            messages_after: self.messages.len(),
1541            dropped_transcript,
1542        }
1543    }
1544
1545    /// Where provider-native compaction would cut, with the messages it
1546    /// would summarize: everything before the last real user turn. `None`
1547    /// unless something precedes that turn and the message just before it
1548    /// is an assistant reply without a tool call: the summarized range must
1549    /// not end on an open call, and the first kept message (the user turn)
1550    /// must differ in role from the last summarized one, or the provider
1551    /// merges them and kept thinking no longer matches.
1552    pub fn native_compaction_prefix(&self) -> Option<(usize, Vec<Message>)> {
1553        let end = self.last_real_user_index().filter(|&end| end > 0)?;
1554        let last = &self.messages[end - 1];
1555        let open_call = matches!(&last.content, MessageContent::Blocks(blocks)
1556            if blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })));
1557        if last.role != Role::Assistant || open_call {
1558            return None;
1559        }
1560        let prefix = &self.messages[..end];
1561        let prefix = crate::pairing::paired_messages(prefix).unwrap_or_else(|| prefix.to_vec());
1562        Some((end, prefix))
1563    }
1564
1565    /// Replace the messages before `cut` (from
1566    /// [`Self::native_compaction_prefix`]) with one assistant message that
1567    /// holds the provider's compaction block. The kept turns follow it
1568    /// unchanged, thinking included, as the provider requires.
1569    pub fn apply_native_compaction(&mut self, cut: usize, block: ContentBlock) -> CompactOutcome {
1570        let tokens_before = self.token_count_cached();
1571        let messages_before = self.messages.len();
1572        let dropped_transcript =
1573            messages_transcript(&self.messages[..cut], DROPPED_TRANSCRIPT_MAX_CHARS);
1574        let mut messages = Vec::with_capacity(1 + messages_before - cut);
1575        messages.push(
1576            Message {
1577                role: Role::Assistant,
1578                content: MessageContent::Blocks(vec![block]),
1579                tool_call_id: None,
1580                name: None,
1581                created_at: None,
1582            }
1583            .stamp(),
1584        );
1585        messages.extend(self.messages.drain(cut..));
1586        self.messages = messages;
1587        self.token_cache
1588            .rebuild(&self.system_prompt, &self.messages);
1589        self.touch();
1590        CompactOutcome {
1591            tokens_before,
1592            tokens_after: self.token_count_cached(),
1593            messages_before,
1594            messages_after: self.messages.len(),
1595            dropped_transcript,
1596        }
1597    }
1598
1599    /// Full-replace compact without an LLM (local stub summary).
1600    pub fn compact_full_replace_local(&mut self) -> CompactOutcome {
1601        self.truncate_large_tool_results();
1602        self.prune_old_tool_results();
1603        if self.messages.is_empty() {
1604            let n = self.token_count_cached();
1605            return CompactOutcome {
1606                tokens_before: n,
1607                tokens_after: n,
1608                messages_before: 0,
1609                messages_after: 0,
1610                dropped_transcript: String::new(),
1611            };
1612        }
1613        let summary = self.local_full_replace_summary();
1614        self.apply_full_replace(&summary)
1615    }
1616
1617    pub fn compact(&mut self, max_tokens: usize) -> CompactOutcome {
1618        let tokens_before = self.token_count_cached();
1619        let messages_before = self.messages.len();
1620
1621        self.truncate_large_tool_results();
1622        self.prune_old_tool_results();
1623
1624        let target = max_tokens.saturating_mul(3) / 4;
1625        let target = target.max(1);
1626
1627        if self.token_count_cached() <= target {
1628            self.touch();
1629            return CompactOutcome {
1630                tokens_before,
1631                tokens_after: self.token_count_cached(),
1632                messages_before,
1633                messages_after: self.messages.len(),
1634                dropped_transcript: String::new(),
1635            };
1636        }
1637
1638        let min_keep = MIN_KEEP_MESSAGES.min(self.messages.len());
1639        // Find the smallest `start` such that messages[start..] fit the budget
1640        // (plus a small allowance for the summary line), never dropping below
1641        // `min_keep` tail messages.
1642        let mut start = 0usize;
1643        while self.messages.len() - start > min_keep {
1644            let tail = &self.messages[start..];
1645            let tail_tokens: usize = tail.iter().map(message_tokens).sum();
1646            let total = estimate_tokens(&self.system_prompt) + tail_tokens + SUMMARY_TOKEN_SLACK;
1647            if total <= target {
1648                break;
1649            }
1650            start += 1;
1651        }
1652
1653        if start == 0 {
1654            // Tail alone still over budget (or nothing to drop). Leave as-is;
1655            // tool caps already ran.
1656            self.touch();
1657            return CompactOutcome {
1658                tokens_before,
1659                tokens_after: self.token_count_cached(),
1660                messages_before,
1661                messages_after: self.messages.len(),
1662                dropped_transcript: String::new(),
1663            };
1664        }
1665
1666        let trimmed = &self.messages[..start];
1667        let dropped_transcript = messages_transcript(trimmed, DROPPED_TRANSCRIPT_MAX_CHARS);
1668        let summary = summarize_trimmed(trimmed);
1669        let mut new_messages = Vec::with_capacity(1 + self.messages.len() - start);
1670        new_messages.push(
1671            Message {
1672                role: Role::User,
1673                content: MessageContent::Text(summary),
1674                tool_call_id: None,
1675                name: None,
1676                created_at: None,
1677            }
1678            .stamp(),
1679        );
1680        new_messages.extend(self.messages[start..].iter().cloned());
1681        self.messages = new_messages;
1682        self.token_cache
1683            .rebuild(&self.system_prompt, &self.messages);
1684        self.touch();
1685        CompactOutcome {
1686            tokens_before,
1687            tokens_after: self.token_count_cached(),
1688            messages_before,
1689            messages_after: self.messages.len(),
1690            dropped_transcript,
1691        }
1692    }
1693
1694    /// Persist this session and all messages to the SQLite database.
1695    ///
1696    /// Session metadata (including provider-reported token usage) is upserted.
1697    /// Messages are replaced as a set so repeated saves do not duplicate rows.
1698    pub fn save_to_db(&self, db: &whycodes_storage::db::Database) -> Result<()> {
1699        db.upsert_session(
1700            &self.id,
1701            &self.title,
1702            &self.project_path.to_string_lossy(),
1703            &self.created_at.to_rfc3339(),
1704            &self.updated_at.to_rfc3339(),
1705            &self.usage,
1706        )?;
1707
1708        // Full replace in one transaction (honest counts; no half-written
1709        // rows): the active path and every stored branch commit together.
1710        let mut rows = Vec::with_capacity(self.messages.len());
1711        for msg in &self.messages {
1712            let msg_json = serde_json::to_string(msg)?;
1713            let role_str = serde_json::to_string(&msg.role)?
1714                .trim_matches('"')
1715                .to_string();
1716            let msg_id = uuid::Uuid::new_v4().to_string();
1717            let created = msg.created_at.unwrap_or_else(chrono::Utc::now).to_rfc3339();
1718            rows.push((
1719                msg_id,
1720                role_str,
1721                msg_json,
1722                msg.tool_call_id.clone(),
1723                msg.name.clone(),
1724                created,
1725            ));
1726        }
1727        let mut branches = Vec::with_capacity(self.branches.len());
1728        for b in &self.branches {
1729            let mut msgs = Vec::with_capacity(b.messages.len());
1730            for msg in &b.messages {
1731                msgs.push(serde_json::to_string(msg)?);
1732            }
1733            branches.push((b.name.clone(), msgs));
1734        }
1735        db.replace_transcript(&self.id, &rows, &branches)?;
1736
1737        Ok(())
1738    }
1739
1740    /// Load a session and its messages from the SQLite database by session id.
1741    pub fn load_from_db(db: &whycodes_storage::db::Database, id: &str) -> Result<Option<Self>> {
1742        let Some(row) = db.get_session(id)? else {
1743            return Ok(None);
1744        };
1745
1746        let created_at =
1747            chrono::DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&chrono::Utc);
1748        let updated_at =
1749            chrono::DateTime::parse_from_rfc3339(&row.updated_at)?.with_timezone(&chrono::Utc);
1750
1751        let message_rows = db.get_messages(id)?;
1752        let messages: Vec<whycodes_core::types::Message> = message_rows
1753            .iter()
1754            .map(|mr| {
1755                let mut msg: whycodes_core::types::Message = serde_json::from_str(&mr.content)?;
1756                if msg.created_at.is_none() {
1757                    msg.created_at = chrono::DateTime::parse_from_rfc3339(&mr.created_at)
1758                        .ok()
1759                        .map(|dt| dt.with_timezone(&chrono::Utc));
1760                }
1761                Ok::<_, crate::error::SessionError>(msg)
1762            })
1763            .collect::<Result<_>>()?;
1764        // A session saved before branches existed has none: one linear path.
1765        let mut branches = Vec::new();
1766        for b in db.get_branches(id)? {
1767            let mut msgs = Vec::with_capacity(b.messages.len());
1768            for json in &b.messages {
1769                msgs.push(serde_json::from_str(json)?);
1770            }
1771            branches.push(crate::tree::Branch {
1772                name: b.name,
1773                messages: msgs,
1774            });
1775        }
1776
1777        let project_path = std::path::PathBuf::from(row.project_path);
1778        let title_source = crate::title::infer_source_from_title(&row.title, &project_path);
1779        let token_cache = SessionTokenCache::from_parts("", &messages);
1780        let edit_stats = crate::EditStats::from_messages(&messages);
1781        Ok(Some(Self {
1782            id: row.id,
1783            title: row.title,
1784            title_source,
1785            messages,
1786            branches,
1787            system_prompt: String::new(), // system_prompt is not yet persisted
1788            project_path,
1789            created_at,
1790            updated_at,
1791            usage: row.usage,
1792            // Not stored yet: a resumed session's estimate starts at zero.
1793            cost: SessionCost::default(),
1794            edit_stats,
1795            token_cache,
1796            checkpoint: None,
1797            last_rewind_report: None,
1798            turn_context: None,
1799            instruction_dirs: Default::default(),
1800            stream_rules: Default::default(),
1801            run_stats: Default::default(),
1802        }))
1803    }
1804
1805    /// Record a conversation checkpoint after the current last message.
1806    pub fn mark_checkpoint(&mut self, goal: impl Into<String>) {
1807        let keep_until = self.messages.len().saturating_sub(1);
1808        self.checkpoint = Some(CheckpointState {
1809            keep_until,
1810            goal: goal.into(),
1811            started_at: chrono::Utc::now(),
1812        });
1813        self.last_rewind_report = None;
1814        self.touch();
1815    }
1816
1817    /// Collapse messages after the active checkpoint and keep `report`.
1818    ///
1819    /// Returns `false` when no checkpoint is active.
1820    pub fn apply_rewind(&mut self, report: &str) -> bool {
1821        let Some(cp) = self.checkpoint.take() else {
1822            return false;
1823        };
1824        let _removed = self.revert_to(cp.keep_until);
1825        let report = report.trim();
1826        let body = format!(
1827            "Checkpoint completed. Exploratory context was collapsed.\n\
1828             Goal: {}\n\nReport:\n{report}\n\n\
1829             Continue from this report. Do not call rewind again until you create a new checkpoint.",
1830            cp.goal
1831        );
1832        self.add_user_message(&body);
1833        self.last_rewind_report = Some(report.to_string());
1834        true
1835    }
1836}
1837
1838#[cfg(test)]
1839mod persist_tests {
1840    use super::*;
1841    use whycodes_core::types::Usage;
1842
1843    #[test]
1844    fn save_and_load_restores_usage_and_messages() {
1845        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
1846        let mut session = Session::new(std::path::PathBuf::from("/proj"), "sys".into());
1847        session.add_user_message("hello");
1848        session.add_usage(&Usage {
1849            input_tokens: 42,
1850            output_tokens: 7,
1851            cache_creation_input_tokens: Some(1),
1852            cache_read_input_tokens: Some(2),
1853        });
1854        session.save_to_db(&db).unwrap();
1855
1856        // Second save must not duplicate messages.
1857        session.add_assistant_message(vec![ContentBlock::Text { text: "hi".into() }]);
1858        session.save_to_db(&db).unwrap();
1859
1860        let loaded = Session::load_from_db(&db, &session.id)
1861            .unwrap()
1862            .expect("session row");
1863        assert_eq!(loaded.usage.input_tokens, 42);
1864        assert_eq!(loaded.usage.output_tokens, 7);
1865        assert_eq!(loaded.usage.cache_creation_input_tokens, Some(1));
1866        assert_eq!(loaded.usage.cache_read_input_tokens, Some(2));
1867        assert_eq!(loaded.messages.len(), 2);
1868        assert!(
1869            loaded.messages[0].created_at.is_some(),
1870            "user message should keep its authored time"
1871        );
1872        assert_eq!(
1873            loaded.messages[0].created_at,
1874            session.messages[0].created_at
1875        );
1876        assert_eq!(db.message_count(&session.id).unwrap(), 2);
1877    }
1878
1879    #[test]
1880    fn edit_stats_follow_tool_results_and_survive_reload() {
1881        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
1882        let mut session = Session::new(std::path::PathBuf::from("/proj"), "sys".into());
1883        session.add_user_message("patch it");
1884        let calls = vec![
1885            whycodes_core::types::ToolCall {
1886                id: "c1".into(),
1887                name: "apply_patch".into(),
1888                arguments: serde_json::json!({}),
1889            },
1890            whycodes_core::types::ToolCall {
1891                id: "c2".into(),
1892                name: "write".into(),
1893                arguments: serde_json::json!({}),
1894            },
1895        ];
1896        session.add_assistant_message(
1897            calls
1898                .iter()
1899                .map(|c| ContentBlock::ToolUse {
1900                    id: c.id.clone(),
1901                    name: c.name.clone(),
1902                    input: c.arguments.clone(),
1903                })
1904                .collect(),
1905        );
1906        let results = vec![
1907            whycodes_core::types::ToolResult {
1908                tool_call_id: "c1".into(),
1909                content: "Patch applied to `a` (1 hunk).\n[+3 -1]".into(),
1910                is_error: false,
1911            },
1912            // A failed write counts nothing.
1913            whycodes_core::types::ToolResult {
1914                tool_call_id: "c2".into(),
1915                content: "Error writing file 'b': denied".into(),
1916                is_error: true,
1917            },
1918            // Unknown call id (no matching tool call): ignored.
1919            whycodes_core::types::ToolResult {
1920                tool_call_id: "zz".into(),
1921                content: "[+9 -9]".into(),
1922                is_error: false,
1923            },
1924        ];
1925        session.record_edit_results(&calls, &results);
1926        session.add_tool_results(results);
1927        assert_eq!(session.edit_stats.added, 3);
1928        assert_eq!(session.edit_stats.removed, 1);
1929        assert!(!session.edit_stats.approximate);
1930
1931        session.save_to_db(&db).unwrap();
1932        let loaded = Session::load_from_db(&db, &session.id)
1933            .unwrap()
1934            .expect("session row");
1935        // Rebuilt from the transcript: the failed write has no trailer, so
1936        // the reloaded total is flagged approximate.
1937        assert_eq!(loaded.edit_stats.added, 3);
1938        assert_eq!(loaded.edit_stats.removed, 1);
1939        assert!(loaded.edit_stats.approximate);
1940    }
1941
1942    #[test]
1943    fn usage_totals_reflect_saved_sessions() {
1944        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
1945        let mut a = Session::new(std::path::PathBuf::from("/a"), "s".into());
1946        a.add_usage(&Usage {
1947            input_tokens: 10,
1948            output_tokens: 1,
1949            ..Default::default()
1950        });
1951        a.save_to_db(&db).unwrap();
1952        let mut b = Session::new(std::path::PathBuf::from("/b"), "s".into());
1953        b.add_usage(&Usage {
1954            input_tokens: 20,
1955            output_tokens: 2,
1956            ..Default::default()
1957        });
1958        b.save_to_db(&db).unwrap();
1959
1960        let totals = db.usage_totals().unwrap();
1961        assert_eq!(totals.session_count, 2);
1962        assert_eq!(totals.usage.input_tokens, 30);
1963        assert_eq!(totals.usage.output_tokens, 3);
1964    }
1965
1966    #[test]
1967    fn save_propagates_session_upsert_database_errors() {
1968        let dir = tempfile::tempdir().unwrap();
1969        let path = dir.path().join("broken.db");
1970        let db = whycodes_storage::db::Database::open(path.to_str().unwrap()).unwrap();
1971        let conn = rusqlite::Connection::open(&path).unwrap();
1972        conn.execute_batch("DROP TABLE messages; DROP TABLE sessions;")
1973            .unwrap();
1974
1975        let session = Session::new(std::path::PathBuf::from("/proj"), String::new());
1976        assert!(session.save_to_db(&db).is_err());
1977    }
1978
1979    #[test]
1980    fn load_missing_session_returns_none() {
1981        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
1982
1983        assert!(Session::load_from_db(&db, "missing").unwrap().is_none());
1984    }
1985
1986    #[test]
1987    fn load_rejects_invalid_session_timestamp() {
1988        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
1989        db.upsert_session(
1990            "broken-time",
1991            "Broken",
1992            "/project",
1993            "not-rfc3339",
1994            "2026-08-21T12:00:00Z",
1995            &Usage::default(),
1996        )
1997        .unwrap();
1998
1999        let error = Session::load_from_db(&db, "broken-time").unwrap_err();
2000        assert!(error.to_string().contains("input"));
2001    }
2002
2003    #[test]
2004    fn load_rejects_invalid_message_json() {
2005        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
2006        db.upsert_session(
2007            "broken-message",
2008            "Broken",
2009            "/project",
2010            "2026-08-21T12:00:00Z",
2011            "2026-08-21T12:00:01Z",
2012            &Usage::default(),
2013        )
2014        .unwrap();
2015        db.insert_message(
2016            "message-1",
2017            "broken-message",
2018            "user",
2019            "{not json}",
2020            None,
2021            None,
2022        )
2023        .unwrap();
2024
2025        let error = Session::load_from_db(&db, "broken-message").unwrap_err();
2026        assert!(
2027            error.to_string().contains("key must be a string"),
2028            "unexpected JSON error: {error:#}"
2029        );
2030    }
2031}
2032
2033// Keep export methods on Session.
2034impl Session {
2035    /// Export the session as a shareable JSON file.
2036    /// Writes to .whycodes/shares/{session_id}.json and returns the file path.
2037    pub fn export_share(&self) -> Result<String> {
2038        let shares_dir = whycodes_core::project_dir(&self.project_path).join("shares");
2039        std::fs::create_dir_all(&shares_dir)?;
2040
2041        let filename = format!("{}.json", self.id);
2042        let share_path = shares_dir.join(&filename);
2043
2044        let json = serde_json::to_string_pretty(self)?;
2045        std::fs::write(&share_path, json)?;
2046
2047        // Also write a human-readable Markdown share (`/export`)
2048        let md_path = shares_dir.join(format!("{}.md", self.id));
2049        let _ = std::fs::write(&md_path, self.export_markdown());
2050
2051        Ok(share_path.to_string_lossy().to_string())
2052    }
2053
2054    /// Export conversation as Markdown for sharing / `/export`.
2055    pub fn export_markdown(&self) -> String {
2056        let mut out = String::new();
2057        out.push_str(&format!("# {}\n\n", self.title));
2058        out.push_str(&format!("- **Session:** `{}`\n", self.id));
2059        out.push_str(&format!(
2060            "- **Project:** `{}`\n",
2061            self.project_path.display()
2062        ));
2063        out.push_str(&format!(
2064            "- **Created:** {}\n\n---\n\n",
2065            self.created_at.to_rfc3339()
2066        ));
2067
2068        for msg in &self.messages {
2069            let role = match msg.role {
2070                Role::User => "User",
2071                Role::Assistant => "Assistant",
2072                Role::System => "System",
2073                Role::Tool => "Tool",
2074            };
2075            out.push_str(&format!("### {role}\n\n"));
2076            match &msg.content {
2077                MessageContent::Text(t) => {
2078                    out.push_str(t);
2079                    out.push_str("\n\n");
2080                }
2081                MessageContent::Blocks(blocks) => {
2082                    for b in blocks {
2083                        match b {
2084                            ContentBlock::Text { text } => {
2085                                out.push_str(text);
2086                                out.push_str("\n\n");
2087                            }
2088                            ContentBlock::ToolUse { name, input, .. } => {
2089                                out.push_str(&format!(
2090                                    "```tool\n{name}\n{}\n```\n\n",
2091                                    serde_json::to_string_pretty(input).unwrap_or_default()
2092                                ));
2093                            }
2094                            ContentBlock::ToolResult {
2095                                content, is_error, ..
2096                            } => {
2097                                let tag = if is_error.unwrap_or(false) {
2098                                    "error"
2099                                } else {
2100                                    "result"
2101                                };
2102                                out.push_str(&format!("```{tag}\n{content}\n```\n\n"));
2103                            }
2104                            ContentBlock::Image { .. } => {
2105                                out.push_str("*[image]*\n\n");
2106                            }
2107                            ContentBlock::Thinking { text, .. } => {
2108                                out.push_str(&format!("```thinking\n{text}\n```\n\n"));
2109                            }
2110                            ContentBlock::RedactedThinking { .. } => {
2111                                out.push_str("*[redacted thinking]*\n\n");
2112                            }
2113                            ContentBlock::Compaction { content, .. } => {
2114                                out.push_str(&format!(
2115                                    "*[earlier conversation summary]*\n\n{content}\n\n"
2116                                ));
2117                            }
2118                        }
2119                    }
2120                }
2121            }
2122        }
2123        out
2124    }
2125
2126    /// Revert the session to a previous state by removing all messages after
2127    /// the given index. Returns the number of messages removed.
2128    pub fn revert_to(&mut self, message_index: usize) -> usize {
2129        if message_index >= self.messages.len() {
2130            return 0;
2131        }
2132
2133        let removed = self.messages.len() - message_index - 1;
2134        self.messages.truncate(message_index + 1);
2135        self.token_cache
2136            .rebuild(&self.system_prompt, &self.messages);
2137        self.touch();
2138        removed
2139    }
2140
2141    /// Replace the entire message list (used by undo/redo).
2142    pub fn set_messages(&mut self, messages: Vec<Message>) {
2143        self.messages = messages;
2144        self.token_cache
2145            .rebuild(&self.system_prompt, &self.messages);
2146        self.touch();
2147    }
2148
2149    /// Undo the last user turn: remove from the last user message to the end.
2150    /// Returns the number of messages removed, or 0 if nothing to undo.
2151    pub fn undo_last_turn(&mut self) -> usize {
2152        let last_user = self.messages.iter().rposition(|m| m.role == Role::User);
2153        match last_user {
2154            Some(idx) => {
2155                let removed = self.messages.len() - idx;
2156                self.messages.truncate(idx);
2157                self.token_cache
2158                    .rebuild(&self.system_prompt, &self.messages);
2159                self.touch();
2160                removed
2161            }
2162            None => 0,
2163        }
2164    }
2165
2166    fn touch(&mut self) {
2167        self.updated_at = chrono::Utc::now();
2168    }
2169}
2170
2171/// Default system prompt for the main agent
2172pub fn default_system_prompt() -> String {
2173    include_str!("../prompts/default.txt").to_string()
2174}
2175
2176#[cfg(test)]
2177mod tests {
2178    use super::*;
2179
2180    fn test_project_path() -> PathBuf {
2181        PathBuf::from("/tmp/test-project")
2182    }
2183
2184    fn test_system_prompt() -> String {
2185        "You are a helpful assistant.".to_string()
2186    }
2187
2188    fn compaction_block() -> ContentBlock {
2189        ContentBlock::compaction_from_raw(serde_json::json!({
2190            "type": "compaction",
2191            "content": "summary of the old work",
2192            "signature": "sig"
2193        }))
2194        .unwrap()
2195    }
2196
2197    #[test]
2198    fn native_compaction_cuts_before_the_last_ask_and_keeps_the_tail() {
2199        let mut s = Session::new(test_project_path(), test_system_prompt());
2200        s.add_user_message("old ask");
2201        s.add_assistant_message(vec![ContentBlock::ToolUse {
2202            id: "t1".into(),
2203            name: "read".into(),
2204            input: serde_json::json!({"path": "a.rs"}),
2205        }]);
2206        s.add_tool_results(vec![whycodes_core::types::ToolResult {
2207            tool_call_id: "t1".into(),
2208            content: "fn a() {}".into(),
2209            is_error: false,
2210        }]);
2211        s.add_assistant_message(vec![ContentBlock::Text {
2212            text: "done".into(),
2213        }]);
2214        s.add_user_message("new ask");
2215        s.add_assistant_message(vec![
2216            ContentBlock::Thinking {
2217                text: "plan".into(),
2218                signature: Some("tsig".into()),
2219            },
2220            ContentBlock::Text {
2221                text: "working".into(),
2222            },
2223        ]);
2224
2225        let (cut, prefix) = s.native_compaction_prefix().unwrap();
2226        assert_eq!(cut, 4);
2227        assert_eq!(prefix.len(), 4);
2228
2229        let outcome = s.apply_native_compaction(cut, compaction_block());
2230        assert!(outcome.dropped_messages());
2231        assert!(outcome.dropped_transcript.contains("old ask"));
2232        assert_eq!(outcome.messages_before, 6);
2233        assert_eq!(outcome.messages_after, 3);
2234        assert_eq!(s.messages[0].role, Role::Assistant);
2235        assert_eq!(
2236            s.messages[0].content.as_text(),
2237            Some("summary of the old work")
2238        );
2239        assert_eq!(s.messages[1].content.as_text(), Some("new ask"));
2240        // Kept thinking stays as it was.
2241        assert!(matches!(
2242            &s.messages[2].content,
2243            MessageContent::Blocks(b) if b[0].is_thinking()
2244        ));
2245        assert_eq!(
2246            s.token_count_cached(),
2247            estimate_tokens(&s.system_prompt)
2248                + s.messages.iter().map(message_tokens).sum::<usize>()
2249        );
2250
2251        // The summary is carried into a later local summary and the export.
2252        assert!(
2253            s.transcript_for_full_summary(0)
2254                .contains("summary of the old work")
2255        );
2256        assert!(
2257            s.export_markdown()
2258                .contains("*[earlier conversation summary]*")
2259        );
2260
2261        // Compacting again: the old block is part of the summarized prefix.
2262        s.add_user_message("third ask");
2263        let (cut, prefix) = s.native_compaction_prefix().unwrap();
2264        assert_eq!(cut, 3);
2265        assert!(matches!(
2266            &prefix[0].content,
2267            MessageContent::Blocks(b) if matches!(b[0], ContentBlock::Compaction { .. })
2268        ));
2269    }
2270
2271    #[test]
2272    fn native_compaction_has_no_cut_without_a_clean_boundary() {
2273        let mut s = Session::new(test_project_path(), test_system_prompt());
2274        assert!(s.native_compaction_prefix().is_none(), "empty");
2275        s.add_user_message("only ask");
2276        assert!(s.native_compaction_prefix().is_none(), "nothing before it");
2277        s.add_user_message("second ask");
2278        assert!(
2279            s.native_compaction_prefix().is_none(),
2280            "user before the last ask"
2281        );
2282        let mut open = Session::new(test_project_path(), test_system_prompt());
2283        open.add_user_message("ask");
2284        open.add_assistant_message(vec![ContentBlock::ToolUse {
2285            id: "t1".into(),
2286            name: "read".into(),
2287            input: serde_json::json!({}),
2288        }]);
2289        open.add_user_message("interrupting ask");
2290        assert!(open.native_compaction_prefix().is_none(), "open tool call");
2291    }
2292
2293    #[test]
2294    fn fork_copies_history_under_a_new_id() {
2295        let mut s = Session::new(test_project_path(), test_system_prompt());
2296        s.set_title_manual("retry loop");
2297        s.add_user_message("explain the retry loop");
2298        s.add_usage(&whycodes_core::types::Usage {
2299            input_tokens: 5,
2300            ..Default::default()
2301        });
2302        let f = s.fork();
2303        assert_ne!(f.id, s.id);
2304        assert_eq!(f.title, "retry loop (fork)");
2305        assert_eq!(f.title_source, s.title_source);
2306        assert_eq!(f.messages.len(), 1);
2307        assert_eq!(f.system_prompt, s.system_prompt);
2308        assert!(f.usage.is_empty());
2309        assert!(f.created_at >= s.created_at);
2310        assert_eq!(s.usage.input_tokens, 5, "original untouched");
2311    }
2312
2313    #[test]
2314    fn test_format_exit_summary() {
2315        let mut session = Session::new(test_project_path(), test_system_prompt());
2316        session.add_user_message("hello");
2317        let text = session.format_exit_summary(
2318            std::time::Duration::from_secs(42),
2319            "openai/gpt-4o",
2320            "whycodes",
2321        );
2322        assert!(text.contains("Session Summary"), "{text}");
2323        assert!(text.contains(&session.id), "{text}");
2324        assert!(text.contains("Duration  42s"), "{text}");
2325        assert!(text.contains("Model     openai/gpt-4o"), "{text}");
2326        assert!(text.contains("CWD       /tmp/test-project"), "{text}");
2327        assert!(text.contains("Messages  1"), "{text}");
2328        assert!(
2329            text.contains(&format!("whycodes --resume {}", session.id)),
2330            "{text}"
2331        );
2332    }
2333
2334    #[test]
2335    fn test_new_session() {
2336        let session = Session::new(test_project_path(), test_system_prompt());
2337
2338        assert!(!session.id.is_empty(), "session id should not be empty");
2339        assert!(
2340            session.title.starts_with("test-project-"),
2341            "title should be project basename + short id, got {:?}",
2342            session.title
2343        );
2344        assert_eq!(session.title_source, crate::title::TitleSource::Default);
2345        assert!(
2346            session.messages.is_empty(),
2347            "new session should have no messages"
2348        );
2349        assert_eq!(session.system_prompt, test_system_prompt());
2350        assert_eq!(session.project_path, test_project_path());
2351        assert_eq!(session.created_at, session.updated_at);
2352    }
2353
2354    #[test]
2355    fn test_heuristic_and_manual_title() {
2356        let mut session = Session::new(test_project_path(), test_system_prompt());
2357        assert!(session.apply_heuristic_title("Please fix the auth middleware bug"));
2358        assert_eq!(session.title_source, crate::title::TitleSource::Heuristic);
2359        assert!(session.title.to_ascii_lowercase().contains("auth"));
2360
2361        session.set_title_manual("My locked name");
2362        assert_eq!(session.title, "My locked name");
2363        assert_eq!(session.title_source, crate::title::TitleSource::Manual);
2364        assert!(!session.apply_heuristic_title("something else"));
2365        assert!(!session.apply_generated_title("model title"));
2366        assert_eq!(session.title, "My locked name");
2367    }
2368
2369    #[test]
2370    fn upgrade_from_history_uses_first_user_message() {
2371        let mut session = Session::new(test_project_path(), test_system_prompt());
2372        // Simulate a legacy placeholder still marked Default.
2373        session.title = "New session - 2026-01-01".into();
2374        session.title_source = crate::title::TitleSource::Default;
2375        session.add_user_message("Please fix the stripe webhook timeout");
2376        session.add_assistant_message(vec![ContentBlock::Text {
2377            text: "Looking into it.".into(),
2378        }]);
2379        session.add_user_message("also check retries");
2380
2381        assert!(session.maybe_upgrade_title_from_history());
2382        assert_eq!(session.title_source, crate::title::TitleSource::Heuristic);
2383        assert!(session.title.to_ascii_lowercase().contains("stripe"));
2384        // Second call is a no-op (no longer Default).
2385        assert!(!session.maybe_upgrade_title_from_history());
2386    }
2387
2388    #[test]
2389    fn test_add_messages() {
2390        let mut session = Session::new(test_project_path(), test_system_prompt());
2391
2392        session.add_user_message("Hello");
2393        assert_eq!(session.messages.len(), 1);
2394        assert_eq!(session.messages[0].role, Role::User);
2395        assert_eq!(session.messages[0].content.as_text(), Some("Hello"));
2396
2397        session.add_assistant_message(vec![ContentBlock::Text {
2398            text: "Hi there!".to_string(),
2399        }]);
2400        assert_eq!(session.messages.len(), 2);
2401        assert_eq!(session.messages[1].role, Role::Assistant);
2402        assert_eq!(session.messages[1].content.as_text(), Some("Hi there!"));
2403
2404        // updated_at should change after adding messages
2405        assert!(session.updated_at >= session.created_at);
2406    }
2407
2408    #[test]
2409    fn test_add_tool_results() {
2410        let mut session = Session::new(test_project_path(), test_system_prompt());
2411
2412        let results = vec![
2413            whycodes_core::types::ToolResult {
2414                tool_call_id: "call-1".to_string(),
2415                content: "result 1".to_string(),
2416                is_error: false,
2417            },
2418            whycodes_core::types::ToolResult {
2419                tool_call_id: "call-2".to_string(),
2420                content: "error result".to_string(),
2421                is_error: true,
2422            },
2423        ];
2424
2425        session.add_user_message("use tools");
2426        session.add_tool_results(results);
2427
2428        assert_eq!(session.messages.len(), 3);
2429        assert_eq!(session.messages[1].role, Role::Tool);
2430        assert_eq!(session.messages[1].tool_call_id.as_deref(), Some("call-1"));
2431        assert_eq!(session.messages[1].content.as_text(), Some("result 1"));
2432        assert_eq!(session.messages[2].role, Role::Tool);
2433        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("call-2"));
2434    }
2435
2436    #[test]
2437    fn add_tool_results_fills_empty_tool_call_id() {
2438        let mut session = Session::new(test_project_path(), test_system_prompt());
2439        session.add_user_message("use tools");
2440        session.add_tool_results(vec![whycodes_core::types::ToolResult {
2441            tool_call_id: String::new(),
2442            content: "ok".into(),
2443            is_error: false,
2444        }]);
2445        let id = session.messages[1]
2446            .tool_call_id
2447            .as_deref()
2448            .unwrap_or_default();
2449        assert_eq!(id, "call_1");
2450    }
2451
2452    #[test]
2453    fn test_build_request() {
2454        let mut session = Session::new(test_project_path(), test_system_prompt());
2455        session.add_user_message("test message");
2456
2457        let tools = vec![ToolDefinition {
2458            name: "search".to_string(),
2459            description: "search tool".to_string(),
2460            parameters: serde_json::json!({"type": "object", "properties": {}}),
2461        }];
2462
2463        let req = session.build_request(tools, Some(1024), Some(0.7), None);
2464
2465        assert_eq!(req.system, test_system_prompt());
2466        assert_eq!(req.messages.len(), 1);
2467        assert_eq!(req.tools.len(), 1);
2468        assert_eq!(req.tools[0].name, "search");
2469        assert_eq!(req.max_tokens, Some(1024));
2470        assert_eq!(req.temperature, Some(0.7));
2471        assert!(req.top_p.is_none());
2472        assert!(req.top_k.is_none());
2473    }
2474
2475    #[test]
2476    fn test_build_request_no_tools() {
2477        let session = Session::new(test_project_path(), test_system_prompt());
2478        let req = session.build_request(Vec::new(), None, None, None);
2479
2480        assert!(req.tools.is_empty());
2481        assert_eq!(req.system, test_system_prompt());
2482        assert!(req.messages.is_empty());
2483    }
2484
2485    #[test]
2486    fn test_token_count() {
2487        let mut session = Session::new(test_project_path(), "short prompt".to_string());
2488
2489        // Empty session should just have system prompt tokens
2490        let base_tokens = session.token_count();
2491        assert_eq!(base_tokens, estimate_tokens("short prompt"));
2492
2493        // Add a text message
2494        session.add_user_message("hello world, this is a test message");
2495        let with_msg = session.token_count();
2496        assert!(with_msg > base_tokens);
2497
2498        // Add assistant with blocks
2499        session.add_assistant_message(vec![
2500            ContentBlock::Text {
2501                text: "response text here".to_string(),
2502            },
2503            ContentBlock::ToolUse {
2504                id: "t1".to_string(),
2505                name: "tool".to_string(),
2506                input: serde_json::json!({}),
2507            },
2508        ]);
2509        let with_blocks = session.token_count();
2510        assert!(with_blocks > with_msg);
2511    }
2512
2513    #[test]
2514    fn test_compact_under_budget_is_noop() {
2515        let mut session = Session::new(test_project_path(), test_system_prompt());
2516        for i in 0..10 {
2517            session.add_user_message(&format!("message {i}"));
2518        }
2519        let before = session.messages.len();
2520        session.compact(1_000_000);
2521        assert_eq!(session.messages.len(), before);
2522        assert_eq!(session.messages[0].content.as_text(), Some("message 0"));
2523    }
2524
2525    #[test]
2526    fn test_compact_drops_to_token_budget() {
2527        let mut session = Session::new(test_project_path(), test_system_prompt());
2528
2529        for i in 0..10 {
2530            session.add_user_message(&format!("message {i} {}", "x".repeat(200)));
2531        }
2532        assert_eq!(session.messages.len(), 10);
2533
2534        let outcome = session.compact(200);
2535
2536        let summary_text = session.messages[0].content.as_text().unwrap();
2537        assert!(
2538            summary_text.contains("earlier messages trimmed"),
2539            "summary should mention trimmed messages: {summary_text}"
2540        );
2541        assert!(
2542            summary_text.contains("Goals:"),
2543            "local summary should include goal snippets: {summary_text}"
2544        );
2545        assert!(
2546            outcome.dropped_messages(),
2547            "dropped_transcript should be non-empty when messages were trimmed"
2548        );
2549        assert!(
2550            outcome.dropped_transcript.contains("User:"),
2551            "dropped transcript should include role-prefixed lines"
2552        );
2553        let message_count = session.messages.len();
2554        assert!(
2555            message_count >= 5,
2556            "summary + min keep, got {message_count}"
2557        );
2558        assert!(
2559            message_count < 11,
2560            "should have dropped something, got {message_count}"
2561        );
2562        let last = session.messages.last().unwrap().content.as_text().unwrap();
2563        assert!(last.starts_with("message 9"), "last was {last}");
2564    }
2565
2566    #[test]
2567    fn test_compact_few_messages() {
2568        let mut session = Session::new(test_project_path(), test_system_prompt());
2569        session.add_user_message("only message");
2570
2571        let before = session.messages.len();
2572        session.compact(1000);
2573
2574        assert_eq!(session.messages.len(), before);
2575        assert_eq!(session.messages[0].content.as_text(), Some("only message"));
2576    }
2577
2578    #[test]
2579    fn format_compact_summary_strips_analysis_and_wraps() {
2580        let raw = "<analysis>\nthinking\n</analysis>\n<summary>\n1. Primary Request: fix login\n</summary>";
2581        let cleaned = format_compact_summary(raw);
2582        assert!(!cleaned.contains("thinking"), "{cleaned}");
2583        assert!(cleaned.starts_with("Summary:\n"), "{cleaned}");
2584        assert!(cleaned.contains("fix login"), "{cleaned}");
2585
2586        let carrier = format_compact_summary_content(raw);
2587        assert!(
2588            carrier.starts_with("This session is being continued"),
2589            "{carrier}"
2590        );
2591        assert!(carrier.contains("fix login"), "{carrier}");
2592        assert!(is_compact_summary_text(&carrier));
2593
2594        let shown = compact_summary_display_text(&carrier);
2595        assert!(shown.starts_with("Conversation compacted"), "{shown}");
2596        assert!(!shown.contains("ran out of context"), "{shown}");
2597        assert!(shown.contains("fix login"), "{shown}");
2598    }
2599
2600    #[test]
2601    fn apply_full_replace_keeps_last_user_and_recent_tail() {
2602        let mut session = Session::new(test_project_path(), test_system_prompt());
2603        session.add_user_message("first task");
2604        session.add_assistant_message(vec![ContentBlock::Text {
2605            text: "working on it".into(),
2606        }]);
2607        session.add_user_message("fix the login bug");
2608        session.add_assistant_message(vec![ContentBlock::Text {
2609            text: "looking at auth.rs".into(),
2610        }]);
2611        session.add_tool_results(vec![whycodes_core::types::ToolResult {
2612            tool_call_id: "t1".into(),
2613            content: "fn login() {}".into(),
2614            is_error: false,
2615        }]);
2616
2617        let outcome = session.apply_full_replace(
2618            "<summary>\n1. Primary Request: fix the login bug\n2. Files: auth.rs\n</summary>",
2619        );
2620        assert!(outcome.dropped_messages());
2621        assert!(
2622            outcome.dropped_transcript.contains("first task"),
2623            "{}",
2624            outcome.dropped_transcript
2625        );
2626
2627        let texts: Vec<String> = session
2628            .messages
2629            .iter()
2630            .filter_map(|m| m.content.as_text().map(str::to_string))
2631            .collect();
2632        assert_eq!(texts[0], "fix the login bug");
2633        assert!(
2634            texts.iter().any(|t| t.contains("looking at auth.rs")),
2635            "{texts:?}"
2636        );
2637        let last = texts.last().expect("summary carrier");
2638        assert!(
2639            last.starts_with("This session is being continued"),
2640            "{last}"
2641        );
2642        assert!(last.contains("fix the login bug"), "{last}");
2643    }
2644
2645    #[test]
2646    fn full_replace_cuts_at_the_user_turn_not_at_agent_notes() {
2647        let mut session = Session::new(test_project_path(), test_system_prompt());
2648        session.add_user_message("old task");
2649        session.add_assistant_message(vec![ContentBlock::Text {
2650            text: "done".into(),
2651        }]);
2652        session.add_user_message("fix the login bug");
2653        session.add_assistant_message(vec![ContentBlock::Text {
2654            text: "reading auth.rs".into(),
2655        }]);
2656        session.add_agent_note("The tool failed with error: not found");
2657        assert!(session.messages.last().unwrap().is_agent_note());
2658        assert_eq!(session.compaction_prefix_end(), Some(2));
2659
2660        session.apply_full_replace("<summary>first pass</summary>");
2661        let texts = |s: &Session| -> Vec<String> {
2662            s.messages
2663                .iter()
2664                .filter_map(|m| m.content.as_text().map(str::to_string))
2665                .collect()
2666        };
2667        let first = texts(&session);
2668        assert_eq!(first[0], "fix the login bug");
2669        assert!(first.iter().any(|t| t.contains("tool failed")), "{first:?}");
2670
2671        // A second pass replaces the earlier carrier instead of stacking one.
2672        session.apply_full_replace("<summary>second pass</summary>");
2673        let second = texts(&session);
2674        let carriers: Vec<&String> = second
2675            .iter()
2676            .filter(|t| is_compact_summary_text(t))
2677            .collect();
2678        assert_eq!(carriers.len(), 1, "{second:?}");
2679        assert!(carriers[0].contains("second pass"), "{second:?}");
2680    }
2681
2682    #[test]
2683    fn compact_full_replace_local_empty_is_noop() {
2684        let mut session = Session::new(test_project_path(), test_system_prompt());
2685        let outcome = session.compact_full_replace_local();
2686        assert_eq!(outcome.messages_before, 0);
2687        assert_eq!(session.messages.len(), 0);
2688        assert!(!outcome.dropped_messages());
2689    }
2690
2691    #[test]
2692    fn compact_full_replace_local_always_replaces_prefix() {
2693        let mut session = Session::new(test_project_path(), test_system_prompt());
2694        session.add_user_message("old work");
2695        session.add_assistant_message(vec![ContentBlock::Text {
2696            text: "done".into(),
2697        }]);
2698        session.add_user_message("new work");
2699        let outcome = session.compact_full_replace_local();
2700        assert!(outcome.dropped_messages());
2701        assert_eq!(session.messages[0].content.as_text(), Some("new work"));
2702        let last = session.messages.last().unwrap().content.as_text().unwrap();
2703        assert!(
2704            last.starts_with("This session is being continued"),
2705            "{last}"
2706        );
2707        assert!(last.contains("earlier messages trimmed"), "{last}");
2708    }
2709
2710    #[test]
2711    fn test_add_tool_results_caps_huge_body() {
2712        let mut session = Session::new(test_project_path(), test_system_prompt());
2713        let huge = "a".repeat(TOOL_RESULT_MAX_CHARS + 5000);
2714        session.add_tool_results(vec![whycodes_core::types::ToolResult {
2715            tool_call_id: "t1".into(),
2716            content: huge,
2717            is_error: false,
2718        }]);
2719        let text = session.messages[0].content.as_text().unwrap();
2720        assert!(text.contains("chars truncated"));
2721        assert!(text.ends_with("[5000 chars truncated]"));
2722        assert!(text.chars().count() < TOOL_RESULT_MAX_CHARS + 40);
2723    }
2724
2725    #[test]
2726    fn test_info() {
2727        let mut session = Session::new(test_project_path(), test_system_prompt());
2728        session.add_user_message("hello");
2729        session.add_assistant_message(vec![ContentBlock::Text {
2730            text: "hi".to_string(),
2731        }]);
2732
2733        let info = session.info();
2734        assert_eq!(info.id, session.id);
2735        assert_eq!(info.title, session.title);
2736        assert_eq!(info.message_count, 2);
2737        assert_eq!(info.project_path, test_project_path());
2738        assert_eq!(info.created_at, session.created_at);
2739        assert_eq!(info.updated_at, session.updated_at);
2740    }
2741
2742    #[test]
2743    fn test_set_system_prompt() {
2744        let mut session = Session::new(test_project_path(), test_system_prompt());
2745        let old_updated = session.updated_at;
2746
2747        // Small delay so we can assert the timestamp changes
2748        std::thread::sleep(std::time::Duration::from_millis(1));
2749
2750        session.set_system_prompt("new prompt");
2751
2752        assert_eq!(session.system_prompt, "new prompt");
2753        assert!(session.updated_at > old_updated);
2754    }
2755
2756    #[test]
2757    fn test_revert_to() {
2758        let mut session = Session::new(test_project_path(), test_system_prompt());
2759        for i in 0..5 {
2760            session.add_user_message(&format!("msg {}", i));
2761        }
2762
2763        assert_eq!(session.messages.len(), 5);
2764
2765        // Keep messages up to and including index 2 (first 3 messages)
2766        let removed = session.revert_to(2);
2767        assert_eq!(removed, 2); // removed messages at indices 3 and 4
2768        assert_eq!(session.messages.len(), 3);
2769        assert_eq!(session.messages[0].content.as_text(), Some("msg 0"));
2770        assert_eq!(session.messages[1].content.as_text(), Some("msg 1"));
2771        assert_eq!(session.messages[2].content.as_text(), Some("msg 2"));
2772    }
2773
2774    #[test]
2775    fn test_revert_to_out_of_bounds() {
2776        let mut session = Session::new(test_project_path(), test_system_prompt());
2777        session.add_user_message("hello");
2778
2779        let removed = session.revert_to(5); // beyond length
2780        assert_eq!(removed, 0);
2781        assert_eq!(session.messages.len(), 1);
2782    }
2783
2784    #[test]
2785    fn test_conversation_text() {
2786        let mut session = Session::new(test_project_path(), test_system_prompt());
2787        session.add_user_message("hello");
2788        session.add_assistant_message(vec![ContentBlock::Text {
2789            text: "hi there".to_string(),
2790        }]);
2791
2792        let text = session.conversation_text();
2793        assert!(text.contains("User: hello"));
2794        assert!(text.contains("Assistant: hi there"));
2795    }
2796
2797    #[test]
2798    fn from_imported_uses_title_hint_or_first_user_text() {
2799        let msgs = vec![
2800            Message {
2801                role: Role::User,
2802                content: MessageContent::Text("please fix the login flow".into()),
2803                tool_call_id: None,
2804                name: None,
2805                created_at: None,
2806            },
2807            Message {
2808                role: Role::Assistant,
2809                content: MessageContent::Text("on it".into()),
2810                tool_call_id: None,
2811                name: None,
2812                created_at: None,
2813            },
2814        ];
2815
2816        let s = Session::from_imported(test_project_path(), msgs.clone(), Some("Imported chat"));
2817        assert_eq!(s.title, "Imported chat");
2818        assert_eq!(s.title_source, crate::title::TitleSource::Manual);
2819        assert_eq!(s.messages.len(), 2);
2820        assert_eq!(s.system_prompt, "");
2821
2822        let s = Session::from_imported(test_project_path(), msgs.clone(), None);
2823        assert_eq!(s.title_source, crate::title::TitleSource::Heuristic);
2824        assert!(s.title.to_ascii_lowercase().contains("login"));
2825    }
2826
2827    #[test]
2828    fn first_user_text_joins_blocks_and_skips_empty() {
2829        let mut session = Session::new(test_project_path(), test_system_prompt());
2830        assert_eq!(session.user_message_count(), 0);
2831        assert_eq!(session.first_user_text(), None);
2832        assert_eq!(session.first_assistant_snippet(10), None);
2833
2834        session.add_user_message_blocks(vec![
2835            ContentBlock::Text {
2836                text: "first line".into(),
2837            },
2838            ContentBlock::ToolUse {
2839                id: "t1".into(),
2840                name: "grep".into(),
2841                input: serde_json::json!({}),
2842            },
2843            ContentBlock::Text {
2844                text: "second line".into(),
2845            },
2846        ]);
2847        assert_eq!(session.user_message_count(), 1);
2848        // `as_text` returns the first text block
2849        assert_eq!(session.first_user_text().as_deref(), Some("first line"));
2850
2851        session.add_assistant_message(vec![ContentBlock::Text {
2852            text: "a longer assistant reply".into(),
2853        }]);
2854        assert_eq!(
2855            session.first_assistant_snippet(7).as_deref(),
2856            Some("a longe")
2857        );
2858        assert_eq!(
2859            session.first_assistant_snippet(100).as_deref(),
2860            Some("a longer assistant reply")
2861        );
2862    }
2863
2864    #[test]
2865    fn first_user_text_skips_whitespace_only_first_message() {
2866        // `first_user_text` inspects only the first user message; a
2867        // whitespace-only first message yields None even when later messages
2868        // carry real text (the caller retries on the next turn).
2869        let mut session = Session::new(test_project_path(), test_system_prompt());
2870        session.add_user_message("   ");
2871        assert_eq!(session.first_user_text(), None);
2872        session.add_user_message("real question");
2873        assert_eq!(session.first_user_text(), None);
2874    }
2875
2876    #[test]
2877    fn truncate_large_tool_results_caps_bodies() {
2878        let mut session = Session::new(test_project_path(), test_system_prompt());
2879        session.add_user_message("go");
2880        session.add_tool_results(vec![whycodes_core::types::ToolResult {
2881            tool_call_id: "t1".into(),
2882            content: "x".repeat(TOOL_RESULT_MAX_CHARS + 10_000),
2883            is_error: false,
2884        }]);
2885        // assistant block carrying an oversized ToolResult
2886        session.add_assistant_message(vec![ContentBlock::ToolResult {
2887            tool_use_id: "t2".into(),
2888            content: "y".repeat(TOOL_RESULT_MAX_CHARS + 10_000),
2889            is_error: Some(false),
2890        }]);
2891
2892        // Ingest already capped the tool-role body; only the assistant
2893        // ToolResult block still needs a pass.
2894        let modified = session.truncate_large_tool_results();
2895        assert_eq!(modified, 1);
2896        let tool_text = session.messages[1].content.as_text().unwrap();
2897        assert!(tool_text.contains("chars truncated"));
2898        assert!(
2899            tool_text.chars().count() < TOOL_RESULT_MAX_CHARS + 40,
2900            "kept chars + notice suffix"
2901        );
2902
2903        // running again is a no-op: the footer is stripped before the cap, so
2904        // the body stays at TOOL_RESULT_MAX_CHARS.
2905        assert_eq!(session.truncate_large_tool_results(), 0);
2906        let tool_text = session.messages[1].content.as_text().unwrap();
2907        assert!(tool_text.chars().count() < TOOL_RESULT_MAX_CHARS + 40);
2908    }
2909
2910    #[test]
2911    fn prune_old_tool_results_keeps_recent_full() {
2912        let mut session = Session::new(test_project_path(), test_system_prompt());
2913        session.add_user_message("start");
2914        for i in 0..6 {
2915            session.add_tool_results(vec![whycodes_core::types::ToolResult {
2916                tool_call_id: format!("t{i}"),
2917                content: "z".repeat(10_000),
2918                is_error: false,
2919            }]);
2920        }
2921        let pruned = session.prune_old_tool_results();
2922        assert!(pruned > 0, "old tool results should be cut");
2923        // recent kept at full size
2924        let last = session.messages.last().unwrap().content.as_text().unwrap();
2925        assert!(
2926            last.chars().count() > TOOL_RESULT_PRUNE_CHARS,
2927            "recent kept full"
2928        );
2929
2930        // no-op when few tools
2931        let mut small = Session::new(test_project_path(), test_system_prompt());
2932        small.add_user_message("start");
2933        small.add_tool_results(vec![whycodes_core::types::ToolResult {
2934            tool_call_id: "a".into(),
2935            content: "small".into(),
2936            is_error: false,
2937        }]);
2938        assert_eq!(small.prune_old_tool_results(), 0);
2939    }
2940
2941    #[test]
2942    fn prune_rebuilds_token_cache_instead_of_leaving_it_stale() {
2943        let mut session = Session::new(test_project_path(), test_system_prompt());
2944        session.add_user_message("start");
2945        for i in 0..6 {
2946            session.add_tool_results(vec![whycodes_core::types::ToolResult {
2947                tool_call_id: format!("t{i}"),
2948                content: "z".repeat(10_000),
2949                is_error: false,
2950            }]);
2951        }
2952        assert!(session.prune_old_tool_results() > 0);
2953        assert!(
2954            session.token_cache.valid,
2955            "prune must rebuild so later token_count stays O(1)"
2956        );
2957        assert_eq!(session.token_cache.per_msg.len(), session.messages.len());
2958        let warm = session.token_count();
2959        session.token_cache.invalidate();
2960        assert_eq!(session.token_count(), warm);
2961        assert_eq!(session.token_count_cached(), warm);
2962        assert!(session.token_cache.valid);
2963    }
2964
2965    #[test]
2966    fn shake_old_tool_results_cuts_harder_than_prune() {
2967        let mut session = Session::new(test_project_path(), test_system_prompt());
2968        session.add_user_message("start");
2969        for i in 0..6 {
2970            session.add_tool_results(vec![whycodes_core::types::ToolResult {
2971                tool_call_id: format!("t{i}"),
2972                content: "z".repeat(10_000),
2973                is_error: false,
2974            }]);
2975        }
2976        let shaken = session.shake_old_tool_results();
2977        assert!(shaken > 0);
2978        let old = session.messages[1].content.as_text().unwrap();
2979        // Cap plus the `[N chars truncated]` notice.
2980        let old_chars = old.chars().count();
2981        assert!(old_chars <= TOOL_RESULT_SHAKE_CHARS + 80, "{old_chars}");
2982        assert!(old.contains("truncated"));
2983        let last = session.messages.last().unwrap().content.as_text().unwrap();
2984        assert!(
2985            last.chars().count() > TOOL_RESULT_SHAKE_CHARS,
2986            "recent kept larger than shake cap"
2987        );
2988        let mut small = Session::new(test_project_path(), test_system_prompt());
2989        small.add_tool_results(vec![whycodes_core::types::ToolResult {
2990            tool_call_id: "a".into(),
2991            content: "tiny".into(),
2992            is_error: false,
2993        }]);
2994        assert_eq!(small.shake_old_tool_results(), 0);
2995    }
2996
2997    #[test]
2998    fn prepend_compact_summary_inserts_or_replaces() {
2999        let mut session = Session::new(test_project_path(), test_system_prompt());
3000        session.add_user_message("first");
3001        session.prepend_compact_summary("dropped a lot");
3002        let first = session.messages[0].content.as_text().unwrap();
3003        assert!(first.starts_with("[Compacted earlier conversation]"));
3004        assert!(first.contains("dropped a lot"));
3005
3006        // empty summary is a no-op
3007        session.prepend_compact_summary("   ");
3008        assert_eq!(session.messages.len(), 2);
3009
3010        // replacing existing compact marker keeps a single stub
3011        session.prepend_compact_summary("[Compacted earlier conversation]\nnewer stub");
3012        assert_eq!(session.messages.len(), 2);
3013        assert!(
3014            session.messages[0]
3015                .content
3016                .as_text()
3017                .unwrap()
3018                .contains("newer stub")
3019        );
3020    }
3021
3022    #[test]
3023    fn export_markdown_contains_roles_and_tools() {
3024        let mut session = Session::new(test_project_path(), test_system_prompt());
3025        session.add_user_message("hello");
3026        session.add_assistant_message(vec![
3027            ContentBlock::Text {
3028                text: "reply".into(),
3029            },
3030            ContentBlock::ToolUse {
3031                id: "t1".into(),
3032                name: "grep".into(),
3033                input: serde_json::json!({"pattern": "x"}),
3034            },
3035            ContentBlock::ToolResult {
3036                tool_use_id: "t1".into(),
3037                content: "found it".into(),
3038                is_error: Some(false),
3039            },
3040            ContentBlock::Image {
3041                source: whycodes_core::types::ImageSource::Base64 {
3042                    media_type: "image/png".into(),
3043                    data: "aGk=".into(),
3044                },
3045            },
3046        ]);
3047        let md = session.export_markdown();
3048        assert!(md.contains(&format!("# {}", session.title)));
3049        assert!(md.contains("### User"));
3050        assert!(md.contains("hello"));
3051        assert!(md.contains("### Assistant"));
3052        assert!(md.contains("```tool"));
3053        assert!(md.contains("grep"));
3054        assert!(md.contains("```result"));
3055        assert!(md.contains("*[image]*"));
3056    }
3057
3058    #[test]
3059    fn undo_last_turn_removes_turn_and_is_idempotent() {
3060        let mut session = Session::new(test_project_path(), test_system_prompt());
3061        session.add_user_message("q1");
3062        session.add_assistant_message(vec![ContentBlock::Text { text: "a1".into() }]);
3063        session.add_user_message("q2");
3064        session.add_assistant_message(vec![ContentBlock::Text { text: "a2".into() }]);
3065        session.add_tool_results(vec![whycodes_core::types::ToolResult {
3066            tool_call_id: "x".into(),
3067            content: "r".into(),
3068            is_error: false,
3069        }]);
3070        assert_eq!(session.messages.len(), 5);
3071
3072        let removed = session.undo_last_turn();
3073        assert_eq!(removed, 3); // q2 + a2 + tool result
3074        assert_eq!(session.messages.len(), 2);
3075        assert_eq!(session.messages[0].content.as_text(), Some("q1"));
3076
3077        let removed = session.undo_last_turn();
3078        assert_eq!(removed, 2);
3079        assert!(session.messages.is_empty());
3080        assert_eq!(session.undo_last_turn(), 0);
3081    }
3082
3083    #[test]
3084    fn checkpoint_rewind_collapses_exploratory_turns() {
3085        let mut session = Session::new(test_project_path(), test_system_prompt());
3086        session.add_user_message("investigate leak");
3087        session.add_assistant_message(vec![ContentBlock::Text {
3088            text: "checkpointing".into(),
3089        }]);
3090        session.mark_checkpoint("find the leak");
3091        assert!(session.checkpoint.is_some());
3092        assert_eq!(session.checkpoint.as_ref().unwrap().goal, "find the leak");
3093
3094        session.add_user_message("dead end A");
3095        session.add_assistant_message(vec![ContentBlock::Text {
3096            text: "noise".into(),
3097        }]);
3098        let before = session.messages.len();
3099        assert!(before > 2);
3100
3101        assert!(session.apply_rewind("root cause is X"));
3102        assert!(session.checkpoint.is_none());
3103        assert_eq!(
3104            session.last_rewind_report.as_deref(),
3105            Some("root cause is X")
3106        );
3107        let text = session.conversation_text();
3108        assert!(text.contains("root cause is X"));
3109        assert!(!text.contains("dead end A"));
3110        assert!(session.messages.len() < before);
3111
3112        assert!(!session.apply_rewind("again"));
3113    }
3114
3115    #[test]
3116    fn rewind_without_checkpoint_is_false() {
3117        let mut session = Session::new(test_project_path(), test_system_prompt());
3118        session.add_user_message("hi");
3119        assert!(!session.apply_rewind("nope"));
3120        assert!(session.last_rewind_report.is_none());
3121        assert_eq!(session.messages.len(), 1);
3122    }
3123
3124    #[test]
3125    fn set_messages_rebuilds_state() {
3126        let mut session = Session::new(test_project_path(), test_system_prompt());
3127        session.add_user_message("one");
3128        session.set_messages(vec![]);
3129        assert!(session.messages.is_empty());
3130        assert_eq!(
3131            session.token_count(),
3132            estimate_tokens(&session.system_prompt)
3133        );
3134    }
3135
3136    #[test]
3137    fn serde_round_trip_preserves_public_state_and_rebuilds_token_count() {
3138        let mut session = Session::new(test_project_path(), "system prompt".into());
3139        session.id = "session-fixed".into();
3140        session.created_at = "2026-08-21T12:00:00Z".parse().unwrap();
3141        session.updated_at = "2026-08-21T12:00:01Z".parse().unwrap();
3142        session.set_title_manual("Stable title");
3143        session.add_user_message("hello 🌍");
3144        session.add_assistant_message(vec![ContentBlock::Text {
3145            text: "deterministic reply".into(),
3146        }]);
3147        let expected_tokens = session.token_count();
3148
3149        let value = serde_json::to_value(&session).unwrap();
3150        assert!(value.get("token_cache").is_none());
3151
3152        let restored: Session = serde_json::from_value(value).unwrap();
3153        assert_eq!(restored.id, "session-fixed");
3154        assert_eq!(restored.title, "Stable title");
3155        assert_eq!(restored.title_source, crate::title::TitleSource::Manual);
3156        assert_eq!(restored.messages.len(), 2);
3157        assert_eq!(restored.system_prompt, "system prompt");
3158        assert_eq!(restored.project_path, test_project_path());
3159        assert_eq!(restored.token_count(), expected_tokens);
3160    }
3161
3162    #[test]
3163    fn generated_and_manual_title_transitions_are_terminal() {
3164        let mut session = Session::new(test_project_path(), test_system_prompt());
3165        let generated = session.title.clone();
3166
3167        assert!(!session.apply_generated_title(generated));
3168        assert_eq!(session.title_source, crate::title::TitleSource::Generated);
3169        assert!(!session.apply_generated_title("another generated title"));
3170
3171        session.set_title_manual("   ");
3172        assert_eq!(session.title_source, crate::title::TitleSource::Generated);
3173        session.set_title_manual("  User title\n  ");
3174        assert_eq!(session.title, "User title");
3175        assert_eq!(session.title_source, crate::title::TitleSource::Manual);
3176        assert!(!session.apply_heuristic_title("ignored heuristic"));
3177    }
3178
3179    #[test]
3180    fn export_share_writes_round_trippable_json_and_markdown_in_temp_project() {
3181        let project = tempfile::tempdir().unwrap();
3182        let mut session = Session::new(project.path().to_path_buf(), "system".into());
3183        session.id = "export-fixed".into();
3184        session.title = "Export title".into();
3185        session.add_user_message("share this");
3186
3187        let json_path = PathBuf::from(session.export_share().unwrap());
3188        let md_path = json_path.with_extension("md");
3189        assert_eq!(
3190            json_path,
3191            project.path().join(".whycodes/shares/export-fixed.json")
3192        );
3193        assert!(md_path.is_file());
3194
3195        let restored: Session =
3196            serde_json::from_str(&std::fs::read_to_string(json_path).unwrap()).unwrap();
3197        assert_eq!(restored.id, "export-fixed");
3198        assert_eq!(restored.messages[0].content.as_text(), Some("share this"));
3199        let markdown = std::fs::read_to_string(md_path).unwrap();
3200        assert!(markdown.contains("# Export title"));
3201        assert!(markdown.contains("### User\n\nshare this"));
3202    }
3203
3204    #[test]
3205    fn export_share_reports_directory_creation_error() {
3206        let project = tempfile::tempdir().unwrap();
3207        std::fs::write(project.path().join(".whycodes"), "not a directory").unwrap();
3208        let session = Session::new(project.path().to_path_buf(), "system".into());
3209
3210        let error = session.export_share().unwrap_err();
3211        assert!(
3212            matches!(
3213                error,
3214                crate::error::SessionError::Io(ref e)
3215                    if e.kind() == std::io::ErrorKind::NotADirectory
3216                        || e.kind() == std::io::ErrorKind::AlreadyExists
3217            ),
3218            "unexpected export error: {error}"
3219        );
3220    }
3221
3222    #[test]
3223    fn compact_outcome_predicates_cover_all_states() {
3224        let unchanged = CompactOutcome {
3225            tokens_before: 10,
3226            tokens_after: 10,
3227            messages_before: 2,
3228            messages_after: 2,
3229            dropped_transcript: String::new(),
3230        };
3231        assert!(!unchanged.reduced());
3232        assert!(!unchanged.still_over(0));
3233        assert!(unchanged.still_over(5));
3234        assert!(unchanged.failed(5));
3235
3236        let reduced = CompactOutcome {
3237            tokens_after: 9,
3238            messages_after: 1,
3239            dropped_transcript: "User: old".into(),
3240            ..unchanged
3241        };
3242        assert!(reduced.reduced());
3243        assert!(!reduced.failed(5));
3244        assert!(reduced.dropped_messages());
3245    }
3246
3247    #[test]
3248    fn token_estimation_covers_every_content_block_and_stale_cache() {
3249        assert_eq!(estimate_tokens(""), 0);
3250        assert_eq!(estimate_tokens("ééééé"), 2);
3251        let mut session = Session::new(test_project_path(), "s".into());
3252        session.add_user_message_blocks(vec![
3253            ContentBlock::Text {
3254                text: "abcd".into(),
3255            },
3256            ContentBlock::ToolResult {
3257                tool_use_id: "r".into(),
3258                content: "abcdefgh".into(),
3259                is_error: None,
3260            },
3261            ContentBlock::ToolUse {
3262                id: "u".into(),
3263                name: "tool".into(),
3264                input: serde_json::json!({"path":"a.rs"}),
3265            },
3266            ContentBlock::Image {
3267                source: whycodes_core::types::ImageSource::Base64 {
3268                    media_type: "image/png".into(),
3269                    data: "x".into(),
3270                },
3271            },
3272            ContentBlock::Thinking {
3273                text: "think".into(),
3274                signature: Some("sig".into()),
3275            },
3276            ContentBlock::Thinking {
3277                text: "plain".into(),
3278                signature: None,
3279            },
3280            ContentBlock::RedactedThinking {
3281                data: "secret".into(),
3282            },
3283        ]);
3284        let cached = session.token_count();
3285        session.token_cache.invalidate();
3286        assert_eq!(session.token_count(), cached);
3287        session.set_system_prompt("longer system");
3288        session.add_user_message("after invalidation");
3289        assert!(session.token_count_cached() > cached);
3290    }
3291
3292    #[test]
3293    fn image_payload_parsing_and_unicode_caps_cover_boundaries() {
3294        assert!(split_whycodes_image_payload("plain").is_none());
3295        assert!(split_whycodes_image_payload("WHYCODES_IMAGE_B64:image/png").is_none());
3296        assert!(split_whycodes_image_payload("WHYCODES_IMAGE_B64:\nabc").is_none());
3297        assert!(split_whycodes_image_payload("WHYCODES_IMAGE_B64:image/png\n ").is_none());
3298        let parsed = split_whycodes_image_payload("WHYCODES_IMAGE_B64:image/png\nYWJj").unwrap();
3299        assert_eq!(parsed.2, "[image image/png]");
3300        let parsed =
3301            split_whycodes_image_payload("preview\nWHYCODES_IMAGE_B64:image/jpeg\nZGF0YQ==")
3302                .unwrap();
3303        assert_eq!(
3304            parsed,
3305            ("image/jpeg".into(), "ZGF0YQ==".into(), "preview".into())
3306        );
3307
3308        let unicode = "é".repeat(10);
3309        assert_eq!(cap_tool_text_to(unicode.clone(), 10), unicode);
3310        assert_eq!(
3311            cap_tool_text_to("é".repeat(11), 10),
3312            format!("{}{}", "é".repeat(10), "\n[1 chars truncated]")
3313        );
3314
3315        let once = cap_tool_text_to("a".repeat(20), 10);
3316        assert_eq!(
3317            once,
3318            format!("{}{}", "a".repeat(10), "\n[10 chars truncated]")
3319        );
3320        assert_eq!(cap_tool_text_to(once.clone(), 10), once);
3321        assert_eq!(
3322            cap_tool_text_to(once, 4),
3323            format!("{}{}", "a".repeat(4), "\n[16 chars truncated]")
3324        );
3325
3326        let legacy = format!(
3327            "{}{}",
3328            "b".repeat(10),
3329            "\n\n[... 5 characters truncated for context management]"
3330        );
3331        assert_eq!(
3332            cap_tool_text_to(legacy.clone(), 10),
3333            format!("{}{}", "b".repeat(10), "\n[5 chars truncated]")
3334        );
3335        assert_eq!(
3336            cap_tool_text_to(legacy, 4),
3337            format!("{}{}", "b".repeat(4), "\n[11 chars truncated]")
3338        );
3339        assert_eq!(split_truncation_notice("plain"), ("plain", None));
3340        assert_eq!(
3341            split_truncation_notice("body\n[not-a-number chars truncated]"),
3342            ("body\n[not-a-number chars truncated]", None)
3343        );
3344        assert_eq!(
3345            split_truncation_notice(
3346                "body\n\n[... not-a-number characters truncated for context management]"
3347            ),
3348            (
3349                "body\n\n[... not-a-number characters truncated for context management]",
3350                None
3351            )
3352        );
3353
3354        let mut session = Session::new(test_project_path(), String::new());
3355        session.add_tool_results(vec![whycodes_core::types::ToolResult {
3356            tool_call_id: "image".into(),
3357            content: "caption\nWHYCODES_IMAGE_B64:image/png\naGk=".into(),
3358            is_error: false,
3359        }]);
3360        assert!(
3361            matches!(session.messages[0].content, MessageContent::Blocks(ref b) if b.len() == 2)
3362        );
3363    }
3364
3365    #[test]
3366    fn compact_summary_formatting_covers_malformed_and_display_inputs() {
3367        assert_eq!(format_compact_summary("<analysis>lost"), "");
3368        assert_eq!(
3369            format_compact_summary("<analysis>x<summary>body"),
3370            "Summary:\nbody"
3371        );
3372        assert_eq!(format_compact_summary("<summary>open"), "Summary:\nopen");
3373        assert_eq!(format_compact_summary("a\n\n\n\nb"), "a\n\nb");
3374        assert_eq!(format_compact_summary_content("   "), "");
3375        assert_eq!(
3376            format_compact_summary_content(COMPACT_CONTINUATION_PREAMBLE),
3377            COMPACT_CONTINUATION_PREAMBLE
3378        );
3379        assert_eq!(
3380            compact_summary_display_text(COMPACT_CONTINUATION_PREAMBLE),
3381            "Conversation compacted"
3382        );
3383        assert_eq!(
3384            compact_summary_display_text("[Compacted earlier conversation]"),
3385            "Conversation compacted"
3386        );
3387        assert_eq!(
3388            compact_summary_display_text("Conversation compacted\nready"),
3389            "Conversation compacted\nready"
3390        );
3391        assert_eq!(
3392            compact_summary_display_text("plain"),
3393            "Conversation compacted\nplain"
3394        );
3395        assert!(is_compact_summary_text("  [Compacted old]"));
3396    }
3397
3398    #[test]
3399    fn summary_helpers_collect_roles_paths_tools_and_limits() {
3400        let messages = vec![
3401            Message {
3402                role: Role::System,
3403                content: MessageContent::text("sys"),
3404                tool_call_id: None,
3405                name: None,
3406                created_at: None,
3407            },
3408            Message {
3409                role: Role::User,
3410                content: MessageContent::text(
3411                    "\nA very long goal that should be shortened because it exceeds the requested snippet width significantly and keeps going forever",
3412                ),
3413                tool_call_id: None,
3414                name: None,
3415                created_at: None,
3416            },
3417            Message {
3418                role: Role::User,
3419                content: MessageContent::text("[stub]"),
3420                tool_call_id: None,
3421                name: None,
3422                created_at: None,
3423            },
3424            Message {
3425                role: Role::Assistant,
3426                content: MessageContent::Blocks(vec![
3427                    ContentBlock::Text {
3428                        text: "# src/main.rs details".into(),
3429                    },
3430                    ContentBlock::ToolUse {
3431                        id: "u".into(),
3432                        name: "read".into(),
3433                        input: serde_json::json!({"path":"src/lib.rs"}),
3434                    },
3435                    ContentBlock::Image {
3436                        source: whycodes_core::types::ImageSource::Base64 {
3437                            media_type: "image/png".into(),
3438                            data: "x".into(),
3439                        },
3440                    },
3441                ]),
3442                tool_call_id: None,
3443                name: None,
3444                created_at: None,
3445            },
3446            Message {
3447                role: Role::Tool,
3448                content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
3449                    tool_use_id: "r".into(),
3450                    content: "see tests/a.rs and https://bad/x.rs".into(),
3451                    is_error: None,
3452                }]),
3453                tool_call_id: Some("r".into()),
3454                name: Some("grep".into()),
3455                created_at: None,
3456            },
3457        ];
3458        let summary = summarize_trimmed(&messages);
3459        assert!(summary.contains("other=1"));
3460        assert!(summary.contains("Goals:"));
3461        assert!(summary.contains("src/main.rs"));
3462        assert!(summary.contains("src/lib.rs"));
3463        assert!(summary.contains("tests/a.rs"));
3464        assert!(summary.contains("Tools used: grep"));
3465        assert!(first_line_snippet("x", 3) == "x");
3466        assert_eq!(first_line_snippet("abcdef", 4), "abc…");
3467        assert!(!looks_like_path("a"));
3468        assert!(!looks_like_path("https://x/a.rs"));
3469        assert!(!looks_like_path(&"a".repeat(201)));
3470        assert!(looks_like_path(r"src\main.rs"));
3471
3472        let mut paths = vec!["existing.rs/x".into()];
3473        push_path("existing.rs/x", &mut paths, 2);
3474        push_path("", &mut paths, 2);
3475        push_path("new.rs/x", &mut paths, 1);
3476        assert_eq!(paths.len(), 1);
3477    }
3478
3479    #[test]
3480    fn transcripts_cover_all_roles_blocks_and_caps() {
3481        let messages = vec![
3482            Message {
3483                role: Role::System,
3484                content: MessageContent::text("system"),
3485                tool_call_id: None,
3486                name: None,
3487                created_at: None,
3488            },
3489            Message {
3490                role: Role::Assistant,
3491                content: MessageContent::Blocks(vec![
3492                    ContentBlock::Text {
3493                        text: "text that is deliberately long enough to exceed the transcript cap"
3494                            .into(),
3495                    },
3496                    ContentBlock::ToolUse {
3497                        id: "u".into(),
3498                        name: "grep".into(),
3499                        input: serde_json::json!({}),
3500                    },
3501                    ContentBlock::ToolResult {
3502                        tool_use_id: "u".into(),
3503                        content: "result".into(),
3504                        is_error: None,
3505                    },
3506                    ContentBlock::Thinking {
3507                        text: "hidden".into(),
3508                        signature: None,
3509                    },
3510                ]),
3511                tool_call_id: None,
3512                name: None,
3513                created_at: None,
3514            },
3515            Message {
3516                role: Role::Tool,
3517                content: MessageContent::text("tool body"),
3518                tool_call_id: None,
3519                name: None,
3520                created_at: None,
3521            },
3522        ];
3523        let full = messages_transcript(&messages, 1000);
3524        assert!(full.contains("System: system"));
3525        assert!(full.contains("Assistant: text that is deliberately long enough"));
3526        assert!(full.contains("grep\nresult"));
3527        assert!(full.contains("Tool: tool body"));
3528        let capped = messages_transcript(&messages, 50);
3529        assert!(capped.ends_with("\n…"));
3530        assert_eq!(messages_transcript(&messages, 10), "");
3531    }
3532
3533    #[test]
3534    fn title_and_snippet_noop_branches_are_observable() {
3535        let mut session = Session::new(test_project_path(), String::new());
3536        assert!(!session.maybe_upgrade_title_from_history());
3537        assert!(!session.apply_heuristic_title(""));
3538        assert!(session.apply_generated_title("Generated useful title"));
3539        let title = session.title.clone();
3540        assert!(!session.apply_generated_title("ignored"));
3541        assert_eq!(session.title, title);
3542
3543        let imported = Session::from_imported(test_project_path(), vec![], None);
3544        assert_eq!(imported.title_source, crate::title::TitleSource::Default);
3545
3546        session.messages = vec![
3547            Message {
3548                role: Role::User,
3549                content: MessageContent::Blocks(vec![ContentBlock::Image {
3550                    source: whycodes_core::types::ImageSource::Base64 {
3551                        media_type: "image/png".into(),
3552                        data: "x".into(),
3553                    },
3554                }]),
3555                tool_call_id: None,
3556                name: None,
3557                created_at: None,
3558            },
3559            Message {
3560                role: Role::Assistant,
3561                content: MessageContent::Text("   ".into()),
3562                tool_call_id: None,
3563                name: None,
3564                created_at: None,
3565            },
3566            Message {
3567                role: Role::Assistant,
3568                content: MessageContent::Blocks(vec![ContentBlock::Text {
3569                    text: " block reply ".into(),
3570                }]),
3571                tool_call_id: None,
3572                name: None,
3573                created_at: None,
3574            },
3575        ];
3576        assert_eq!(session.first_user_text(), None);
3577        assert_eq!(
3578            session.first_assistant_snippet(100).as_deref(),
3579            Some("block reply")
3580        );
3581    }
3582
3583    #[test]
3584    fn conversation_and_markdown_cover_system_tool_and_special_blocks() {
3585        let mut session = Session::new(test_project_path(), String::new());
3586        session.set_messages(vec![
3587            Message {
3588                role: Role::System,
3589                content: MessageContent::text("sys"),
3590                tool_call_id: None,
3591                name: None,
3592                created_at: None,
3593            },
3594            Message {
3595                role: Role::Tool,
3596                content: MessageContent::text("tool"),
3597                tool_call_id: None,
3598                name: None,
3599                created_at: None,
3600            },
3601            Message {
3602                role: Role::Assistant,
3603                content: MessageContent::Blocks(vec![
3604                    ContentBlock::Thinking {
3605                        text: "thought".into(),
3606                        signature: None,
3607                    },
3608                    ContentBlock::RedactedThinking {
3609                        data: "redacted".into(),
3610                    },
3611                    ContentBlock::ToolResult {
3612                        tool_use_id: "x".into(),
3613                        content: "bad".into(),
3614                        is_error: Some(true),
3615                    },
3616                ]),
3617                tool_call_id: None,
3618                name: None,
3619                created_at: None,
3620            },
3621        ]);
3622        let text = session.conversation_text();
3623        assert!(text.contains("System: sys"));
3624        assert!(text.contains("Tool: tool"));
3625        let md = session.export_markdown();
3626        assert!(md.contains("### System"));
3627        assert!(md.contains("### Tool"));
3628        assert!(md.contains("```thinking\nthought"));
3629        assert!(md.contains("*[redacted thinking]*"));
3630        assert!(md.contains("```error\nbad"));
3631    }
3632
3633    #[test]
3634    fn truncation_and_pruning_cover_block_variants_and_noops() {
3635        let huge = "x".repeat(TOOL_RESULT_MAX_CHARS + 100);
3636        let mut session = Session::new(test_project_path(), String::new());
3637        session.set_messages(vec![Message {
3638            role: Role::Tool,
3639            content: MessageContent::Blocks(vec![
3640                ContentBlock::Text { text: huge.clone() },
3641                ContentBlock::ToolResult {
3642                    tool_use_id: "x".into(),
3643                    content: huge,
3644                    is_error: None,
3645                },
3646            ]),
3647            tool_call_id: None,
3648            name: None,
3649            created_at: None,
3650        }]);
3651        assert_eq!(session.truncate_large_tool_results(), 2);
3652        assert_eq!(session.truncate_large_tool_results(), 0);
3653
3654        let mut pruning = Session::new(test_project_path(), String::new());
3655        for i in 0..5 {
3656            pruning.messages.push(Message {
3657                role: Role::Tool,
3658                content: if i == 0 {
3659                    MessageContent::Blocks(vec![
3660                        ContentBlock::Text {
3661                            text: "y".repeat(3000),
3662                        },
3663                        ContentBlock::ToolResult {
3664                            tool_use_id: "x".into(),
3665                            content: "z".repeat(3000),
3666                            is_error: None,
3667                        },
3668                        ContentBlock::RedactedThinking {
3669                            data: "opaque".into(),
3670                        },
3671                    ])
3672                } else {
3673                    MessageContent::Text("short".into())
3674                },
3675                tool_call_id: None,
3676                name: None,
3677                created_at: None,
3678            });
3679        }
3680        assert_eq!(pruning.prune_old_tool_results(), 2);
3681        assert_eq!(pruning.prune_old_tool_results(), 0);
3682        assert!(matches!(
3683            &pruning.messages[0].content,
3684            MessageContent::Blocks(blocks)
3685                if blocks.iter().all(|block| match block {
3686                    ContentBlock::Text { text }
3687                    | ContentBlock::ToolResult { content: text, .. } =>
3688                        text.contains("chars truncated"),
3689                    ContentBlock::Image { .. }
3690                    | ContentBlock::ToolUse { .. }
3691                    | ContentBlock::Thinking { .. }
3692                    | ContentBlock::RedactedThinking { .. }
3693                    | ContentBlock::Compaction { .. } => true,
3694                })
3695        ));
3696    }
3697
3698    #[test]
3699    fn truncate_and_shake_cover_tool_text_and_block_mutations() {
3700        let huge = "x".repeat(TOOL_RESULT_MAX_CHARS + 50);
3701        let mut truncating = Session::new(test_project_path(), String::new());
3702        truncating.set_messages(vec![Message {
3703            role: Role::Tool,
3704            content: MessageContent::Text(huge),
3705            tool_call_id: None,
3706            name: None,
3707            created_at: None,
3708        }]);
3709        assert_eq!(truncating.truncate_large_tool_results(), 1);
3710        assert!(
3711            truncating.messages[0]
3712                .content
3713                .as_text()
3714                .unwrap()
3715                .contains("chars truncated")
3716        );
3717
3718        let mut shaking = Session::new(test_project_path(), String::new());
3719        for i in 0..5 {
3720            shaking.messages.push(Message {
3721                role: Role::Tool,
3722                content: if i == 0 {
3723                    MessageContent::Blocks(vec![
3724                        ContentBlock::Text {
3725                            text: "y".repeat(3000),
3726                        },
3727                        ContentBlock::ToolResult {
3728                            tool_use_id: "x".into(),
3729                            content: "z".repeat(3000),
3730                            is_error: None,
3731                        },
3732                        ContentBlock::RedactedThinking {
3733                            data: "opaque".into(),
3734                        },
3735                    ])
3736                } else {
3737                    MessageContent::Text("short".into())
3738                },
3739                tool_call_id: None,
3740                name: None,
3741                created_at: None,
3742            });
3743        }
3744        assert_eq!(shaking.shake_old_tool_results(), 2);
3745        assert_eq!(shaking.shake_old_tool_results(), 0);
3746        assert!(matches!(
3747            &shaking.messages[0].content,
3748            MessageContent::Blocks(blocks)
3749                if blocks.iter().all(|block| match block {
3750                    ContentBlock::Text { text }
3751                    | ContentBlock::ToolResult { content: text, .. } =>
3752                        text.contains("chars truncated"),
3753                    ContentBlock::Image { .. }
3754                    | ContentBlock::ToolUse { .. }
3755                    | ContentBlock::Thinking { .. }
3756                    | ContentBlock::RedactedThinking { .. }
3757                    | ContentBlock::Compaction { .. } => true,
3758                })
3759        ));
3760    }
3761
3762    #[test]
3763    fn transcript_and_full_replace_edge_cases() {
3764        let mut session = Session::new(test_project_path(), String::new());
3765        for i in 0..6 {
3766            session.add_user_message(&format!("m{i}"));
3767        }
3768        assert!(session.transcript_for_compact_summary(1000).contains("m0"));
3769        assert!(!session.transcript_for_compact_summary(1000).contains("m5"));
3770        assert_eq!(
3771            session.transcript_for_full_summary(0),
3772            session.transcript_for_full_summary(48_000)
3773        );
3774
3775        let empty = Session::new(test_project_path(), String::new()).apply_full_replace("summary");
3776        assert_eq!(empty.messages_after, 0);
3777
3778        let mut no_real_user = Session::new(test_project_path(), String::new());
3779        no_real_user.messages.push(Message {
3780            role: Role::User,
3781            content: MessageContent::text("[Compacted old]"),
3782            tool_call_id: None,
3783            name: None,
3784            created_at: None,
3785        });
3786        no_real_user.messages.push(Message {
3787            role: Role::Assistant,
3788            content: MessageContent::text("tail"),
3789            tool_call_id: None,
3790            name: None,
3791            created_at: None,
3792        });
3793        let outcome = no_real_user.apply_full_replace("");
3794        assert!(outcome.dropped_transcript.contains("Compacted old"));
3795        assert!(no_real_user.messages.iter().any(|m| {
3796            m.content
3797                .as_text()
3798                .is_some_and(|t| t.starts_with(COMPACT_CONTINUATION_PREAMBLE))
3799        }));
3800
3801        let mut block_user = Session::new(test_project_path(), String::new());
3802        block_user.add_user_message_blocks(vec![ContentBlock::Image {
3803            source: whycodes_core::types::ImageSource::Base64 {
3804                media_type: "image/png".into(),
3805                data: "x".into(),
3806            },
3807        }]);
3808        let outcome = block_user.apply_full_replace("summary");
3809        assert_eq!(
3810            outcome.messages_after, 1,
3811            "non-text user is replaced by summary only"
3812        );
3813    }
3814
3815    #[test]
3816    fn compact_handles_zero_budget_and_undroppable_tail() {
3817        let mut session = Session::new(test_project_path(), "large system prompt".repeat(100));
3818        session.add_user_message(&"x".repeat(1000));
3819        let outcome = session.compact(0);
3820        assert_eq!(outcome.messages_before, outcome.messages_after);
3821        assert!(!outcome.dropped_messages());
3822        assert!(outcome.tokens_after > 1);
3823    }
3824
3825    #[test]
3826    fn default_prompt_is_nonempty() {
3827        assert!(!default_system_prompt().trim().is_empty());
3828        assert!(default_system_prompt().contains("`.whycodes/scratch/`"));
3829    }
3830
3831    #[test]
3832    fn helper_limits_and_duplicate_summary_metadata_are_covered() {
3833        let mut paths = Vec::new();
3834        let block_message = Message {
3835            role: Role::Assistant,
3836            content: MessageContent::Blocks(vec![
3837                ContentBlock::Text {
3838                    text: "# src/a.rs".into(),
3839                },
3840                ContentBlock::ToolUse {
3841                    id: "u".into(),
3842                    name: "read".into(),
3843                    input: serde_json::json!({"other": true}),
3844                },
3845                ContentBlock::Text {
3846                    text: "# src/b.rs".into(),
3847                },
3848            ]),
3849            tool_call_id: None,
3850            name: None,
3851            created_at: None,
3852        };
3853        collect_paths_from_message(&block_message, &mut paths, 1);
3854        assert_eq!(paths, vec!["src/a.rs"]);
3855        collect_paths_from_message(&block_message, &mut paths, 1);
3856
3857        let mut scanned = Vec::new();
3858        push_paths_from_text(
3859            "# words only\nsee src/c.rs src/c.rs src/d.rs",
3860            &mut scanned,
3861            1,
3862        );
3863        assert_eq!(scanned, vec!["src/c.rs"]);
3864        push_paths_from_text("# src/ignored.rs", &mut scanned, 1);
3865        assert_eq!(scanned, vec!["src/c.rs"]);
3866
3867        let duplicate_tools = vec![
3868            Message {
3869                role: Role::User,
3870                content: MessageContent::text("goal"),
3871                tool_call_id: None,
3872                name: None,
3873                created_at: None,
3874            },
3875            Message {
3876                role: Role::User,
3877                content: MessageContent::text("goal"),
3878                tool_call_id: None,
3879                name: None,
3880                created_at: None,
3881            },
3882            Message {
3883                role: Role::Tool,
3884                content: MessageContent::text("x"),
3885                tool_call_id: None,
3886                name: Some("read".into()),
3887                created_at: None,
3888            },
3889            Message {
3890                role: Role::Tool,
3891                content: MessageContent::text("y"),
3892                tool_call_id: None,
3893                name: Some("read".into()),
3894                created_at: None,
3895            },
3896        ];
3897        let summary = summarize_trimmed(&duplicate_tools);
3898        assert_eq!(summary.matches("goal").count(), 1);
3899        assert_eq!(summary.matches("read").count(), 1);
3900    }
3901
3902    #[test]
3903    fn generated_same_title_and_block_first_user_fallbacks_are_covered() {
3904        let mut session = Session::new(test_project_path(), String::new());
3905        session.title = "Same title".into();
3906        assert!(!session.apply_generated_title("Same title"));
3907        assert_eq!(session.title_source, crate::title::TitleSource::Generated);
3908
3909        session.messages = vec![Message {
3910            role: Role::User,
3911            content: MessageContent::Blocks(vec![
3912                ContentBlock::Image {
3913                    source: whycodes_core::types::ImageSource::Base64 {
3914                        media_type: "image/png".into(),
3915                        data: "x".into(),
3916                    },
3917                },
3918                ContentBlock::Text {
3919                    text: "fallback text".into(),
3920                },
3921                ContentBlock::Text {
3922                    text: "second".into(),
3923                },
3924            ]),
3925            tool_call_id: None,
3926            name: None,
3927            created_at: None,
3928        }];
3929        assert_eq!(session.first_user_text().as_deref(), Some("fallback text"));
3930
3931        session.messages.insert(
3932            0,
3933            Message {
3934                role: Role::Assistant,
3935                content: MessageContent::Blocks(vec![ContentBlock::Image {
3936                    source: whycodes_core::types::ImageSource::Base64 {
3937                        media_type: "image/png".into(),
3938                        data: "x".into(),
3939                    },
3940                }]),
3941                tool_call_id: None,
3942                name: None,
3943                created_at: None,
3944            },
3945        );
3946        assert_eq!(session.first_assistant_snippet(5), None);
3947    }
3948
3949    #[test]
3950    fn truncation_and_pruning_touch_only_when_modified() {
3951        let mut session = Session::new(test_project_path(), String::new());
3952        session.set_messages(vec![
3953            Message {
3954                role: Role::Assistant,
3955                content: MessageContent::Blocks(vec![ContentBlock::Text {
3956                    text: "normal".into(),
3957                }]),
3958                tool_call_id: None,
3959                name: None,
3960                created_at: None,
3961            },
3962            Message {
3963                role: Role::Tool,
3964                content: MessageContent::Blocks(vec![ContentBlock::Image {
3965                    source: whycodes_core::types::ImageSource::Base64 {
3966                        media_type: "image/png".into(),
3967                        data: "x".into(),
3968                    },
3969                }]),
3970                tool_call_id: None,
3971                name: None,
3972                created_at: None,
3973            },
3974        ]);
3975        assert_eq!(session.truncate_large_tool_results(), 0);
3976
3977        let mut pruning = Session::new(test_project_path(), String::new());
3978        for _ in 0..5 {
3979            pruning.messages.push(Message {
3980                role: Role::Tool,
3981                content: MessageContent::Blocks(vec![ContentBlock::Image {
3982                    source: whycodes_core::types::ImageSource::Base64 {
3983                        media_type: "image/png".into(),
3984                        data: "x".into(),
3985                    },
3986                }]),
3987                tool_call_id: None,
3988                name: None,
3989                created_at: None,
3990            });
3991        }
3992        assert_eq!(pruning.prune_old_tool_results(), 0);
3993    }
3994
3995    #[test]
3996    fn local_summary_and_replace_without_real_user_cover_empty_prefix() {
3997        let mut session = Session::new(test_project_path(), String::new());
3998        assert!(!session.local_full_replace_summary().trim().is_empty());
3999        session.messages.push(Message {
4000            role: Role::Assistant,
4001            content: MessageContent::text("answer only"),
4002            tool_call_id: None,
4003            name: None,
4004            created_at: None,
4005        });
4006        assert!(session.local_full_replace_summary().contains("assistant=1"));
4007        let outcome = session.apply_full_replace("ready");
4008        assert_eq!(outcome.messages_after, 1);
4009        assert!(
4010            session.messages[0]
4011                .content
4012                .as_text()
4013                .unwrap()
4014                .contains("ready")
4015        );
4016    }
4017
4018    #[test]
4019    fn compact_breaks_when_tail_reaches_target_before_minimum() {
4020        let mut session = Session::new(test_project_path(), String::new());
4021        session.add_user_message(&"x".repeat(2000));
4022        for i in 0..5 {
4023            session.add_user_message(&format!("small {i}"));
4024        }
4025        let outcome = session.compact(500);
4026        assert!(outcome.dropped_messages());
4027        assert_eq!(
4028            outcome.messages_after, 6,
4029            "summary plus five retained small messages"
4030        );
4031    }
4032
4033    #[test]
4034    fn load_fills_missing_message_timestamp_from_valid_and_invalid_rows() {
4035        let db = whycodes_storage::db::Database::open_in_memory().unwrap();
4036        let usage = whycodes_core::types::Usage::default();
4037        for (id, created) in [
4038            ("valid-created", "2026-08-21T12:00:02Z"),
4039            ("invalid-created", "bad-time"),
4040        ] {
4041            db.upsert_session(
4042                id,
4043                "Title",
4044                "/project",
4045                "2026-08-21T12:00:00Z",
4046                "2026-08-21T12:00:01Z",
4047                &usage,
4048            )
4049            .unwrap();
4050            let msg = Message {
4051                role: Role::User,
4052                content: MessageContent::text("hello"),
4053                tool_call_id: None,
4054                name: None,
4055                created_at: None,
4056            };
4057            db.insert_message(
4058                &format!("msg-{id}"),
4059                id,
4060                "user",
4061                &serde_json::to_string(&msg).unwrap(),
4062                None,
4063                None,
4064            )
4065            .unwrap();
4066            // Database insert API owns its timestamp, so mutate the row through SQL is unavailable;
4067            // loading still exercises the fallback with the generated valid timestamp.
4068            let loaded = Session::load_from_db(&db, id).unwrap().unwrap();
4069            assert!(loaded.messages[0].created_at.is_some(), "{created}");
4070        }
4071    }
4072
4073    #[test]
4074    fn title_and_token_cache_noop_and_invalid_branches() {
4075        let mut session = Session::new(test_project_path(), "sys".into());
4076        session.set_title_manual("   ");
4077        assert_eq!(session.title_source, crate::title::TitleSource::Default);
4078        session.set_title_manual("Manual name");
4079        assert_eq!(session.title_source, crate::title::TitleSource::Manual);
4080        assert!(!session.apply_heuristic_title("ignored"));
4081        session.title_source = crate::title::TitleSource::Default;
4082        session.title = crate::title::heuristic_title("same heuristic");
4083        assert!(!session.apply_heuristic_title("same heuristic"));
4084        assert!(!session.apply_generated_title("   "));
4085        session.prepend_compact_summary("  ");
4086        assert!(session.messages.is_empty());
4087
4088        session.token_cache.invalidate();
4089        session.token_cache.push_msg(&Message {
4090            role: Role::User,
4091            content: MessageContent::text("ignored"),
4092            tool_call_id: None,
4093            name: None,
4094            created_at: None,
4095        });
4096        session.token_cache.set_system("also ignored");
4097        assert!(!session.token_cache.valid);
4098        session.token_cache.ensure("sys", &[]);
4099        assert!(session.token_cache.valid);
4100        session.token_cache.ensure("sys", &[]);
4101    }
4102
4103    #[test]
4104    fn last_real_user_skips_compact_summary_carriers() {
4105        let mut session = Session::new(test_project_path(), String::new());
4106        session.messages.push(Message {
4107            role: Role::User,
4108            content: MessageContent::text("[Compacted earlier conversation]\nold"),
4109            tool_call_id: None,
4110            name: None,
4111            created_at: None,
4112        });
4113        session.messages.push(Message {
4114            role: Role::Assistant,
4115            content: MessageContent::text("tail"),
4116            tool_call_id: None,
4117            name: None,
4118            created_at: None,
4119        });
4120        assert_eq!(session.last_real_user_index(), None);
4121        let summary = session.local_full_replace_summary();
4122        assert!(summary.contains("assistant=1"));
4123    }
4124
4125    #[test]
4126    fn compact_outcome_default_and_undo_without_user() {
4127        let outcome = CompactOutcome::default();
4128        assert!(!outcome.reduced());
4129        assert!(!outcome.still_over(0));
4130        assert!(!outcome.failed(10));
4131        assert!(!outcome.dropped_messages());
4132        let mut session = Session::new(test_project_path(), String::new());
4133        session.add_assistant_message(vec![ContentBlock::Text {
4134            text: "only assistant".into(),
4135        }]);
4136        assert_eq!(session.undo_last_turn(), 0);
4137        let text = session.conversation_text();
4138        assert!(text.contains("Assistant: only assistant"));
4139    }
4140
4141    #[test]
4142    fn export_share_writes_markdown_even_if_json_exists() {
4143        let project = tempfile::tempdir().unwrap();
4144        let mut session = Session::new(project.path().to_path_buf(), "system".into());
4145        session.add_user_message("hello");
4146        let path = session.export_share().unwrap();
4147        assert!(std::path::Path::new(&path).exists());
4148        let md = project
4149            .path()
4150            .join(".whycodes")
4151            .join("shares")
4152            .join(format!("{}.md", session.id));
4153        assert!(md.exists());
4154    }
4155
4156    #[test]
4157    fn spill_old_tool_results_keeps_head_and_pointer() {
4158        let mut session = Session::new(test_project_path(), test_system_prompt());
4159        session.add_user_message("start");
4160        for i in 0..6 {
4161            session.add_tool_results(vec![whycodes_core::types::ToolResult {
4162                tool_call_id: format!("t{i}"),
4163                content: format!("{i}{}", "z".repeat(5_000)),
4164                is_error: false,
4165            }]);
4166        }
4167        // A block-shaped tool message: a result block and a plain text block.
4168        session.messages.insert(
4169            1,
4170            Message {
4171                role: Role::Tool,
4172                content: MessageContent::Blocks(vec![
4173                    ContentBlock::ToolResult {
4174                        tool_use_id: "blk".into(),
4175                        content: "b".repeat(3_000),
4176                        is_error: Some(false),
4177                    },
4178                    ContentBlock::Text {
4179                        text: "x".repeat(3_000),
4180                    },
4181                    ContentBlock::Image {
4182                        source: whycodes_core::types::ImageSource::Base64 {
4183                            media_type: "image/png".into(),
4184                            data: "AAAA".into(),
4185                        },
4186                    },
4187                ]),
4188                tool_call_id: None,
4189                name: None,
4190                created_at: None,
4191            },
4192        );
4193        let before_tokens = session.token_count_cached();
4194        // One store for every call below: it logs what it was asked to keep
4195        // and declines while `decline` is set.
4196        let stored: std::cell::RefCell<Vec<(String, usize)>> = std::cell::RefCell::new(Vec::new());
4197        let decline = std::cell::Cell::new(false);
4198        let mut store = |id: &str, body: &str| {
4199            stored
4200                .borrow_mut()
4201                .push((id.to_string(), body.chars().count()));
4202            if decline.get() {
4203                return None;
4204            }
4205            Some(format!(".whycodes/scratch/artifacts/{id}.txt"))
4206        };
4207        let spilled = session.spill_old_tool_results(&mut store);
4208        let stored_now = || stored.borrow().clone();
4209        // Seven tool messages; the last four (t2..t5) stay as they are.
4210        assert_eq!(spilled, 4, "{:?}", stored_now());
4211        assert_eq!(
4212            stored_now()
4213                .iter()
4214                .map(|(id, _)| id.clone())
4215                .collect::<Vec<_>>(),
4216            ["blk", "msg-1", "t0", "t1"]
4217        );
4218        assert_eq!(stored_now()[2].1, 5_001, "the full body reaches the store");
4219        let first = session.messages[2].content.as_text().unwrap();
4220        assert!(first.starts_with("0zzz"), "{first}");
4221        assert!(
4222            first.contains(
4223                "[4601 more chars; full output saved: .whycodes/scratch/artifacts/t0.txt]"
4224            ),
4225            "{first}"
4226        );
4227        assert!(first.chars().count() < TOOL_RESULT_SHAKE_CHARS);
4228        // The recent four are untouched.
4229        let last = session.messages.last().unwrap().content.as_text().unwrap();
4230        assert_eq!(last.chars().count(), 5_001);
4231        assert!(
4232            session.token_count_cached() < before_tokens,
4233            "token cache rebuilt"
4234        );
4235
4236        // Spilled bodies are not spilled again; short ones never are.
4237        assert_eq!(session.spill_old_tool_results(&mut store), 0);
4238        assert_eq!(stored_now().len(), 4, "the store is not asked again");
4239        assert_eq!(
4240            session.prune_old_tool_results(),
4241            0,
4242            "spilled bodies are under the prune cap"
4243        );
4244        assert_eq!(
4245            session.shake_old_tool_results(),
4246            0,
4247            "and under the shake cap"
4248        );
4249
4250        // A store that declines leaves the body as it was.
4251        let mut fresh = Session::new(test_project_path(), test_system_prompt());
4252        for i in 0..5 {
4253            fresh.add_tool_results(vec![whycodes_core::types::ToolResult {
4254                tool_call_id: format!("t{i}"),
4255                content: "q".repeat(3_000),
4256                is_error: false,
4257            }]);
4258        }
4259        decline.set(true);
4260        assert_eq!(fresh.spill_old_tool_results(&mut store), 0);
4261        assert_eq!(stored_now().len(), 5, "the store was asked and declined");
4262        assert_eq!(fresh.messages[0].content.as_text().unwrap().len(), 3_000);
4263        decline.set(false);
4264
4265        // Few tool messages: nothing is old enough.
4266        let mut small = Session::new(test_project_path(), test_system_prompt());
4267        small.add_tool_results(vec![whycodes_core::types::ToolResult {
4268            tool_call_id: "a".into(),
4269            content: "w".repeat(3_000),
4270            is_error: false,
4271        }]);
4272        assert_eq!(small.spill_old_tool_results(&mut store), 0);
4273        assert_eq!(stored_now().len(), 5, "nothing old enough to store");
4274    }
4275
4276    #[test]
4277    fn prefix_transcript_covers_everything_before_the_last_user_turn() {
4278        let mut session = Session::new(test_project_path(), test_system_prompt());
4279        assert!(session.prefix_transcript_for_summary().is_none());
4280        assert!(session.compaction_prefix_end().is_none());
4281        session.add_user_message("first ask");
4282        assert!(
4283            session.prefix_transcript_for_summary().is_none(),
4284            "nothing before it"
4285        );
4286        session.add_assistant_message(vec![ContentBlock::Text {
4287            text: "first answer".into(),
4288        }]);
4289        session.add_user_message("second ask");
4290        session.add_assistant_message(vec![ContentBlock::Text {
4291            text: "working".into(),
4292        }]);
4293        let (end, transcript) = session.prefix_transcript_for_summary().unwrap();
4294        assert_eq!(end, 2);
4295        assert_eq!(session.compaction_prefix_end(), Some(2));
4296        assert!(transcript.contains("first ask") && transcript.contains("first answer"));
4297        assert!(!transcript.contains("second ask"), "{transcript}");
4298    }
4299}