recall_echo/transcript/
gemini.rs1use serde_json::Value;
49
50use crate::conversation::{Conversation, ConversationEntry};
51
52use super::content_text;
53
54const HUMAN_TURN: &str = "user";
56const MODEL_TURN: &str = "gemini";
57
58#[must_use]
64pub fn is_session_document(document: &Value) -> bool {
65 document.get("messages").is_some_and(Value::is_array)
66}
67
68#[must_use]
74pub fn parse_session(document: &Value, session_id: &str) -> Option<Conversation> {
75 if !is_session_document(document) {
76 return None;
77 }
78
79 let session_id = if session_id.is_empty() {
80 document
81 .get("sessionId")
82 .and_then(Value::as_str)
83 .unwrap_or("gemini-session")
84 } else {
85 session_id
86 };
87
88 let mut conv = Conversation::new(session_id);
89 conv.first_timestamp = timestamp(document, "startTime");
90 conv.last_timestamp = timestamp(document, "lastUpdated");
91
92 for message in document["messages"].as_array().into_iter().flatten() {
93 append_message(&mut conv, message);
94 }
95
96 Some(conv)
97}
98
99fn append_message(conv: &mut Conversation, message: &Value) {
101 let text = message
102 .get("content")
103 .map(content_text)
104 .unwrap_or_default()
105 .trim()
106 .to_string();
107
108 match message.get("type").and_then(Value::as_str).unwrap_or("") {
109 HUMAN_TURN if !text.is_empty() => {
110 conv.user_message_count += 1;
111 conv.entries.push(ConversationEntry::UserMessage(text));
112 }
113 MODEL_TURN => {
114 if !text.is_empty() {
115 conv.assistant_message_count += 1;
116 conv.entries.push(ConversationEntry::AssistantText(text));
117 }
118 append_tool_calls(conv, message);
119 }
120 _ => {}
123 }
124}
125
126fn append_tool_calls(conv: &mut Conversation, message: &Value) {
128 for call in message
129 .get("toolCalls")
130 .and_then(Value::as_array)
131 .into_iter()
132 .flatten()
133 {
134 let name = call
135 .get("name")
136 .and_then(Value::as_str)
137 .unwrap_or("unknown")
138 .to_string();
139 let input_summary = call
140 .get("args")
141 .map(|args| crate::conversation::truncate(&args.to_string(), 200))
142 .unwrap_or_default();
143 conv.entries.push(ConversationEntry::ToolUse {
144 name,
145 input_summary,
146 });
147 }
148}
149
150fn timestamp(document: &Value, field: &str) -> Option<String> {
151 document
152 .get(field)
153 .and_then(Value::as_str)
154 .filter(|value| !value.is_empty())
155 .map(str::to_string)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn session() -> Value {
163 serde_json::json!({
164 "sessionId": "sess-1",
165 "projectHash": "abc123",
166 "startTime": "2026-08-06T10:00:00Z",
167 "lastUpdated": "2026-08-06T10:30:00Z",
168 "messages": [
169 {"type": "user", "content": "Where does recall-echo live?"},
170 {
171 "type": "gemini",
172 "content": [{"text": "Under /opt/recall-echo."}],
173 "thoughts": [{"subject": "plan", "description": "check the path first"}],
174 "toolCalls": [{"name": "read_file", "args": {"path": "/opt/recall-echo"}}],
175 },
176 {"type": "info", "content": "Model switched to gemini-2.5-pro."},
177 {"type": "error", "content": "Quota exceeded."},
178 ],
179 })
180 }
181
182 #[test]
183 fn a_session_document_is_recognised_by_its_messages() {
184 assert!(is_session_document(&session()));
185 assert!(!is_session_document(&serde_json::json!({"sessionId": "s"})));
186 assert!(!is_session_document(&serde_json::json!({"messages": "no"})));
187 }
188
189 #[test]
190 fn both_parties_turns_survive_and_nothing_else_does() {
191 let conv = parse_session(&session(), "hook-session").expect("recognised");
192
193 assert_eq!(conv.session_id, "hook-session");
194 assert_eq!(conv.user_message_count, 1);
195 assert_eq!(conv.assistant_message_count, 1);
196 assert_eq!(
197 conv.first_timestamp.as_deref(),
198 Some("2026-08-06T10:00:00Z")
199 );
200 assert_eq!(conv.last_timestamp.as_deref(), Some("2026-08-06T10:30:00Z"));
201
202 let markdown = crate::conversation::conversation_to_markdown(&conv, 1);
203 assert!(markdown.contains("Where does recall-echo live?"));
204 assert!(markdown.contains("Under /opt/recall-echo."));
205 assert!(markdown.contains("read_file"));
206 assert!(
207 !markdown.contains("Model switched"),
208 "harness notices are not turns: {markdown}"
209 );
210 assert!(!markdown.contains("Quota exceeded"), "{markdown}");
211 }
212
213 #[test]
216 fn thoughts_never_become_a_turn() {
217 let conv = parse_session(&session(), "s").expect("recognised");
218 for entry in &conv.entries {
219 if let ConversationEntry::AssistantText(text) = entry {
220 assert!(!text.contains("check the path first"), "{text}");
221 }
222 }
223 }
224
225 #[test]
228 fn every_shape_of_content_reads_the_same() {
229 let document = serde_json::json!({"messages": [
230 {"type": "user", "content": "bare string"},
231 {"type": "user", "content": {"text": "one part"}},
232 {"type": "user", "content": [{"text": "two "}, {"text": "parts"}]},
233 ]});
234 let conv = parse_session(&document, "s").expect("recognised");
235 let said: Vec<&str> = conv
236 .entries
237 .iter()
238 .filter_map(|entry| match entry {
239 ConversationEntry::UserMessage(text) => Some(text.as_str()),
240 _ => None,
241 })
242 .collect();
243 assert_eq!(said, ["bare string", "one part", "two parts"]);
244 }
245
246 #[test]
249 fn an_unreadable_message_is_dropped_not_invented() {
250 let document = serde_json::json!({"messages": [
251 {"type": "user"},
252 {"type": "user", "content": 42},
253 {"type": "unheard-of", "content": "something new"},
254 {"type": "gemini", "content": ""},
255 ]});
256 let conv = parse_session(&document, "s").expect("recognised");
257 assert_eq!(conv.user_message_count, 0);
258 assert_eq!(conv.assistant_message_count, 0);
259 assert!(conv.entries.is_empty(), "{:?}", conv.entries);
260 }
261
262 #[test]
263 fn a_document_that_is_not_a_session_is_not_parsed() {
264 let document = serde_json::json!({"type": "user", "message": {"role": "user"}});
265 assert!(parse_session(&document, "s").is_none());
266 }
267
268 #[test]
271 fn the_document_names_the_session_when_the_caller_cannot() {
272 let conv = parse_session(&session(), "").expect("recognised");
273 assert_eq!(conv.session_id, "sess-1");
274 }
275}