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