Skip to main content

toolpath_gemini/
types.rs

1//! On-disk schema for Gemini CLI conversation files, plus the higher-level
2//! `Conversation` type that bundles a main chat with its sibling sub-agent
3//! chats.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11/// The raw JSON chat file as written by Gemini CLI.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ChatFile {
15    /// Short alphanumeric identifier written inside the file.
16    /// Distinct from the enclosing session UUID directory name.
17    #[serde(default)]
18    pub session_id: String,
19
20    /// SHA-256 hex of the absolute project path.
21    #[serde(default)]
22    pub project_hash: String,
23
24    #[serde(default)]
25    pub start_time: Option<DateTime<Utc>>,
26
27    #[serde(default)]
28    pub last_updated: Option<DateTime<Utc>>,
29
30    /// Workspace directories captured at session start. `None` preserves
31    /// "field absent in source" for round-trip fidelity; `Some(vec![])`
32    /// preserves an explicit empty array.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub directories: Option<Vec<PathBuf>>,
35
36    /// Present on sub-agent chat files (`"subagent"`); absent on the main chat.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub kind: Option<String>,
39
40    /// Sub-agent's reported result (populated on `kind: "subagent"` files).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub summary: Option<String>,
43
44    #[serde(default)]
45    pub messages: Vec<GeminiMessage>,
46
47    /// Forward-compat: anything not covered above lands here.
48    #[serde(flatten)]
49    pub extra: HashMap<String, Value>,
50}
51
52/// A single message within a chat file.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct GeminiMessage {
56    #[serde(default)]
57    pub id: String,
58
59    #[serde(default)]
60    pub timestamp: String,
61
62    /// `"user"` or `"gemini"`.
63    #[serde(rename = "type")]
64    pub role: GeminiRole,
65
66    #[serde(default)]
67    pub content: GeminiContent,
68
69    /// `None` = field absent in source. `Some(vec![])` = explicit empty array.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub thoughts: Option<Vec<Thought>>,
72
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub tokens: Option<Tokens>,
75
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub model: Option<String>,
78
79    /// `None` = field absent in source. `Some(vec![])` = explicit empty array.
80    #[serde(default, skip_serializing_if = "Option::is_none", rename = "toolCalls")]
81    pub tool_calls: Option<Vec<ToolCall>>,
82
83    #[serde(flatten)]
84    pub extra: HashMap<String, Value>,
85}
86
87impl GeminiMessage {
88    /// Borrow the thoughts list as a slice, regardless of whether the
89    /// source had the field absent or present-but-empty.
90    pub fn thoughts(&self) -> &[Thought] {
91        self.thoughts.as_deref().unwrap_or(&[])
92    }
93
94    /// Borrow the tool-calls list as a slice, regardless of whether the
95    /// source had the field absent or present-but-empty.
96    pub fn tool_calls(&self) -> &[ToolCall] {
97        self.tool_calls.as_deref().unwrap_or(&[])
98    }
99}
100
101impl ChatFile {
102    /// Borrow `directories` as a slice, regardless of absent-vs-empty.
103    pub fn directories(&self) -> &[PathBuf] {
104        self.directories.as_deref().unwrap_or(&[])
105    }
106}
107
108/// Who produced a message.
109///
110/// The three canonical values observed in real Gemini CLI logs are
111/// `"user"`, `"gemini"`, and `"info"` (system notifications like
112/// "Request cancelled."). Unknown values round-trip through
113/// [`GeminiRole::Other`] so the crate stays forward-compatible if
114/// Gemini adds new roles.
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub enum GeminiRole {
117    User,
118    Gemini,
119    Info,
120    Other(String),
121}
122
123impl GeminiRole {
124    pub fn as_str(&self) -> &str {
125        match self {
126            GeminiRole::User => "user",
127            GeminiRole::Gemini => "gemini",
128            GeminiRole::Info => "info",
129            GeminiRole::Other(s) => s,
130        }
131    }
132}
133
134impl serde::Serialize for GeminiRole {
135    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
136        s.serialize_str(self.as_str())
137    }
138}
139
140impl<'de> serde::Deserialize<'de> for GeminiRole {
141    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
142        let s = String::deserialize(d)?;
143        Ok(match s.as_str() {
144            "user" => GeminiRole::User,
145            "gemini" => GeminiRole::Gemini,
146            "info" => GeminiRole::Info,
147            _ => GeminiRole::Other(s),
148        })
149    }
150}
151
152/// Message content. User messages are typically `Parts([{text}])`; Gemini
153/// messages are typically `Text("…")` or `Empty` (with the payload in
154/// `toolCalls`).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(untagged)]
157pub enum GeminiContent {
158    Text(String),
159    Parts(Vec<TextPart>),
160}
161
162impl Default for GeminiContent {
163    fn default() -> Self {
164        GeminiContent::Text(String::new())
165    }
166}
167
168impl GeminiContent {
169    /// Flattened text content. Empty strings collapse to an empty result.
170    pub fn text(&self) -> String {
171        match self {
172            GeminiContent::Text(s) => s.clone(),
173            GeminiContent::Parts(parts) => parts
174                .iter()
175                .filter_map(|p| p.text.as_deref())
176                .collect::<Vec<_>>()
177                .join("\n"),
178        }
179    }
180
181    /// True when the content has no extractable text.
182    pub fn is_empty(&self) -> bool {
183        match self {
184            GeminiContent::Text(s) => s.is_empty(),
185            GeminiContent::Parts(parts) => parts
186                .iter()
187                .all(|p| p.text.as_deref().unwrap_or("").is_empty()),
188        }
189    }
190}
191
192/// One part of a multi-part message. Gemini currently only emits text parts.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct TextPart {
195    #[serde(default)]
196    pub text: Option<String>,
197    #[serde(flatten)]
198    pub extra: HashMap<String, Value>,
199}
200
201/// A single thinking step.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Thought {
204    #[serde(default)]
205    pub subject: Option<String>,
206    #[serde(default)]
207    pub description: Option<String>,
208    #[serde(default)]
209    pub timestamp: Option<String>,
210}
211
212/// Per-message token usage. All fields optional because older logs may omit
213/// them.
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct Tokens {
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub input: Option<u32>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub output: Option<u32>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub cached: Option<u32>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub thoughts: Option<u32>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub tool: Option<u32>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub total: Option<u32>,
228}
229
230/// A tool invocation.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct ToolCall {
234    #[serde(default)]
235    pub id: String,
236
237    #[serde(default)]
238    pub name: String,
239
240    #[serde(default)]
241    pub args: Value,
242
243    /// `"success"`, `"error"`, or a pending state like `"executing"`.
244    #[serde(default)]
245    pub status: String,
246
247    #[serde(default)]
248    pub timestamp: String,
249
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub result: Vec<FunctionResponse>,
252
253    /// Display-ready result payload. Usually a string, sometimes a
254    /// structured value (object with `fileDiff`, styled-text arrays,
255    /// etc.). Kept as an opaque `Value` to match whatever Gemini writes.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub result_display: Option<Value>,
258
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub description: Option<String>,
261
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub display_name: Option<String>,
264
265    #[serde(flatten)]
266    pub extra: HashMap<String, Value>,
267}
268
269impl ToolCall {
270    /// If `resultDisplay` is a structured diff (common for
271    /// `write_file`/`replace`), return the unified-diff text.
272    pub fn file_diff(&self) -> Option<String> {
273        self.result_display
274            .as_ref()
275            .and_then(|v| v.get("fileDiff"))
276            .and_then(|v| v.as_str())
277            .map(str::to_string)
278    }
279
280    /// Plain-text flavour of `resultDisplay` when it's a plain string
281    /// rather than a structured value.
282    pub fn result_display_text(&self) -> Option<String> {
283        self.result_display
284            .as_ref()
285            .and_then(|v| v.as_str())
286            .map(str::to_string)
287    }
288}
289
290impl ToolCall {
291    /// Flattened text content from all function responses.
292    pub fn result_text(&self) -> String {
293        let mut parts: Vec<String> = Vec::new();
294        for fr in &self.result {
295            parts.push(response_to_text(&fr.function_response.response));
296        }
297        parts.join("\n")
298    }
299
300    /// True when this call reported an error status.
301    pub fn is_error(&self) -> bool {
302        self.status == "error"
303    }
304}
305
306/// One entry in `toolCalls[].result`, wrapping a Gemini `FunctionResponse`.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct FunctionResponse {
309    #[serde(default, rename = "functionResponse")]
310    pub function_response: FunctionResponseBody,
311}
312
313#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct FunctionResponseBody {
315    #[serde(default)]
316    pub id: String,
317    #[serde(default)]
318    pub name: String,
319    #[serde(default)]
320    pub response: Value,
321}
322
323fn response_to_text(v: &Value) -> String {
324    if let Some(output) = v.get("output").and_then(|o| o.as_str()) {
325        return output.to_string();
326    }
327    if let Some(s) = v.as_str() {
328        return s.to_string();
329    }
330    if v.is_null() {
331        return String::new();
332    }
333    v.to_string()
334}
335
336/// A `logs.json` entry (lightweight per-project prompt log).
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct LogEntry {
340    #[serde(default)]
341    pub session_id: String,
342    #[serde(default)]
343    pub message_id: u64,
344    #[serde(default, rename = "type")]
345    pub entry_type: String,
346    #[serde(default)]
347    pub message: String,
348    #[serde(default)]
349    pub timestamp: String,
350}
351
352/// Lightweight summary of a session on disk.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct ConversationMetadata {
355    /// The session UUID (directory name under `chats/`).
356    pub session_uuid: String,
357    pub project_path: String,
358    pub file_path: PathBuf,
359    pub message_count: usize,
360    pub started_at: Option<DateTime<Utc>>,
361    pub last_activity: Option<DateTime<Utc>>,
362    /// Number of sub-agent chat files detected alongside the main chat.
363    pub sub_agent_count: usize,
364    /// The first non-empty user-prompt text in the main chat. Useful as a
365    /// human-readable title for picker UIs.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub first_user_message: Option<String>,
368}
369
370/// A fully-loaded session: the main chat plus every sub-agent file in the
371/// same session UUID directory.
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct Conversation {
374    /// Session UUID from the directory name (not the inner `sessionId`).
375    pub session_uuid: String,
376    pub project_path: Option<String>,
377    /// The main chat file (the one without `kind: "subagent"`). If the
378    /// directory only contains sub-agent files, the first one is used.
379    pub main: ChatFile,
380    /// Sub-agent chat files found alongside the main chat.
381    pub sub_agents: Vec<ChatFile>,
382    pub started_at: Option<DateTime<Utc>>,
383    pub last_activity: Option<DateTime<Utc>>,
384}
385
386impl Conversation {
387    pub fn new(session_uuid: String, main: ChatFile) -> Self {
388        let started_at = main.start_time;
389        let last_activity = main.last_updated;
390        Self {
391            session_uuid,
392            project_path: None,
393            main,
394            sub_agents: Vec::new(),
395            started_at,
396            last_activity,
397        }
398    }
399
400    /// Messages in the main chat, in file order.
401    pub fn messages(&self) -> &[GeminiMessage] {
402        &self.main.messages
403    }
404
405    /// Total message count across main chat and sub-agent chats.
406    pub fn total_message_count(&self) -> usize {
407        self.main.messages.len()
408            + self
409                .sub_agents
410                .iter()
411                .map(|s| s.messages.len())
412                .sum::<usize>()
413    }
414
415    /// First user-message text from the main chat (untruncated).
416    pub fn first_user_text(&self) -> Option<String> {
417        self.main.messages.iter().find_map(|m| {
418            if m.role == GeminiRole::User {
419                let t = m.content.text();
420                if t.is_empty() { None } else { Some(t) }
421            } else {
422                None
423            }
424        })
425    }
426
427    /// Text of the first user message, truncated to `max_len` characters.
428    pub fn title(&self, max_len: usize) -> Option<String> {
429        self.first_user_text().map(|t| {
430            if t.chars().count() > max_len {
431                let trunc: String = t.chars().take(max_len).collect();
432                format!("{}...", trunc)
433            } else {
434                t
435            }
436        })
437    }
438
439    /// Iterate all messages (main + sub-agents) in document order.
440    pub fn all_messages(&self) -> impl Iterator<Item = &GeminiMessage> {
441        self.main
442            .messages
443            .iter()
444            .chain(self.sub_agents.iter().flat_map(|s| s.messages.iter()))
445    }
446
447    /// Find the sub-agent chat whose inner `sessionId` matches.
448    pub fn sub_agent_by_session_id(&self, session_id: &str) -> Option<&ChatFile> {
449        self.sub_agents.iter().find(|s| s.session_id == session_id)
450    }
451}
452
453// ── Tests ────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    const MIN_CHAT: &str = r#"{
460  "sessionId": "abc",
461  "projectHash": "deadbeef",
462  "startTime": "2026-04-17T15:23:55.515Z",
463  "lastUpdated": "2026-04-17T15:27:05.630Z",
464  "messages": [
465    {"id":"m1","timestamp":"2026-04-17T15:23:55.515Z","type":"user","content":[{"text":"Hello"}]},
466    {"id":"m2","timestamp":"2026-04-17T15:23:57.196Z","type":"gemini","content":"Hi","model":"gemini-3-flash-preview","tokens":{"input":10,"output":2,"cached":0,"thoughts":0,"tool":0,"total":12}}
467  ]
468}"#;
469
470    #[test]
471    fn test_parse_minimal_chat() {
472        let chat: ChatFile = serde_json::from_str(MIN_CHAT).unwrap();
473        assert_eq!(chat.session_id, "abc");
474        assert_eq!(chat.project_hash, "deadbeef");
475        assert_eq!(chat.messages.len(), 2);
476        assert_eq!(chat.messages[0].role, GeminiRole::User);
477        assert_eq!(chat.messages[1].role, GeminiRole::Gemini);
478        assert_eq!(chat.messages[1].content.text(), "Hi");
479        assert_eq!(
480            chat.messages[1].model.as_deref(),
481            Some("gemini-3-flash-preview")
482        );
483        assert_eq!(chat.messages[1].tokens.as_ref().unwrap().input, Some(10));
484    }
485
486    #[test]
487    fn test_content_text_from_parts() {
488        let c = GeminiContent::Parts(vec![
489            TextPart {
490                text: Some("a".into()),
491                extra: Default::default(),
492            },
493            TextPart {
494                text: None,
495                extra: Default::default(),
496            },
497            TextPart {
498                text: Some("b".into()),
499                extra: Default::default(),
500            },
501        ]);
502        assert_eq!(c.text(), "a\nb");
503    }
504
505    #[test]
506    fn test_content_text_empty_string() {
507        let c = GeminiContent::Text(String::new());
508        assert!(c.is_empty());
509        assert_eq!(c.text(), "");
510    }
511
512    #[test]
513    fn test_parse_tool_call_with_result() {
514        let json = r#"{
515  "id":"t1","name":"read_file","args":{"path":"x"},
516  "status":"success","timestamp":"2026-04-17T15:23:57Z",
517  "result":[{"functionResponse":{"id":"t1","name":"read_file","response":{"output":"hello"}}}]
518}"#;
519        let tc: ToolCall = serde_json::from_str(json).unwrap();
520        assert_eq!(tc.name, "read_file");
521        assert_eq!(tc.status, "success");
522        assert!(!tc.is_error());
523        assert_eq!(tc.result_text(), "hello");
524    }
525
526    #[test]
527    fn test_parse_tool_call_error() {
528        let json = r#"{"id":"t1","name":"run_shell_command","args":{},"status":"error","timestamp":"2026-04-17T15:23:57Z"}"#;
529        let tc: ToolCall = serde_json::from_str(json).unwrap();
530        assert!(tc.is_error());
531    }
532
533    #[test]
534    fn test_parse_subagent_file() {
535        let json = r#"{
536  "sessionId":"qclszz",
537  "projectHash":"d",
538  "kind":"subagent",
539  "summary":"found the bug",
540  "messages":[]
541}"#;
542        let chat: ChatFile = serde_json::from_str(json).unwrap();
543        assert_eq!(chat.kind.as_deref(), Some("subagent"));
544        assert_eq!(chat.summary.as_deref(), Some("found the bug"));
545    }
546
547    #[test]
548    fn test_conversation_helpers() {
549        let main: ChatFile = serde_json::from_str(MIN_CHAT).unwrap();
550        let convo = Conversation::new("session-uuid".to_string(), main);
551        assert_eq!(convo.total_message_count(), 2);
552        assert_eq!(convo.first_user_text().as_deref(), Some("Hello"));
553        assert_eq!(convo.title(3).as_deref(), Some("Hel..."));
554        assert_eq!(convo.title(100).as_deref(), Some("Hello"));
555    }
556
557    #[test]
558    fn test_thoughts_optional() {
559        let json = r#"{"id":"m","timestamp":"t","type":"gemini","content":"","thoughts":[{"subject":"s","description":"d","timestamp":"t"}]}"#;
560        let msg: GeminiMessage = serde_json::from_str(json).unwrap();
561        assert_eq!(msg.thoughts().len(), 1);
562        assert_eq!(msg.thoughts()[0].subject.as_deref(), Some("s"));
563    }
564
565    #[test]
566    fn test_log_entry() {
567        let json =
568            r#"{"sessionId":"s","messageId":0,"type":"user","message":"hi","timestamp":"t"}"#;
569        let e: LogEntry = serde_json::from_str(json).unwrap();
570        assert_eq!(e.session_id, "s");
571        assert_eq!(e.message, "hi");
572    }
573
574    #[test]
575    fn test_function_response_nested() {
576        let json = r#"[{"functionResponse":{"id":"t","name":"x","response":"plain"}}]"#;
577        let responses: Vec<FunctionResponse> = serde_json::from_str(json).unwrap();
578        assert_eq!(responses.len(), 1);
579        assert_eq!(responses[0].function_response.name, "x");
580    }
581
582    #[test]
583    fn test_response_to_text_fallback() {
584        assert_eq!(response_to_text(&serde_json::json!("hello")), "hello");
585        assert_eq!(response_to_text(&serde_json::json!(null)), "");
586        assert_eq!(response_to_text(&serde_json::json!({"output":"ok"})), "ok");
587        // Non-string, non-object values stringify
588        assert_eq!(response_to_text(&serde_json::json!(42)), "42");
589    }
590
591    // ── Accessors for Option<Vec<T>> fields ──────────────────────────
592
593    #[test]
594    fn test_accessors_return_empty_slice_when_absent() {
595        let msg: GeminiMessage =
596            serde_json::from_str(r#"{"id":"m","timestamp":"ts","type":"user","content":""}"#)
597                .unwrap();
598        assert!(msg.thoughts.is_none());
599        assert!(msg.tool_calls.is_none());
600        assert!(msg.thoughts().is_empty());
601        assert!(msg.tool_calls().is_empty());
602
603        let chat: ChatFile =
604            serde_json::from_str(r#"{"sessionId":"s","projectHash":"","messages":[]}"#).unwrap();
605        assert!(chat.directories.is_none());
606        assert!(chat.directories().is_empty());
607    }
608
609    #[test]
610    fn test_accessors_return_slice_when_empty_array() {
611        let msg: GeminiMessage = serde_json::from_str(
612            r#"{"id":"m","timestamp":"ts","type":"user","content":"","thoughts":[],"toolCalls":[]}"#,
613        )
614        .unwrap();
615        assert!(msg.thoughts.is_some());
616        assert!(msg.tool_calls.is_some());
617        assert!(msg.thoughts().is_empty());
618        assert!(msg.tool_calls().is_empty());
619    }
620
621    // ── GeminiRole exhaustive round-trip ────────────────────────────
622
623    #[test]
624    fn test_gemini_role_all_variants_roundtrip() {
625        for (json, role) in [
626            (r#""user""#, GeminiRole::User),
627            (r#""gemini""#, GeminiRole::Gemini),
628            (r#""info""#, GeminiRole::Info),
629        ] {
630            let parsed: GeminiRole = serde_json::from_str(json).unwrap();
631            assert_eq!(parsed, role);
632            let back = serde_json::to_string(&role).unwrap();
633            assert_eq!(back, json);
634        }
635        // Unknown values survive via Other(String)
636        let parsed: GeminiRole = serde_json::from_str(r#""plan""#).unwrap();
637        assert_eq!(parsed, GeminiRole::Other("plan".to_string()));
638        assert_eq!(parsed.as_str(), "plan");
639        let back = serde_json::to_string(&parsed).unwrap();
640        assert_eq!(back, r#""plan""#);
641    }
642
643    #[test]
644    fn test_gemini_role_as_str() {
645        assert_eq!(GeminiRole::User.as_str(), "user");
646        assert_eq!(GeminiRole::Gemini.as_str(), "gemini");
647        assert_eq!(GeminiRole::Info.as_str(), "info");
648        assert_eq!(GeminiRole::Other("x".into()).as_str(), "x");
649    }
650
651    // ── ToolCall::file_diff / result_display_text ────────────────────
652
653    #[test]
654    fn test_tool_call_file_diff_from_dict() {
655        let tc: ToolCall = serde_json::from_str(
656            r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"@@ -0,0 +1 @@\n+x"}}"#,
657        )
658        .unwrap();
659        assert_eq!(tc.file_diff().as_deref(), Some("@@ -0,0 +1 @@\n+x"));
660    }
661
662    #[test]
663    fn test_tool_call_file_diff_absent_when_no_result_display() {
664        let tc: ToolCall = serde_json::from_str(
665            r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts"}"#,
666        )
667        .unwrap();
668        assert!(tc.file_diff().is_none());
669    }
670
671    #[test]
672    fn test_tool_call_file_diff_absent_when_plain_string() {
673        let tc: ToolCall = serde_json::from_str(
674            r#"{"id":"t","name":"run_shell_command","args":{},"status":"success","timestamp":"ts","resultDisplay":"output text"}"#,
675        )
676        .unwrap();
677        assert!(tc.file_diff().is_none());
678        assert_eq!(tc.result_display_text().as_deref(), Some("output text"));
679    }
680
681    #[test]
682    fn test_tool_call_result_display_text_absent_when_dict() {
683        let tc: ToolCall = serde_json::from_str(
684            r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"x"}}"#,
685        )
686        .unwrap();
687        assert!(tc.result_display_text().is_none());
688    }
689
690    // ── Conversation::sub_agent_by_session_id, all_messages ──────────
691
692    #[test]
693    fn test_sub_agent_by_session_id() {
694        let main: ChatFile =
695            serde_json::from_str(r#"{"sessionId":"main","projectHash":"","messages":[]}"#).unwrap();
696        let mut convo = Conversation::new("uuid".into(), main);
697        let sub: ChatFile = serde_json::from_str(
698            r#"{"sessionId":"helper","projectHash":"","kind":"subagent","messages":[]}"#,
699        )
700        .unwrap();
701        convo.sub_agents.push(sub);
702        assert!(convo.sub_agent_by_session_id("helper").is_some());
703        assert!(convo.sub_agent_by_session_id("nope").is_none());
704    }
705
706    #[test]
707    fn test_conversation_all_messages_covers_main_and_subs() {
708        let main: ChatFile = serde_json::from_str(
709            r#"{"sessionId":"m","projectHash":"","messages":[
710  {"id":"u","timestamp":"ts","type":"user","content":"hi"}
711]}"#,
712        )
713        .unwrap();
714        let mut convo = Conversation::new("u".into(), main);
715        let sub: ChatFile = serde_json::from_str(
716            r#"{"sessionId":"s","projectHash":"","kind":"subagent","messages":[
717  {"id":"s1","timestamp":"ts","type":"user","content":"sub"}
718]}"#,
719        )
720        .unwrap();
721        convo.sub_agents.push(sub);
722        let all: Vec<&GeminiMessage> = convo.all_messages().collect();
723        assert_eq!(all.len(), 2);
724    }
725
726    #[test]
727    fn test_conversation_messages_accessor() {
728        let main: ChatFile = serde_json::from_str(
729            r#"{"sessionId":"m","projectHash":"","messages":[
730  {"id":"u","timestamp":"ts","type":"user","content":"hi"}
731]}"#,
732        )
733        .unwrap();
734        let convo = Conversation::new("u".into(), main);
735        assert_eq!(convo.messages().len(), 1);
736    }
737
738    // ── GeminiContent further coverage ─────────────────────────────
739
740    #[test]
741    fn test_content_default_is_empty_text() {
742        let c = GeminiContent::default();
743        assert!(matches!(c, GeminiContent::Text(ref s) if s.is_empty()));
744    }
745
746    #[test]
747    fn test_content_parts_is_empty_all_none_texts() {
748        let c = GeminiContent::Parts(vec![TextPart {
749            text: None,
750            extra: Default::default(),
751        }]);
752        assert!(c.is_empty());
753    }
754}