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