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