use crate::cli_ndjson_safe_stringify::serialize_to_ndjson;
use crate::coordinator::coordinator_mode::{is_coordinator_mode, match_session_mode};
use crate::constants::tools::TODO_WRITE_TOOL_NAME;
use crate::error::AgentError;
use crate::session::{get_jsonl_path, get_session_path, get_sessions_dir, SessionEntry, SessionMetadata};
use crate::types::api_types::{Message, MessageRole};
use crate::types::logs::{
AttributionSnapshotMessage, ContextCollapseCommitEntry, ContextCollapseSnapshotEntry,
FileHistorySnapshot, PersistedWorktreeSession,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoEntry {
pub id: String,
pub content: String,
pub done: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneAgentContext {
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRestoreInfo {
pub agent_type: String,
pub agent_name: Option<String>,
pub agent_color: Option<String>,
pub model_override: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConsistencyCheck {
pub can_resume: bool,
pub issues: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SessionRestoreResult {
pub messages: Vec<Message>,
pub file_history_snapshots: Vec<FileHistorySnapshot>,
pub attribution_snapshots: Vec<AttributionSnapshotMessage>,
pub context_collapse_commits: Vec<ContextCollapseCommitEntry>,
pub context_collapse_snapshot: Option<ContextCollapseSnapshotEntry>,
pub todo_items: Vec<String>,
pub agent_info: Option<AgentRestoreInfo>,
pub standalone_agent_context: Option<StandaloneAgentContext>,
pub metadata: Option<SessionMetadata>,
pub mode: Option<String>,
pub worktree_session: Option<PersistedWorktreeSession>,
pub custom_title: Option<String>,
pub tag: Option<String>,
pub skipped_count: usize,
}
pub async fn restore_session_from_log(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
let entries = load_transcript_entries(session_id).await?;
let mut file_history_snapshots: Vec<FileHistorySnapshot> = Vec::new();
let mut attribution_snapshots: Vec<AttributionSnapshotMessage> = Vec::new();
let mut context_collapse_commits: Vec<ContextCollapseCommitEntry> = Vec::new();
let mut context_collapse_snapshot: Option<ContextCollapseSnapshotEntry> = None;
let mut worktree_session: Option<PersistedWorktreeSession> = None;
let mut custom_title: Option<String> = None;
let mut tag: Option<String> = None;
let mut agent_setting: Option<String> = None;
let mut agent_name: Option<String> = None;
let mut agent_color: Option<String> = None;
let mut metadata: Option<SessionMetadata> = None;
let mut mode: Option<String> = None;
let mut messages: Vec<Message> = Vec::new();
for entry in &entries {
let entry_type = match &entry.entry_type {
Some(t) => t.as_str(),
None => continue,
};
match entry_type {
"file-history-snapshot" => {
if let Some(data) = &entry.data {
if let Ok(snapshot) =
serde_json::from_value::<FileHistorySnapshot>(data.clone())
{
file_history_snapshots.push(snapshot);
}
}
}
"attribution-snapshot" => {
if let Some(data) = &entry.data {
if let Ok(snapshot) = serde_json::from_value::<AttributionSnapshotMessage>(data.clone()) {
attribution_snapshots.push(snapshot);
}
}
}
"marble-origami-commit" => {
if let Some(data) = &entry.data {
if let Ok(commit) = serde_json::from_value::<ContextCollapseCommitEntry>(data.clone()) {
context_collapse_commits.push(commit);
}
}
}
"marble-origami-snapshot" => {
if let Some(data) = &entry.data {
if let Ok(snapshot) =
serde_json::from_value::<ContextCollapseSnapshotEntry>(data.clone())
{
context_collapse_snapshot = Some(snapshot);
}
}
}
"worktree-state" => {
if let Some(data) = &entry.data {
if let Ok(ws) = serde_json::from_value::<PersistedWorktreeSession>(data.clone()) {
worktree_session = Some(ws);
}
}
}
"agent-setting" => {
if let Some(data) = &entry.data {
if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
agent_setting = Some(setting.to_string());
}
}
}
"agent-name" => {
if let Some(data) = &entry.data {
if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
agent_name = Some(name.to_string());
}
}
}
"agent-color" => {
if let Some(data) = &entry.data {
if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
agent_color = Some(color.to_string());
}
}
}
"custom-title" => {
if let Some(data) = &entry.data {
if let Some(title) = data.get("customTitle").and_then(|v| v.as_str()) {
custom_title = Some(title.to_string());
}
}
}
"tag" => {
if let Some(data) = &entry.data {
if let Some(t) = data.get("tag").and_then(|v| v.as_str()) {
tag = Some(t.to_string());
}
}
}
"mode" => {
if let Some(data) = &entry.data {
if let Some(m) = data.get("mode").and_then(|v| v.as_str()) {
mode = Some(m.to_string());
}
}
}
"metadata" => {
if let Some(data) = &entry.data {
if let Ok(md) = serde_json::from_value::<SessionMetadata>(data.clone()) {
metadata = Some(md);
}
}
}
"message" => {
if let Some(data) = &entry.data {
if let Ok(msg) = serde_json::from_value::<Message>(data.clone()) {
messages.push(msg);
}
}
}
_ => {}
}
}
let todo_items = extract_todo_from_transcript(&entries);
let agent_info = restore_agent_from_session_with_fields(
agent_setting,
agent_name.clone(),
agent_color.clone(),
);
let standalone_agent_context = compute_standalone_agent_context(agent_name.as_deref(), agent_color.as_deref());
let _ = &context_collapse_commits;
let _ = &context_collapse_snapshot;
Ok(SessionRestoreResult {
messages,
file_history_snapshots,
attribution_snapshots,
context_collapse_commits,
context_collapse_snapshot,
todo_items,
agent_info,
standalone_agent_context,
metadata,
mode,
worktree_session,
custom_title,
tag,
skipped_count: 0,
})
}
async fn load_transcript_entries(session_id: &str) -> Result<Vec<SessionEntry>, AgentError> {
let path = get_jsonl_path(session_id);
let content = match tokio::fs::read_to_string(&path).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(AgentError::Session(format!(
"Session '{}' not found: no transcript at {:?}",
session_id, path
)));
}
Err(e) => return Err(AgentError::Io(e)),
};
let mut entries = Vec::new();
for line in content.lines() {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let entry: SessionEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
entries.push(entry);
}
Ok(entries)
}
pub fn extract_todo_from_transcript(entries: &[SessionEntry]) -> Vec<String> {
for entry in entries.iter().rev() {
let entry_type = match &entry.entry_type {
Some(t) if t == "message" => "message",
_ => continue,
};
let data = match &entry.data {
Some(d) => d,
None => continue,
};
let role = data.get("role").and_then(|r| r.as_str());
if role != Some("assistant") {
continue;
}
let tool_calls = data.get("tool_calls");
if let Some(tc_arr) = tool_calls.and_then(|arr| arr.as_array()) {
for tc in tc_arr {
let name = tc.get("name").and_then(|n| n.as_str());
if name == Some(TODO_WRITE_TOOL_NAME) {
return parse_todos_from_tool_input(tc);
}
}
}
}
Vec::new()
}
fn parse_todos_from_tool_input(tool_call: &serde_json::Value) -> Vec<String> {
if let Some(input) = tool_call.get("input") {
if let Some(todos) = input.get("todos") {
if let Some(arr) = todos.as_array() {
let mut items = Vec::new();
for item in arr {
if let Some(content) = item.get("content").and_then(|c| c.as_str()) {
items.push(content.to_string());
} else if let Some(content) = item.as_str() {
items.push(content.to_string());
}
}
if !items.is_empty() {
return items;
}
}
}
}
Vec::new()
}
pub fn restore_agent_from_session(entries: &[SessionEntry]) -> Option<AgentRestoreInfo> {
let mut agent_setting: Option<String> = None;
let mut agent_name: Option<String> = None;
let mut agent_color: Option<String> = None;
for entry in entries {
let entry_type = match &entry.entry_type {
Some(t) => t.as_str(),
None => continue,
};
let data = match &entry.data {
Some(d) => d,
None => continue,
};
match entry_type {
"agent-setting" => {
if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
agent_setting = Some(setting.to_string());
}
}
"agent-name" => {
if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
agent_name = Some(name.to_string());
}
}
"agent-color" => {
if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
agent_color = Some(color.to_string());
}
}
_ => {}
}
}
restore_agent_from_session_with_fields(agent_setting, agent_name, agent_color)
}
fn restore_agent_from_session_with_fields(
agent_setting: Option<String>,
_agent_name: Option<String>,
_agent_color: Option<String>,
) -> Option<AgentRestoreInfo> {
let agent_setting = agent_setting?;
Some(AgentRestoreInfo {
agent_type: agent_setting,
agent_name: _agent_name,
agent_color: normalize_agent_color(_agent_color.as_deref()),
model_override: None,
})
}
fn compute_standalone_agent_context(
agent_name: Option<&str>,
agent_color: Option<&str>,
) -> Option<StandaloneAgentContext> {
if agent_name.is_none() && agent_color.is_none() {
return None;
}
Some(StandaloneAgentContext {
name: agent_name.unwrap_or("").to_string(),
color: normalize_agent_color(agent_color),
})
}
fn normalize_agent_color(color: Option<&str>) -> Option<String> {
match color {
Some("default") => None,
Some(c) => Some(c.to_string()),
None => None,
}
}
pub async fn check_resume_consistency(
session_id: &str,
parent_session_id: Option<&str>,
) -> ConsistencyCheck {
let mut issues = Vec::new();
let session_dir = get_session_path(session_id);
if !session_dir.exists() {
return ConsistencyCheck {
can_resume: false,
issues: vec![format!(
"Session directory does not exist: {:?}",
session_dir
)],
};
}
let jsonl_path = get_jsonl_path(session_id);
if !jsonl_path.exists() {
issues.push(format!(
"Transcript file does not exist: {:?}",
jsonl_path
));
} else {
match tokio::fs::read_to_string(&jsonl_path).await {
Ok(content) => {
let line_count = content.lines().filter(|l| !l.trim().is_empty()).count();
if line_count == 0 {
issues.push("Transcript file is empty, no entries to resume.".to_string());
}
}
Err(e) => issues.push(format!("Failed to read transcript: {}", e)),
}
}
if let Some(parent_id) = parent_session_id {
let parent_dir = get_session_path(parent_id);
if !parent_dir.exists() {
issues.push(format!("Parent session '{}' directory not found: {:?}", parent_id, parent_dir));
} else {
let parent_jsonl = get_jsonl_path(parent_id);
if !parent_jsonl.exists() {
issues.push(format!(
"Parent session '{}' has no transcript file: {:?}",
parent_id, parent_jsonl
));
}
}
}
if let Some(cwd) = std::env::var("AI_CODE_CWD").ok() {
let current = std::env::current_dir().unwrap_or_default();
if cwd != current.to_string_lossy() {
issues.push(format!(
"Session CWD '{}' differs from current directory '{}'",
cwd,
current.to_string_lossy()
));
}
}
ConsistencyCheck {
can_resume: issues.is_empty(),
issues,
}
}
pub async fn check_resume_consistency_err(
session_id: &str,
parent_session_id: Option<&str>,
) -> Result<(), String> {
let check = check_resume_consistency(session_id, parent_session_id).await;
if check.can_resume {
Ok(())
} else {
Err(check.issues.join("\n"))
}
}
pub async fn handle_session_resume(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
let check = check_resume_consistency(session_id, None).await;
if !check.can_resume {
return Err(AgentError::Session(format!(
"Resume consistency check failed: {}",
check.issues.join("; ")
)));
}
let mut result = restore_session_from_log(session_id).await?;
if let Some(ref mode) = result.mode {
if let Some(warning) = match_session_mode(Some(mode)) {
result.messages.push(create_system_message(&warning));
}
}
log::info!(
"Session '{}' restored: {} messages, {} file history snapshots, {} attribution snapshots, {} context collapse commits, {} todo items",
session_id,
result.messages.len(),
result.file_history_snapshots.len(),
result.attribution_snapshots.len(),
result.context_collapse_commits.len(),
result.todo_items.len(),
);
Ok(result)
}
pub fn restore_file_history_state(
snapshots: &[FileHistorySnapshot],
) -> Option<FileHistorySnapshot> {
if snapshots.is_empty() {
return None;
}
let mut merged = FileHistorySnapshot::new();
for snapshot in snapshots {
for (key, value) in snapshot {
merged.insert(key.clone(), value.clone());
}
}
Some(merged)
}
pub fn restore_attribution_state(
snapshots: &[AttributionSnapshotMessage],
) -> Option<AttributionSnapshotMessage> {
snapshots.last().cloned()
}
pub fn restore_worktree_state(
worktree_session: Option<PersistedWorktreeSession>,
) -> Option<PersistedWorktreeSession> {
let ws = worktree_session?;
let path = PathBuf::from(&ws.worktree_path);
if !path.is_dir() {
log::warn!(
"Worktree directory no longer exists: {:?}. Treating as exited.",
path
);
return None;
}
Some(ws)
}
fn create_system_message(content: &str) -> Message {
Message {
role: MessageRole::System,
content: content.to_string(),
attachments: None,
tool_call_id: None,
tool_calls: None,
is_error: None,
is_meta: Some(true),
is_api_error_message: None,
error_details: None,
uuid: None,
}
}
pub async fn save_mode_to_session(session_id: &str) {
let mode = if is_coordinator_mode() {
"coordinator"
} else {
"normal"
};
let entry = SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("mode".to_string()),
data: Some(serde_json::json!({
"mode": mode,
"sessionId": session_id,
})),
};
if let Err(e) = crate::session::append_session_entry(session_id, &entry).await {
log::warn!("Failed to save mode entry: {}", e);
}
}
pub fn apply_context_collapse_restore(result: &SessionRestoreResult) {
let _ = &result.context_collapse_commits;
let _ = &result.context_collapse_snapshot;
}
pub fn session_restore_dir() -> PathBuf {
get_sessions_dir()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_message_entry(role: &str, content: &str) -> SessionEntry {
SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("message".to_string()),
data: Some(serde_json::json!({
"role": role,
"content": content,
})),
}
}
fn make_assistant_with_tool_call(tool_name: &str, todos: Vec<serde_json::Value>) -> SessionEntry {
SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("message".to_string()),
data: Some(serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "toolu-123",
"type": "function",
"name": tool_name,
"input": {
"todos": todos,
},
}],
})),
}
}
fn make_agent_setting_entry(setting: &str) -> SessionEntry {
SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("agent-setting".to_string()),
data: Some(serde_json::json!({
"agentSetting": setting,
"sessionId": "test-session",
})),
}
}
fn make_agent_name_entry(name: &str) -> SessionEntry {
SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("agent-name".to_string()),
data: Some(serde_json::json!({
"agentName": name,
"sessionId": "test-session",
})),
}
}
fn make_agent_color_entry(color: &str) -> SessionEntry {
SessionEntry {
timestamp: Some(chrono::Utc::now().to_rfc3339()),
entry_type: Some("agent-color".to_string()),
data: Some(serde_json::json!({
"agentColor": color,
"sessionId": "test-session",
})),
}
}
#[test]
fn test_extract_todo_finds_last_todo_write() {
let entries = vec![
make_message_entry("user", "hello"),
make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
serde_json::json!({"content": "first task", "id": "1", "done": false}),
serde_json::json!({"content": "second task", "id": "2", "done": false}),
]),
make_message_entry("user", "done with first"),
make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
serde_json::json!({"content": "first task", "id": "1", "done": true}),
serde_json::json!({"content": "third task", "id": "3", "done": false}),
]),
];
let todos = extract_todo_from_transcript(&entries);
assert_eq!(todos.len(), 2);
assert_eq!(todos[0], "first task");
assert_eq!(todos[1], "third task");
}
#[test]
fn test_extract_todo_empty_transcript() {
let entries: Vec<SessionEntry> = vec![];
let todos = extract_todo_from_transcript(&entries);
assert!(todos.is_empty());
}
#[test]
fn test_extract_todo_no_todo_write() {
let entries = vec![
make_message_entry("user", "hello"),
make_message_entry("assistant", "hi there"),
make_assistant_with_tool_call("Read", vec![]),
];
let todos = extract_todo_from_transcript(&entries);
assert!(todos.is_empty());
}
#[test]
fn test_extract_todo_plain_string_items() {
let entries = vec![make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
serde_json::json!("just a task"),
serde_json::json!("another task"),
])];
let todos = extract_todo_from_transcript(&entries);
assert_eq!(todos.len(), 2);
assert_eq!(todos[0], "just a task");
assert_eq!(todos[1], "another task");
}
#[test]
fn test_restore_agent_finds_all_fields() {
let entries = vec![
make_message_entry("user", "hello"),
make_agent_setting_entry("reviewer"),
make_agent_name_entry("Code Reviewer"),
make_agent_color_entry("blue"),
];
let info = restore_agent_from_session(&entries);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.agent_type, "reviewer");
assert_eq!(info.agent_color, Some("blue".to_string()));
}
#[test]
fn test_restore_agent_no_setting() {
let entries = vec![
make_agent_name_entry("Some Agent"),
make_agent_color_entry("red"),
];
let info = restore_agent_from_session(&entries);
assert!(info.is_none());
}
#[test]
fn test_restore_agent_default_color_normalized() {
let entries = vec![
make_agent_setting_entry("worker"),
make_agent_color_entry("default"),
];
let info = restore_agent_from_session(&entries);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.agent_type, "worker");
assert_eq!(info.agent_color, None);
}
#[test]
fn test_restore_file_history_empty() {
let snapshots: Vec<FileHistorySnapshot> = vec![];
let result = restore_file_history_state(&snapshots);
assert!(result.is_none());
}
#[test]
fn test_restore_file_history_merges() {
let mut s1 = FileHistorySnapshot::new();
s1.insert("file_a".to_string(), serde_json::json!({"hash": "abc"}));
let mut s2 = FileHistorySnapshot::new();
s2.insert("file_b".to_string(), serde_json::json!({"hash": "def"}));
s2.insert("file_a".to_string(), serde_json::json!({"hash": "abc2"}));
let result = restore_file_history_state(&[s1, s2]);
assert!(result.is_some());
let merged = result.unwrap();
assert_eq!(merged.len(), 2);
assert_eq!(merged["file_a"], serde_json::json!({"hash": "abc2"}));
assert_eq!(merged["file_b"], serde_json::json!({"hash": "def"}));
}
#[test]
fn test_restore_attribution_empty() {
let snapshots: Vec<AttributionSnapshotMessage> = vec![];
let result = restore_attribution_state(&snapshots);
assert!(result.is_none());
}
#[test]
fn test_restore_attribution_returns_last() {
let s1 = AttributionSnapshotMessage {
message_type: "attribution-snapshot".to_string(),
message_id: uuid::Uuid::new_v4(),
surface: "edit".to_string(),
file_states: HashMap::new(),
prompt_count: Some(1),
prompt_count_at_last_commit: None,
permission_prompt_count: None,
permission_prompt_count_at_last_commit: None,
escape_count: None,
escape_count_at_last_commit: None,
};
let s2 = AttributionSnapshotMessage {
message_type: "attribution-snapshot".to_string(),
message_id: uuid::Uuid::new_v4(),
surface: "edit".to_string(),
file_states: HashMap::new(),
prompt_count: Some(5),
prompt_count_at_last_commit: None,
permission_prompt_count: None,
permission_prompt_count_at_last_commit: None,
escape_count: None,
escape_count_at_last_commit: None,
};
let result = restore_attribution_state(&[s1, s2]);
assert!(result.is_some());
assert_eq!(result.unwrap().prompt_count, Some(5));
}
#[test]
fn test_restore_worktree_none() {
let result = restore_worktree_state(None);
assert!(result.is_none());
}
#[test]
fn test_restore_worktree_missing_dir() {
let ws = PersistedWorktreeSession {
original_cwd: "/tmp".to_string(),
worktree_path: "/tmp/nonexistent-worktree-path-12345".to_string(),
worktree_name: "test".to_string(),
worktree_branch: None,
original_branch: None,
original_head_commit: None,
session_id: "test-session".to_string(),
tmux_session_name: None,
hook_based: None,
};
let result = restore_worktree_state(Some(ws));
assert!(result.is_none());
}
#[test]
fn test_restore_worktree_existing_dir() {
let ws = PersistedWorktreeSession {
original_cwd: "/tmp".to_string(),
worktree_path: "/tmp".to_string(),
worktree_name: "test".to_string(),
worktree_branch: None,
original_branch: None,
original_head_commit: None,
session_id: "test-session".to_string(),
tmux_session_name: None,
hook_based: None,
};
let result = restore_worktree_state(Some(ws));
assert!(result.is_some());
}
#[test]
fn test_normalize_agent_color() {
assert_eq!(normalize_agent_color(Some("default")), None);
assert_eq!(normalize_agent_color(Some("blue")), Some("blue".to_string()));
assert_eq!(normalize_agent_color(None), None);
}
#[test]
fn test_compute_standalone_agent_context_both_none() {
let result = compute_standalone_agent_context(None, None);
assert!(result.is_none());
}
#[test]
fn test_compute_standalone_agent_context_name_only() {
let result = compute_standalone_agent_context(Some("Reviewer"), None);
assert!(result.is_some());
assert_eq!(result.unwrap().name, "Reviewer");
}
#[test]
fn test_compute_standalone_agent_context_with_default_color() {
let result = compute_standalone_agent_context(Some("Agent"), Some("default"));
assert!(result.is_some());
let ctx = result.unwrap();
assert_eq!(ctx.name, "Agent");
assert_eq!(ctx.color, None);
}
#[test]
fn test_create_system_message() {
let msg = create_system_message("Mode switched");
assert_eq!(msg.role, MessageRole::System);
assert_eq!(msg.content, "Mode switched");
assert!(msg.is_meta == Some(true));
}
#[tokio::test]
async fn test_check_resume_consistency_nonexistent() {
let check = check_resume_consistency("nonexistent-session-12345", None).await;
assert!(!check.can_resume);
assert!(!check.issues.is_empty());
}
#[tokio::test]
async fn test_check_resume_consistency_valid_session() {
crate::tests::common::clear_all_test_state();
let session_id = format!("consistency-test-{}", uuid::Uuid::new_v4());
let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
role: crate::types::api_types::MessageRole::User,
content: "hello".to_string(),
..Default::default()
});
crate::session::append_session_entry(&session_id, &msg).await.unwrap();
let check = check_resume_consistency(&session_id, None).await;
assert!(check.can_resume);
assert!(check.issues.is_empty());
let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
}
#[tokio::test]
async fn test_check_resume_consistency_with_missing_parent() {
crate::tests::common::clear_all_test_state();
let session_id = format!("consistency-parent-test-{}", uuid::Uuid::new_v4());
let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
role: crate::types::api_types::MessageRole::User,
content: "hello".to_string(),
..Default::default()
});
crate::session::append_session_entry(&session_id, &msg).await.unwrap();
let check = check_resume_consistency(&session_id, Some("missing-parent-session")).await;
assert!(!check.can_resume);
assert!(check.issues.iter().any(|i| i.contains("Parent session")));
let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
}
#[test]
fn test_session_restore_result_debug() {
let result = SessionRestoreResult {
messages: vec![],
file_history_snapshots: vec![],
attribution_snapshots: vec![],
context_collapse_commits: vec![],
context_collapse_snapshot: None,
todo_items: vec!["test".to_string()],
agent_info: None,
standalone_agent_context: None,
metadata: None,
mode: None,
worktree_session: None,
custom_title: None,
tag: None,
skipped_count: 0,
};
let _ = format!("{:?}", result);
}
#[test]
fn test_parse_todos_from_tool_input() {
let tool_call = serde_json::json!({
"name": "TodoWrite",
"input": {
"todos": [
{"content": "task one", "id": "1", "done": false},
{"content": "task two", "id": "2", "done": true},
]
}
});
let todos = parse_todos_from_tool_input(&tool_call);
assert_eq!(todos.len(), 2);
assert_eq!(todos[0], "task one");
assert_eq!(todos[1], "task two");
}
#[test]
fn test_parse_todos_from_tool_input_empty() {
let tool_call = serde_json::json!({
"name": "TodoWrite",
"input": {
"todos": []
}
});
let todos = parse_todos_from_tool_input(&tool_call);
assert!(todos.is_empty());
}
#[test]
fn test_parse_todos_from_tool_input_no_input() {
let tool_call = serde_json::json!({
"name": "Read",
});
let todos = parse_todos_from_tool_input(&tool_call);
assert!(todos.is_empty());
}
}