1use crate::cli_ndjson_safe_stringify::serialize_to_ndjson;
14use crate::coordinator::coordinator_mode::{is_coordinator_mode, match_session_mode};
15use crate::constants::tools::TODO_WRITE_TOOL_NAME;
16use crate::error::AgentError;
17use crate::session::{get_jsonl_path, get_session_path, get_sessions_dir, SessionEntry, SessionMetadata};
18use crate::types::api_types::{Message, MessageRole};
19use crate::types::logs::{
20 AttributionSnapshotMessage, ContextCollapseCommitEntry, ContextCollapseSnapshotEntry,
21 FileHistorySnapshot, PersistedWorktreeSession,
22};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TodoEntry {
34 pub id: String,
35 pub content: String,
36 pub done: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct StandaloneAgentContext {
42 pub name: String,
43 pub color: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AgentRestoreInfo {
50 pub agent_type: String,
52 pub agent_name: Option<String>,
54 pub agent_color: Option<String>,
56 pub model_override: Option<String>,
58}
59
60#[derive(Debug, Clone)]
62pub struct ConsistencyCheck {
63 pub can_resume: bool,
65 pub issues: Vec<String>,
67}
68
69#[derive(Debug, Clone)]
71pub struct SessionRestoreResult {
72 pub messages: Vec<Message>,
74 pub file_history_snapshots: Vec<FileHistorySnapshot>,
76 pub attribution_snapshots: Vec<AttributionSnapshotMessage>,
78 pub context_collapse_commits: Vec<ContextCollapseCommitEntry>,
80 pub context_collapse_snapshot: Option<ContextCollapseSnapshotEntry>,
82 pub todo_items: Vec<String>,
84 pub agent_info: Option<AgentRestoreInfo>,
86 pub standalone_agent_context: Option<StandaloneAgentContext>,
88 pub metadata: Option<SessionMetadata>,
90 pub mode: Option<String>,
92 pub worktree_session: Option<PersistedWorktreeSession>,
94 pub custom_title: Option<String>,
96 pub tag: Option<String>,
98 pub skipped_count: usize,
100}
101
102pub async fn restore_session_from_log(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
122 let entries = load_transcript_entries(session_id).await?;
123
124 let mut file_history_snapshots: Vec<FileHistorySnapshot> = Vec::new();
126 let mut attribution_snapshots: Vec<AttributionSnapshotMessage> = Vec::new();
127 let mut context_collapse_commits: Vec<ContextCollapseCommitEntry> = Vec::new();
128 let mut context_collapse_snapshot: Option<ContextCollapseSnapshotEntry> = None;
129 let mut worktree_session: Option<PersistedWorktreeSession> = None;
130 let mut custom_title: Option<String> = None;
131 let mut tag: Option<String> = None;
132
133 let mut agent_setting: Option<String> = None;
135 let mut agent_name: Option<String> = None;
136 let mut agent_color: Option<String> = None;
137
138 let mut metadata: Option<SessionMetadata> = None;
139 let mut mode: Option<String> = None;
140 let mut messages: Vec<Message> = Vec::new();
141
142 for entry in &entries {
143 let entry_type = match &entry.entry_type {
144 Some(t) => t.as_str(),
145 None => continue,
146 };
147
148 match entry_type {
149 "file-history-snapshot" => {
150 if let Some(data) = &entry.data {
151 if let Ok(snapshot) =
152 serde_json::from_value::<FileHistorySnapshot>(data.clone())
153 {
154 file_history_snapshots.push(snapshot);
155 }
156 }
157 }
158 "attribution-snapshot" => {
159 if let Some(data) = &entry.data {
160 if let Ok(snapshot) = serde_json::from_value::<AttributionSnapshotMessage>(data.clone()) {
161 attribution_snapshots.push(snapshot);
162 }
163 }
164 }
165 "marble-origami-commit" => {
166 if let Some(data) = &entry.data {
167 if let Ok(commit) = serde_json::from_value::<ContextCollapseCommitEntry>(data.clone()) {
168 context_collapse_commits.push(commit);
169 }
170 }
171 }
172 "marble-origami-snapshot" => {
173 if let Some(data) = &entry.data {
174 if let Ok(snapshot) =
175 serde_json::from_value::<ContextCollapseSnapshotEntry>(data.clone())
176 {
177 context_collapse_snapshot = Some(snapshot);
178 }
179 }
180 }
181 "worktree-state" => {
182 if let Some(data) = &entry.data {
183 if let Ok(ws) = serde_json::from_value::<PersistedWorktreeSession>(data.clone()) {
184 worktree_session = Some(ws);
185 }
186 }
187 }
188 "agent-setting" => {
189 if let Some(data) = &entry.data {
190 if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
191 agent_setting = Some(setting.to_string());
192 }
193 }
194 }
195 "agent-name" => {
196 if let Some(data) = &entry.data {
197 if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
198 agent_name = Some(name.to_string());
199 }
200 }
201 }
202 "agent-color" => {
203 if let Some(data) = &entry.data {
204 if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
205 agent_color = Some(color.to_string());
206 }
207 }
208 }
209 "custom-title" => {
210 if let Some(data) = &entry.data {
211 if let Some(title) = data.get("customTitle").and_then(|v| v.as_str()) {
212 custom_title = Some(title.to_string());
213 }
214 }
215 }
216 "tag" => {
217 if let Some(data) = &entry.data {
218 if let Some(t) = data.get("tag").and_then(|v| v.as_str()) {
219 tag = Some(t.to_string());
220 }
221 }
222 }
223 "mode" => {
224 if let Some(data) = &entry.data {
225 if let Some(m) = data.get("mode").and_then(|v| v.as_str()) {
226 mode = Some(m.to_string());
227 }
228 }
229 }
230 "metadata" => {
231 if let Some(data) = &entry.data {
232 if let Ok(md) = serde_json::from_value::<SessionMetadata>(data.clone()) {
233 metadata = Some(md);
234 }
235 }
236 }
237 "message" => {
238 if let Some(data) = &entry.data {
239 if let Ok(msg) = serde_json::from_value::<Message>(data.clone()) {
240 messages.push(msg);
241 }
242 }
243 }
244 _ => {}
245 }
246 }
247
248 let todo_items = extract_todo_from_transcript(&entries);
250
251 let agent_info = restore_agent_from_session_with_fields(
253 agent_setting,
254 agent_name.clone(),
255 agent_color.clone(),
256 );
257
258 let standalone_agent_context = compute_standalone_agent_context(agent_name.as_deref(), agent_color.as_deref());
260
261 let _ = &context_collapse_commits;
266 let _ = &context_collapse_snapshot;
267 Ok(SessionRestoreResult {
271 messages,
272 file_history_snapshots,
273 attribution_snapshots,
274 context_collapse_commits,
275 context_collapse_snapshot,
276 todo_items,
277 agent_info,
278 standalone_agent_context,
279 metadata,
280 mode,
281 worktree_session,
282 custom_title,
283 tag,
284 skipped_count: 0,
285 })
286}
287
288async fn load_transcript_entries(session_id: &str) -> Result<Vec<SessionEntry>, AgentError> {
290 let path = get_jsonl_path(session_id);
291 let content = match tokio::fs::read_to_string(&path).await {
292 Ok(c) => c,
293 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
294 return Err(AgentError::Session(format!(
295 "Session '{}' not found: no transcript at {:?}",
296 session_id, path
297 )));
298 }
299 Err(e) => return Err(AgentError::Io(e)),
300 };
301
302 let mut entries = Vec::new();
303 for line in content.lines() {
304 let line = line.trim().to_string();
305 if line.is_empty() {
306 continue;
307 }
308 let entry: SessionEntry = match serde_json::from_str(&line) {
309 Ok(e) => e,
310 Err(_) => continue,
311 };
312 entries.push(entry);
313 }
314
315 Ok(entries)
316}
317
318pub fn extract_todo_from_transcript(entries: &[SessionEntry]) -> Vec<String> {
338 for entry in entries.iter().rev() {
340 let entry_type = match &entry.entry_type {
341 Some(t) if t == "message" => "message",
342 _ => continue,
343 };
344 let data = match &entry.data {
345 Some(d) => d,
346 None => continue,
347 };
348
349 let role = data.get("role").and_then(|r| r.as_str());
351 if role != Some("assistant") {
352 continue;
353 }
354
355 let tool_calls = data.get("tool_calls");
357 if let Some(tc_arr) = tool_calls.and_then(|arr| arr.as_array()) {
358 for tc in tc_arr {
359 let name = tc.get("name").and_then(|n| n.as_str());
360 if name == Some(TODO_WRITE_TOOL_NAME) {
361 return parse_todos_from_tool_input(tc);
362 }
363 }
364 }
365 }
366 Vec::new()
367}
368
369fn parse_todos_from_tool_input(tool_call: &serde_json::Value) -> Vec<String> {
373 if let Some(input) = tool_call.get("input") {
375 if let Some(todos) = input.get("todos") {
376 if let Some(arr) = todos.as_array() {
377 let mut items = Vec::new();
378 for item in arr {
379 if let Some(content) = item.get("content").and_then(|c| c.as_str()) {
380 items.push(content.to_string());
381 } else if let Some(content) = item.as_str() {
382 items.push(content.to_string());
383 }
384 }
385 if !items.is_empty() {
386 return items;
387 }
388 }
389 }
390 }
391
392 Vec::new()
394}
395
396pub fn restore_agent_from_session(entries: &[SessionEntry]) -> Option<AgentRestoreInfo> {
415 let mut agent_setting: Option<String> = None;
416 let mut agent_name: Option<String> = None;
417 let mut agent_color: Option<String> = None;
418
419 for entry in entries {
420 let entry_type = match &entry.entry_type {
421 Some(t) => t.as_str(),
422 None => continue,
423 };
424 let data = match &entry.data {
425 Some(d) => d,
426 None => continue,
427 };
428
429 match entry_type {
430 "agent-setting" => {
431 if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
432 agent_setting = Some(setting.to_string());
433 }
434 }
435 "agent-name" => {
436 if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
437 agent_name = Some(name.to_string());
438 }
439 }
440 "agent-color" => {
441 if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
442 agent_color = Some(color.to_string());
443 }
444 }
445 _ => {}
446 }
447 }
448
449 restore_agent_from_session_with_fields(agent_setting, agent_name, agent_color)
450}
451
452fn restore_agent_from_session_with_fields(
454 agent_setting: Option<String>,
455 _agent_name: Option<String>,
456 _agent_color: Option<String>,
457) -> Option<AgentRestoreInfo> {
458 let agent_setting = agent_setting?;
459
460 Some(AgentRestoreInfo {
461 agent_type: agent_setting,
462 agent_name: _agent_name,
463 agent_color: normalize_agent_color(_agent_color.as_deref()),
464 model_override: None,
465 })
466}
467
468fn compute_standalone_agent_context(
471 agent_name: Option<&str>,
472 agent_color: Option<&str>,
473) -> Option<StandaloneAgentContext> {
474 if agent_name.is_none() && agent_color.is_none() {
475 return None;
476 }
477 Some(StandaloneAgentContext {
478 name: agent_name.unwrap_or("").to_string(),
479 color: normalize_agent_color(agent_color),
480 })
481}
482
483fn normalize_agent_color(color: Option<&str>) -> Option<String> {
485 match color {
486 Some("default") => None,
487 Some(c) => Some(c.to_string()),
488 None => None,
489 }
490}
491
492pub async fn check_resume_consistency(
513 session_id: &str,
514 parent_session_id: Option<&str>,
515) -> ConsistencyCheck {
516 let mut issues = Vec::new();
517
518 let session_dir = get_session_path(session_id);
520 if !session_dir.exists() {
521 return ConsistencyCheck {
522 can_resume: false,
523 issues: vec![format!(
524 "Session directory does not exist: {:?}",
525 session_dir
526 )],
527 };
528 }
529
530 let jsonl_path = get_jsonl_path(session_id);
532 if !jsonl_path.exists() {
533 issues.push(format!(
534 "Transcript file does not exist: {:?}",
535 jsonl_path
536 ));
537 } else {
538 match tokio::fs::read_to_string(&jsonl_path).await {
540 Ok(content) => {
541 let line_count = content.lines().filter(|l| !l.trim().is_empty()).count();
542 if line_count == 0 {
543 issues.push("Transcript file is empty, no entries to resume.".to_string());
544 }
545 }
546 Err(e) => issues.push(format!("Failed to read transcript: {}", e)),
547 }
548 }
549
550 if let Some(parent_id) = parent_session_id {
552 let parent_dir = get_session_path(parent_id);
553 if !parent_dir.exists() {
554 issues.push(format!("Parent session '{}' directory not found: {:?}", parent_id, parent_dir));
555 } else {
556 let parent_jsonl = get_jsonl_path(parent_id);
557 if !parent_jsonl.exists() {
558 issues.push(format!(
559 "Parent session '{}' has no transcript file: {:?}",
560 parent_id, parent_jsonl
561 ));
562 }
563 }
564 }
565
566 if let Some(cwd) = std::env::var("AI_CODE_CWD").ok() {
568 let current = std::env::current_dir().unwrap_or_default();
569 if cwd != current.to_string_lossy() {
570 issues.push(format!(
571 "Session CWD '{}' differs from current directory '{}'",
572 cwd,
573 current.to_string_lossy()
574 ));
575 }
576 }
577
578 ConsistencyCheck {
579 can_resume: issues.is_empty(),
580 issues,
581 }
582}
583
584pub async fn check_resume_consistency_err(
599 session_id: &str,
600 parent_session_id: Option<&str>,
601) -> Result<(), String> {
602 let check = check_resume_consistency(session_id, parent_session_id).await;
603 if check.can_resume {
604 Ok(())
605 } else {
606 Err(check.issues.join("\n"))
607 }
608}
609
610pub async fn handle_session_resume(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
633 let check = check_resume_consistency(session_id, None).await;
635 if !check.can_resume {
636 return Err(AgentError::Session(format!(
637 "Resume consistency check failed: {}",
638 check.issues.join("; ")
639 )));
640 }
641
642 let mut result = restore_session_from_log(session_id).await?;
644
645 if let Some(ref mode) = result.mode {
647 if let Some(warning) = match_session_mode(Some(mode)) {
648 result.messages.push(create_system_message(&warning));
650 }
651 }
652
653 log::info!(
655 "Session '{}' restored: {} messages, {} file history snapshots, {} attribution snapshots, {} context collapse commits, {} todo items",
656 session_id,
657 result.messages.len(),
658 result.file_history_snapshots.len(),
659 result.attribution_snapshots.len(),
660 result.context_collapse_commits.len(),
661 result.todo_items.len(),
662 );
663
664 Ok(result)
665}
666
667pub fn restore_file_history_state(
684 snapshots: &[FileHistorySnapshot],
685) -> Option<FileHistorySnapshot> {
686 if snapshots.is_empty() {
687 return None;
688 }
689
690 let mut merged = FileHistorySnapshot::new();
692 for snapshot in snapshots {
693 for (key, value) in snapshot {
694 merged.insert(key.clone(), value.clone());
695 }
696 }
697 Some(merged)
698}
699
700pub fn restore_attribution_state(
713 snapshots: &[AttributionSnapshotMessage],
714) -> Option<AttributionSnapshotMessage> {
715 snapshots.last().cloned()
716}
717
718pub fn restore_worktree_state(
734 worktree_session: Option<PersistedWorktreeSession>,
735) -> Option<PersistedWorktreeSession> {
736 let ws = worktree_session?;
737
738 let path = PathBuf::from(&ws.worktree_path);
740 if !path.is_dir() {
741 log::warn!(
742 "Worktree directory no longer exists: {:?}. Treating as exited.",
743 path
744 );
745 return None;
746 }
747
748 Some(ws)
749}
750
751fn create_system_message(content: &str) -> Message {
753 Message {
754 role: MessageRole::System,
755 content: content.to_string(),
756 attachments: None,
757 tool_call_id: None,
758 tool_calls: None,
759 is_error: None,
760 is_meta: Some(true),
761 is_api_error_message: None,
762 error_details: None,
763 uuid: None,
764 }
765}
766
767pub async fn save_mode_to_session(session_id: &str) {
776 let mode = if is_coordinator_mode() {
777 "coordinator"
778 } else {
779 "normal"
780 };
781 let entry = SessionEntry {
782 timestamp: Some(chrono::Utc::now().to_rfc3339()),
783 entry_type: Some("mode".to_string()),
784 data: Some(serde_json::json!({
785 "mode": mode,
786 "sessionId": session_id,
787 })),
788 };
789 if let Err(e) = crate::session::append_session_entry(session_id, &entry).await {
790 log::warn!("Failed to save mode entry: {}", e);
791 }
792}
793
794pub fn apply_context_collapse_restore(result: &SessionRestoreResult) {
805 let _ = &result.context_collapse_commits;
809 let _ = &result.context_collapse_snapshot;
810}
811
812pub fn session_restore_dir() -> PathBuf {
814 get_sessions_dir()
815}
816
817#[cfg(test)]
822mod tests {
823 use super::*;
824
825 fn make_message_entry(role: &str, content: &str) -> SessionEntry {
826 SessionEntry {
827 timestamp: Some(chrono::Utc::now().to_rfc3339()),
828 entry_type: Some("message".to_string()),
829 data: Some(serde_json::json!({
830 "role": role,
831 "content": content,
832 })),
833 }
834 }
835
836 fn make_assistant_with_tool_call(tool_name: &str, todos: Vec<serde_json::Value>) -> SessionEntry {
837 SessionEntry {
838 timestamp: Some(chrono::Utc::now().to_rfc3339()),
839 entry_type: Some("message".to_string()),
840 data: Some(serde_json::json!({
841 "role": "assistant",
842 "content": "",
843 "tool_calls": [{
844 "id": "toolu-123",
845 "type": "function",
846 "name": tool_name,
847 "input": {
848 "todos": todos,
849 },
850 }],
851 })),
852 }
853 }
854
855 fn make_agent_setting_entry(setting: &str) -> SessionEntry {
856 SessionEntry {
857 timestamp: Some(chrono::Utc::now().to_rfc3339()),
858 entry_type: Some("agent-setting".to_string()),
859 data: Some(serde_json::json!({
860 "agentSetting": setting,
861 "sessionId": "test-session",
862 })),
863 }
864 }
865
866 fn make_agent_name_entry(name: &str) -> SessionEntry {
867 SessionEntry {
868 timestamp: Some(chrono::Utc::now().to_rfc3339()),
869 entry_type: Some("agent-name".to_string()),
870 data: Some(serde_json::json!({
871 "agentName": name,
872 "sessionId": "test-session",
873 })),
874 }
875 }
876
877 fn make_agent_color_entry(color: &str) -> SessionEntry {
878 SessionEntry {
879 timestamp: Some(chrono::Utc::now().to_rfc3339()),
880 entry_type: Some("agent-color".to_string()),
881 data: Some(serde_json::json!({
882 "agentColor": color,
883 "sessionId": "test-session",
884 })),
885 }
886 }
887
888 #[test]
891 fn test_extract_todo_finds_last_todo_write() {
892 let entries = vec![
893 make_message_entry("user", "hello"),
894 make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
895 serde_json::json!({"content": "first task", "id": "1", "done": false}),
896 serde_json::json!({"content": "second task", "id": "2", "done": false}),
897 ]),
898 make_message_entry("user", "done with first"),
899 make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
900 serde_json::json!({"content": "first task", "id": "1", "done": true}),
901 serde_json::json!({"content": "third task", "id": "3", "done": false}),
902 ]),
903 ];
904 let todos = extract_todo_from_transcript(&entries);
905 assert_eq!(todos.len(), 2);
907 assert_eq!(todos[0], "first task");
908 assert_eq!(todos[1], "third task");
909 }
910
911 #[test]
912 fn test_extract_todo_empty_transcript() {
913 let entries: Vec<SessionEntry> = vec![];
914 let todos = extract_todo_from_transcript(&entries);
915 assert!(todos.is_empty());
916 }
917
918 #[test]
919 fn test_extract_todo_no_todo_write() {
920 let entries = vec![
921 make_message_entry("user", "hello"),
922 make_message_entry("assistant", "hi there"),
923 make_assistant_with_tool_call("Read", vec![]),
924 ];
925 let todos = extract_todo_from_transcript(&entries);
926 assert!(todos.is_empty());
927 }
928
929 #[test]
930 fn test_extract_todo_plain_string_items() {
931 let entries = vec![make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
932 serde_json::json!("just a task"),
933 serde_json::json!("another task"),
934 ])];
935 let todos = extract_todo_from_transcript(&entries);
937 assert_eq!(todos.len(), 2);
938 assert_eq!(todos[0], "just a task");
939 assert_eq!(todos[1], "another task");
940 }
941
942 #[test]
945 fn test_restore_agent_finds_all_fields() {
946 let entries = vec![
947 make_message_entry("user", "hello"),
948 make_agent_setting_entry("reviewer"),
949 make_agent_name_entry("Code Reviewer"),
950 make_agent_color_entry("blue"),
951 ];
952 let info = restore_agent_from_session(&entries);
953 assert!(info.is_some());
954 let info = info.unwrap();
955 assert_eq!(info.agent_type, "reviewer");
956 assert_eq!(info.agent_color, Some("blue".to_string()));
957 }
958
959 #[test]
960 fn test_restore_agent_no_setting() {
961 let entries = vec![
962 make_agent_name_entry("Some Agent"),
963 make_agent_color_entry("red"),
964 ];
965 let info = restore_agent_from_session(&entries);
967 assert!(info.is_none());
968 }
969
970 #[test]
971 fn test_restore_agent_default_color_normalized() {
972 let entries = vec![
973 make_agent_setting_entry("worker"),
974 make_agent_color_entry("default"),
975 ];
976 let info = restore_agent_from_session(&entries);
977 assert!(info.is_some());
978 let info = info.unwrap();
979 assert_eq!(info.agent_type, "worker");
980 assert_eq!(info.agent_color, None);
982 }
983
984 #[test]
987 fn test_restore_file_history_empty() {
988 let snapshots: Vec<FileHistorySnapshot> = vec![];
989 let result = restore_file_history_state(&snapshots);
990 assert!(result.is_none());
991 }
992
993 #[test]
994 fn test_restore_file_history_merges() {
995 let mut s1 = FileHistorySnapshot::new();
996 s1.insert("file_a".to_string(), serde_json::json!({"hash": "abc"}));
997
998 let mut s2 = FileHistorySnapshot::new();
999 s2.insert("file_b".to_string(), serde_json::json!({"hash": "def"}));
1000 s2.insert("file_a".to_string(), serde_json::json!({"hash": "abc2"}));
1001
1002 let result = restore_file_history_state(&[s1, s2]);
1003 assert!(result.is_some());
1004 let merged = result.unwrap();
1005 assert_eq!(merged.len(), 2);
1006 assert_eq!(merged["file_a"], serde_json::json!({"hash": "abc2"}));
1008 assert_eq!(merged["file_b"], serde_json::json!({"hash": "def"}));
1009 }
1010
1011 #[test]
1014 fn test_restore_attribution_empty() {
1015 let snapshots: Vec<AttributionSnapshotMessage> = vec![];
1016 let result = restore_attribution_state(&snapshots);
1017 assert!(result.is_none());
1018 }
1019
1020 #[test]
1021 fn test_restore_attribution_returns_last() {
1022 let s1 = AttributionSnapshotMessage {
1023 message_type: "attribution-snapshot".to_string(),
1024 message_id: uuid::Uuid::new_v4(),
1025 surface: "edit".to_string(),
1026 file_states: HashMap::new(),
1027 prompt_count: Some(1),
1028 prompt_count_at_last_commit: None,
1029 permission_prompt_count: None,
1030 permission_prompt_count_at_last_commit: None,
1031 escape_count: None,
1032 escape_count_at_last_commit: None,
1033 };
1034 let s2 = AttributionSnapshotMessage {
1035 message_type: "attribution-snapshot".to_string(),
1036 message_id: uuid::Uuid::new_v4(),
1037 surface: "edit".to_string(),
1038 file_states: HashMap::new(),
1039 prompt_count: Some(5),
1040 prompt_count_at_last_commit: None,
1041 permission_prompt_count: None,
1042 permission_prompt_count_at_last_commit: None,
1043 escape_count: None,
1044 escape_count_at_last_commit: None,
1045 };
1046 let result = restore_attribution_state(&[s1, s2]);
1047 assert!(result.is_some());
1048 assert_eq!(result.unwrap().prompt_count, Some(5));
1049 }
1050
1051 #[test]
1054 fn test_restore_worktree_none() {
1055 let result = restore_worktree_state(None);
1056 assert!(result.is_none());
1057 }
1058
1059 #[test]
1060 fn test_restore_worktree_missing_dir() {
1061 let ws = PersistedWorktreeSession {
1062 original_cwd: "/tmp".to_string(),
1063 worktree_path: "/tmp/nonexistent-worktree-path-12345".to_string(),
1064 worktree_name: "test".to_string(),
1065 worktree_branch: None,
1066 original_branch: None,
1067 original_head_commit: None,
1068 session_id: "test-session".to_string(),
1069 tmux_session_name: None,
1070 hook_based: None,
1071 };
1072 let result = restore_worktree_state(Some(ws));
1074 assert!(result.is_none());
1075 }
1076
1077 #[test]
1078 fn test_restore_worktree_existing_dir() {
1079 let ws = PersistedWorktreeSession {
1080 original_cwd: "/tmp".to_string(),
1081 worktree_path: "/tmp".to_string(),
1082 worktree_name: "test".to_string(),
1083 worktree_branch: None,
1084 original_branch: None,
1085 original_head_commit: None,
1086 session_id: "test-session".to_string(),
1087 tmux_session_name: None,
1088 hook_based: None,
1089 };
1090 let result = restore_worktree_state(Some(ws));
1092 assert!(result.is_some());
1093 }
1094
1095 #[test]
1098 fn test_normalize_agent_color() {
1099 assert_eq!(normalize_agent_color(Some("default")), None);
1100 assert_eq!(normalize_agent_color(Some("blue")), Some("blue".to_string()));
1101 assert_eq!(normalize_agent_color(None), None);
1102 }
1103
1104 #[test]
1107 fn test_compute_standalone_agent_context_both_none() {
1108 let result = compute_standalone_agent_context(None, None);
1109 assert!(result.is_none());
1110 }
1111
1112 #[test]
1113 fn test_compute_standalone_agent_context_name_only() {
1114 let result = compute_standalone_agent_context(Some("Reviewer"), None);
1115 assert!(result.is_some());
1116 assert_eq!(result.unwrap().name, "Reviewer");
1117 }
1118
1119 #[test]
1120 fn test_compute_standalone_agent_context_with_default_color() {
1121 let result = compute_standalone_agent_context(Some("Agent"), Some("default"));
1122 assert!(result.is_some());
1123 let ctx = result.unwrap();
1124 assert_eq!(ctx.name, "Agent");
1125 assert_eq!(ctx.color, None);
1126 }
1127
1128 #[test]
1131 fn test_create_system_message() {
1132 let msg = create_system_message("Mode switched");
1133 assert_eq!(msg.role, MessageRole::System);
1134 assert_eq!(msg.content, "Mode switched");
1135 assert!(msg.is_meta == Some(true));
1136 }
1137
1138 #[tokio::test]
1141 async fn test_check_resume_consistency_nonexistent() {
1142 let check = check_resume_consistency("nonexistent-session-12345", None).await;
1143 assert!(!check.can_resume);
1144 assert!(!check.issues.is_empty());
1145 }
1146
1147 #[tokio::test]
1148 async fn test_check_resume_consistency_valid_session() {
1149 crate::tests::common::clear_all_test_state();
1150 let session_id = format!("consistency-test-{}", uuid::Uuid::new_v4());
1151
1152 let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
1154 role: crate::types::api_types::MessageRole::User,
1155 content: "hello".to_string(),
1156 ..Default::default()
1157 });
1158 crate::session::append_session_entry(&session_id, &msg).await.unwrap();
1159
1160 let check = check_resume_consistency(&session_id, None).await;
1161 assert!(check.can_resume);
1162 assert!(check.issues.is_empty());
1163
1164 let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
1166 }
1167
1168 #[tokio::test]
1169 async fn test_check_resume_consistency_with_missing_parent() {
1170 crate::tests::common::clear_all_test_state();
1171 let session_id = format!("consistency-parent-test-{}", uuid::Uuid::new_v4());
1172
1173 let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
1175 role: crate::types::api_types::MessageRole::User,
1176 content: "hello".to_string(),
1177 ..Default::default()
1178 });
1179 crate::session::append_session_entry(&session_id, &msg).await.unwrap();
1180
1181 let check = check_resume_consistency(&session_id, Some("missing-parent-session")).await;
1183 assert!(!check.can_resume);
1184 assert!(check.issues.iter().any(|i| i.contains("Parent session")));
1185
1186 let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
1188 }
1189
1190 #[test]
1193 fn test_session_restore_result_debug() {
1194 let result = SessionRestoreResult {
1195 messages: vec![],
1196 file_history_snapshots: vec![],
1197 attribution_snapshots: vec![],
1198 context_collapse_commits: vec![],
1199 context_collapse_snapshot: None,
1200 todo_items: vec!["test".to_string()],
1201 agent_info: None,
1202 standalone_agent_context: None,
1203 metadata: None,
1204 mode: None,
1205 worktree_session: None,
1206 custom_title: None,
1207 tag: None,
1208 skipped_count: 0,
1209 };
1210 let _ = format!("{:?}", result);
1212 }
1213
1214 #[test]
1217 fn test_parse_todos_from_tool_input() {
1218 let tool_call = serde_json::json!({
1219 "name": "TodoWrite",
1220 "input": {
1221 "todos": [
1222 {"content": "task one", "id": "1", "done": false},
1223 {"content": "task two", "id": "2", "done": true},
1224 ]
1225 }
1226 });
1227 let todos = parse_todos_from_tool_input(&tool_call);
1228 assert_eq!(todos.len(), 2);
1229 assert_eq!(todos[0], "task one");
1230 assert_eq!(todos[1], "task two");
1231 }
1232
1233 #[test]
1234 fn test_parse_todos_from_tool_input_empty() {
1235 let tool_call = serde_json::json!({
1236 "name": "TodoWrite",
1237 "input": {
1238 "todos": []
1239 }
1240 });
1241 let todos = parse_todos_from_tool_input(&tool_call);
1242 assert!(todos.is_empty());
1243 }
1244
1245 #[test]
1246 fn test_parse_todos_from_tool_input_no_input() {
1247 let tool_call = serde_json::json!({
1248 "name": "Read",
1249 });
1250 let todos = parse_todos_from_tool_input(&tool_call);
1251 assert!(todos.is_empty());
1252 }
1253}