use anyhow::{Context, Result};
use std::path::PathBuf;
use crate::api::types::Message;
use crate::db::{Db, SessionInfo};
fn db_path() -> Result<PathBuf> {
let base =
dirs::data_local_dir().ok_or_else(|| anyhow::anyhow!("Could not find data directory"))?;
let dir = base.join("claux");
std::fs::create_dir_all(&dir)?;
Ok(dir.join("sessions.db"))
}
fn get_db() -> Result<Db> {
let path = db_path()?;
Db::open(&path).context("Failed to open session database")
}
pub fn create_session(model: &str) -> Result<(String, PathBuf)> {
let id = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
let db = get_db()?;
db.create_session(&id, model, None, None)?;
let dummy_path = PathBuf::from(format!("sqlite://{id}"));
Ok((id, dummy_path))
}
pub fn save_messages(path: &std::path::Path, messages: &[Message]) -> Result<()> {
let session_id = extract_session_id(path);
let db = get_db()?;
db.replace_messages(&session_id, messages)?;
Ok(())
}
pub fn load_session(path: &std::path::Path) -> Result<(SessionMeta, Vec<Message>)> {
let session_id = extract_session_id(path);
let db = get_db()?;
let session_info = db
.get_session(&session_id)?
.ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?;
let messages = repair_history(db.get_messages(&session_id)?);
let meta = SessionMeta {
id: session_info.id,
cwd: String::new(), model: session_info.model,
created_at: session_info
.created_at
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
updated_at: session_info
.last_active
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
};
Ok((meta, messages))
}
pub fn find_session(prefix: &str) -> Result<Option<(String, PathBuf)>> {
Ok(list_sessions()?
.into_iter()
.find(|(sid, _)| sid == prefix || sid.starts_with(prefix)))
}
pub fn list_sessions() -> Result<Vec<(String, PathBuf)>> {
let db = get_db()?;
let sessions = db.list_sessions()?;
let result: Vec<(String, PathBuf)> = sessions
.into_iter()
.map(|s| {
let dummy_path = PathBuf::from(format!("sqlite://{}", s.id));
(s.id, dummy_path)
})
.collect();
Ok(result)
}
fn extract_session_id(path: &std::path::Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "default".to_string())
}
pub fn update_session_stats(
session_id: &str,
message_count: usize,
token_count: usize,
) -> Result<()> {
let db = get_db()?;
db.update_session_stats(session_id, message_count, token_count)?;
Ok(())
}
pub fn search_sessions(query: &str) -> Result<Vec<SessionInfo>> {
let db = get_db()?;
db.search_sessions(query)
}
#[derive(Debug, Clone)]
pub struct SessionMeta {
pub id: String,
pub cwd: String,
pub model: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
pub fn repair_history(messages: Vec<Message>) -> Vec<Message> {
use crate::api::types::{ContentBlock, MessageContent};
const LOST_RESULT: &str = "Tool result not saved before the session ended.";
let synthetic = |id: &str| ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: LOST_RESULT.to_string(),
is_error: Some(true),
};
let mut repaired: Vec<Message> = Vec::with_capacity(messages.len());
let mut pending: Vec<String> = Vec::new();
for msg in messages {
let is_result_message = matches!(
&msg.content,
MessageContent::Blocks(blocks)
if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))
);
if is_result_message {
let MessageContent::Blocks(blocks) = &msg.content else {
unreachable!("is_result_message implies Blocks");
};
let mut kept: Vec<ContentBlock> = blocks
.iter()
.filter(|b| match b {
ContentBlock::ToolResult { tool_use_id, .. } => pending.contains(tool_use_id),
_ => true,
})
.cloned()
.collect();
for block in &kept {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
pending.retain(|id| id != tool_use_id);
}
}
for id in pending.drain(..) {
kept.push(synthetic(&id));
}
if kept.is_empty() {
continue; }
repaired.push(Message {
role: msg.role.clone(),
content: MessageContent::Blocks(kept),
});
continue;
}
if !pending.is_empty() {
repaired.push(Message::tool_results(
pending.drain(..).map(|id| synthetic(&id)).collect(),
));
}
if let MessageContent::Blocks(blocks) = &msg.content {
for block in blocks {
if let ContentBlock::ToolUse { id, .. } = block {
pending.push(id.clone());
}
}
}
repaired.push(msg);
}
if !pending.is_empty() {
repaired.push(Message::tool_results(
pending.drain(..).map(|id| synthetic(&id)).collect(),
));
}
repaired
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::{ContentBlock, MessageContent};
fn tool_use_msg(id: &str) -> Message {
Message::assistant_blocks(vec![ContentBlock::ToolUse {
id: id.to_string(),
name: "Bash".to_string(),
input: serde_json::json!({"command": "true"}),
}])
}
fn tool_result_msg(id: &str) -> Message {
Message::tool_results(vec![ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: "ok".to_string(),
is_error: None,
}])
}
fn assert_valid_pairing(messages: &[Message]) {
let mut seen = std::collections::HashSet::new();
let mut pending: Vec<String> = Vec::new();
for msg in messages {
if let MessageContent::Blocks(blocks) = &msg.content {
let has_results = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. }));
if !has_results && !pending.is_empty() {
panic!("tool_uses {pending:?} not answered by the next message");
}
for block in blocks {
match block {
ContentBlock::ToolUse { id, .. } => {
seen.insert(id.clone());
pending.push(id.clone());
}
ContentBlock::ToolResult { tool_use_id, .. } => {
assert!(seen.contains(tool_use_id), "orphan result {tool_use_id}");
pending.retain(|p| p != tool_use_id);
}
ContentBlock::Text { .. } => {}
}
}
} else if !pending.is_empty() {
panic!("tool_uses {pending:?} not answered by the next message");
}
}
assert!(
pending.is_empty(),
"history ends with unanswered {pending:?}"
);
}
#[test]
fn repair_leaves_valid_history_untouched() {
let history = vec![
Message::user("hi"),
tool_use_msg("tu_1"),
tool_result_msg("tu_1"),
Message::assistant_text("done"),
];
let repaired = repair_history(history.clone());
assert_eq!(repaired.len(), history.len());
assert_valid_pairing(&repaired);
}
#[test]
fn repair_synthesizes_result_for_trailing_tool_use() {
let history = vec![Message::user("go"), tool_use_msg("tu_1")];
let repaired = repair_history(history);
assert_eq!(repaired.len(), 3);
assert_valid_pairing(&repaired);
let MessageContent::Blocks(blocks) = &repaired[2].content else {
panic!("expected synthetic results message");
};
assert!(matches!(
&blocks[0],
ContentBlock::ToolResult {
is_error: Some(true),
..
}
));
}
#[test]
fn repair_synthesizes_result_before_next_message() {
let history = vec![
Message::user("go"),
tool_use_msg("tu_1"),
Message::assistant_text("moving on"),
Message::user("ok"),
];
let repaired = repair_history(history);
assert_eq!(repaired.len(), 5);
assert_valid_pairing(&repaired);
}
#[test]
fn repair_drops_orphan_results() {
let history = vec![
Message::user("go"),
tool_result_msg("tu_ghost"),
Message::assistant_text("done"),
];
let repaired = repair_history(history);
assert_eq!(repaired.len(), 2, "orphan-only message must be dropped");
assert_valid_pairing(&repaired);
}
#[test]
fn repair_fills_partial_results() {
let history = vec![
Message::user("go"),
Message::assistant_blocks(vec![
ContentBlock::ToolUse {
id: "tu_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
},
ContentBlock::ToolUse {
id: "tu_2".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
},
]),
tool_result_msg("tu_1"),
Message::assistant_text("done"),
];
let repaired = repair_history(history);
assert_valid_pairing(&repaired);
}
}