use serde_json::Value;
use crate::conversation::{Conversation, ConversationEntry};
use super::content_text;
const HUMAN_TURN: &str = "user";
const MODEL_TURN: &str = "gemini";
#[must_use]
pub fn is_session_document(document: &Value) -> bool {
document.get("messages").is_some_and(Value::is_array)
}
#[must_use]
pub fn parse_session(document: &Value, session_id: &str) -> Option<Conversation> {
if !is_session_document(document) {
return None;
}
let session_id = if session_id.is_empty() {
document
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or("gemini-session")
} else {
session_id
};
let mut conv = Conversation::new(session_id);
conv.first_timestamp = timestamp(document, "startTime");
conv.last_timestamp = timestamp(document, "lastUpdated");
for message in document["messages"].as_array().into_iter().flatten() {
append_message(&mut conv, message);
}
Some(conv)
}
fn append_message(conv: &mut Conversation, message: &Value) {
let text = message
.get("content")
.map(content_text)
.unwrap_or_default()
.trim()
.to_string();
match message.get("type").and_then(Value::as_str).unwrap_or("") {
HUMAN_TURN if !text.is_empty() => {
conv.user_message_count += 1;
conv.entries.push(ConversationEntry::UserMessage(text));
}
MODEL_TURN => {
if !text.is_empty() {
conv.assistant_message_count += 1;
conv.entries.push(ConversationEntry::AssistantText(text));
}
append_tool_calls(conv, message);
}
_ => {}
}
}
fn append_tool_calls(conv: &mut Conversation, message: &Value) {
for call in message
.get("toolCalls")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = call
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let input_summary = call
.get("args")
.map(|args| crate::conversation::truncate(&args.to_string(), 200))
.unwrap_or_default();
conv.entries.push(ConversationEntry::ToolUse {
name,
input_summary,
});
}
}
fn timestamp(document: &Value, field: &str) -> Option<String> {
document
.get(field)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Value {
serde_json::json!({
"sessionId": "sess-1",
"projectHash": "abc123",
"startTime": "2026-08-06T10:00:00Z",
"lastUpdated": "2026-08-06T10:30:00Z",
"messages": [
{"type": "user", "content": "Where does recall-echo live?"},
{
"type": "gemini",
"content": [{"text": "Under /opt/recall-echo."}],
"thoughts": [{"subject": "plan", "description": "check the path first"}],
"toolCalls": [{"name": "read_file", "args": {"path": "/opt/recall-echo"}}],
},
{"type": "info", "content": "Model switched to gemini-2.5-pro."},
{"type": "error", "content": "Quota exceeded."},
],
})
}
#[test]
fn a_session_document_is_recognised_by_its_messages() {
assert!(is_session_document(&session()));
assert!(!is_session_document(&serde_json::json!({"sessionId": "s"})));
assert!(!is_session_document(&serde_json::json!({"messages": "no"})));
}
#[test]
fn both_parties_turns_survive_and_nothing_else_does() {
let conv = parse_session(&session(), "hook-session").expect("recognised");
assert_eq!(conv.session_id, "hook-session");
assert_eq!(conv.user_message_count, 1);
assert_eq!(conv.assistant_message_count, 1);
assert_eq!(
conv.first_timestamp.as_deref(),
Some("2026-08-06T10:00:00Z")
);
assert_eq!(conv.last_timestamp.as_deref(), Some("2026-08-06T10:30:00Z"));
let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
assert!(markdown.contains("Where does recall-echo live?"));
assert!(markdown.contains("Under /opt/recall-echo."));
assert!(markdown.contains("read_file"));
assert!(
!markdown.contains("Model switched"),
"harness notices are not turns: {markdown}"
);
assert!(!markdown.contains("Quota exceeded"), "{markdown}");
}
#[test]
fn thoughts_never_become_a_turn() {
let conv = parse_session(&session(), "s").expect("recognised");
for entry in &conv.entries {
if let ConversationEntry::AssistantText(text) = entry {
assert!(!text.contains("check the path first"), "{text}");
}
}
}
#[test]
fn every_shape_of_content_reads_the_same() {
let document = serde_json::json!({"messages": [
{"type": "user", "content": "bare string"},
{"type": "user", "content": {"text": "one part"}},
{"type": "user", "content": [{"text": "two "}, {"text": "parts"}]},
]});
let conv = parse_session(&document, "s").expect("recognised");
let said: Vec<&str> = conv
.entries
.iter()
.filter_map(|entry| match entry {
ConversationEntry::UserMessage(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(said, ["bare string", "one part", "two parts"]);
}
#[test]
fn an_unreadable_message_is_dropped_not_invented() {
let document = serde_json::json!({"messages": [
{"type": "user"},
{"type": "user", "content": 42},
{"type": "unheard-of", "content": "something new"},
{"type": "gemini", "content": ""},
]});
let conv = parse_session(&document, "s").expect("recognised");
assert_eq!(conv.user_message_count, 0);
assert_eq!(conv.assistant_message_count, 0);
assert!(conv.entries.is_empty(), "{:?}", conv.entries);
}
#[test]
fn a_document_that_is_not_a_session_is_not_parsed() {
let document = serde_json::json!({"type": "user", "message": {"role": "user"}});
assert!(parse_session(&document, "s").is_none());
}
#[test]
fn the_document_names_the_session_when_the_caller_cannot() {
let conv = parse_session(&session(), "").expect("recognised");
assert_eq!(conv.session_id, "sess-1");
}
}