Skip to main content

gemini_cli_sdk/types/
messages.rs

1//! Public message types — the primary types consumers work with.
2//!
3//! The [`Message`] enum is the top-level type yielded by the streaming API.
4//! Each variant carries a typed payload. All structs use `#[serde(default)]`
5//! on every field for forward-compatibility with unknown server additions,
6//! and `#[serde(flatten)] pub extra: Value` to capture unknown fields without
7//! losing them.
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use super::content::ContentBlock;
13
14// ── Top-level Message enum ──────────────────────────────────────────────
15
16/// Top-level message enum — the primary type consumers work with.
17///
18/// Every message emitted by the SDK during a session is one of these variants.
19/// Consumers typically `match` on this enum to route messages to their
20/// appropriate handlers.
21///
22/// # Example
23///
24/// ```rust,ignore
25/// match message {
26///     Message::System(sys) => println!("session: {}", sys.session_id),
27///     Message::Assistant(asst) => {
28///         if let Some(text) = Message::Assistant(asst).assistant_text() {
29///             println!("{}", text);
30///         }
31///     }
32///     Message::Result(r) if r.is_error => eprintln!("error turn"),
33///     Message::StreamEvent(ev) => { /* rich streaming data */ }
34///     _ => {}
35/// }
36/// ```
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum Message {
40    /// Synthesized initialization message, emitted once per session.
41    System(SystemMessage),
42    /// An assistant response containing one or more content blocks.
43    Assistant(AssistantMessage),
44    /// A user message (present in session history / resume replay).
45    User(UserMessage),
46    /// Terminal message for a completed prompt turn.
47    Result(ResultMessage),
48    /// Rich streaming event for data that does not map to a content block
49    /// (tool call lifecycle, plan updates, usage deltas, etc.).
50    #[serde(rename = "stream_event")]
51    StreamEvent(StreamEvent),
52}
53
54impl Message {
55    /// Returns the session ID carried by this message, if present.
56    ///
57    /// All current variants carry a `session_id`. This method returns
58    /// `Some(&str)` for every variant; the `Option` wrapper exists for
59    /// forward-compatibility with future variants that may omit it.
60    pub fn session_id(&self) -> Option<&str> {
61        match self {
62            Message::System(m) => Some(&m.session_id),
63            Message::Assistant(m) => Some(&m.session_id),
64            Message::User(m) => Some(&m.session_id),
65            Message::Result(m) => Some(&m.session_id),
66            Message::StreamEvent(m) => Some(&m.session_id),
67        }
68    }
69
70    /// Returns `true` if this is a [`ResultMessage`] with `is_error = true`.
71    pub fn is_error_result(&self) -> bool {
72        matches!(self, Message::Result(r) if r.is_error)
73    }
74
75    /// Returns `true` if this is a [`StreamEvent`].
76    pub fn is_stream_event(&self) -> bool {
77        matches!(self, Message::StreamEvent(_))
78    }
79
80    /// Extracts concatenated text from all [`ContentBlock::Text`] blocks in
81    /// an [`AssistantMessage`]. Returns `None` for any other variant, and
82    /// `None` when the assistant message contains no text blocks.
83    pub fn assistant_text(&self) -> Option<String> {
84        let Message::Assistant(m) = self else {
85            return None;
86        };
87        let texts: Vec<&str> = m
88            .message
89            .content
90            .iter()
91            .filter_map(|b| match b {
92                ContentBlock::Text(t) => Some(t.text.as_str()),
93                _ => None,
94            })
95            .collect();
96        if texts.is_empty() {
97            None
98        } else {
99            Some(texts.join(""))
100        }
101    }
102}
103
104// ── System Message ──────────────────────────────────────────────────────
105
106/// Initialization message synthesized from `initialize` + `session/new`
107/// responses. Emitted exactly once at the start of each session.
108///
109/// This is not a wire type — it is constructed by the SDK transport layer
110/// during the connection handshake and never arrives as a raw JSON-RPC
111/// notification.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct SystemMessage {
114    /// Subtype discriminator (e.g., `"init"`).
115    #[serde(default)]
116    pub subtype: String,
117    /// Unique session identifier assigned by the server.
118    #[serde(default)]
119    pub session_id: String,
120    /// Server's current working directory.
121    #[serde(default)]
122    pub cwd: String,
123    /// Names of built-in tools available in this session.
124    #[serde(default)]
125    pub tools: Vec<String>,
126    /// Status of configured MCP servers.
127    #[serde(default)]
128    pub mcp_servers: Vec<McpServerStatus>,
129    /// Model identifier active for this session.
130    #[serde(default)]
131    pub model: String,
132    /// Unknown fields preserved for forward-compatibility.
133    #[serde(flatten)]
134    pub extra: Value,
135}
136
137/// MCP server connection status reported in [`SystemMessage`].
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct McpServerStatus {
140    /// Server name as declared in the MCP configuration.
141    pub name: String,
142    /// Connection status (e.g., `"connected"`, `"failed"`).
143    #[serde(default)]
144    pub status: String,
145    /// Unknown fields preserved for forward-compatibility.
146    #[serde(flatten)]
147    pub extra: Value,
148}
149
150// ── Assistant Message ───────────────────────────────────────────────────
151
152/// An assistant response containing one or more typed content blocks.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct AssistantMessage {
155    /// The inner message payload with role, content, and model metadata.
156    pub message: AssistantMessageInner,
157    /// Session this message belongs to.
158    #[serde(default)]
159    pub session_id: String,
160}
161
162/// Inner content of an [`AssistantMessage`].
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub struct AssistantMessageInner {
165    /// Always `"assistant"`.
166    #[serde(default)]
167    pub role: String,
168    /// Typed content blocks (text, tool use, thinking, image, etc.).
169    #[serde(default)]
170    pub content: Vec<ContentBlock>,
171    /// Model that generated this response.
172    #[serde(default)]
173    pub model: String,
174    /// Reason the model stopped generating (e.g., `"end_turn"`, `"tool_use"`).
175    #[serde(default)]
176    pub stop_reason: String,
177    /// Stop sequence that triggered termination, if any.
178    #[serde(default)]
179    pub stop_sequence: Option<String>,
180    /// Unknown fields preserved for forward-compatibility.
181    #[serde(flatten)]
182    pub extra: Value,
183}
184
185// ── User Message ────────────────────────────────────────────────────────
186
187/// A user message — present in session history and resume replay only.
188///
189/// Outbound user messages are sent via the transport layer, not constructed
190/// as `Message::User` variants.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct UserMessage {
193    /// The inner message payload.
194    pub message: UserMessageInner,
195    /// Session this message belongs to.
196    #[serde(default)]
197    pub session_id: String,
198}
199
200/// Inner content of a [`UserMessage`].
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct UserMessageInner {
203    /// Always `"user"`.
204    #[serde(default)]
205    pub role: String,
206    /// Content blocks in this user message.
207    #[serde(default)]
208    pub content: Vec<ContentBlock>,
209    /// Unknown fields preserved for forward-compatibility.
210    #[serde(flatten)]
211    pub extra: Value,
212}
213
214// ── Result Message ──────────────────────────────────────────────────────
215
216/// Terminal message for a completed prompt turn.
217///
218/// Emitted exactly once per `send_message` call, after all streaming content
219/// has been delivered. Check [`ResultMessage::is_error`] to determine success.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct ResultMessage {
222    /// Subtype discriminator (e.g., `"success"`, `"error"`).
223    #[serde(default)]
224    pub subtype: String,
225    /// `true` if the turn ended in an error condition.
226    #[serde(default)]
227    pub is_error: bool,
228    /// Wall-clock duration of the full turn in milliseconds.
229    #[serde(default)]
230    pub duration_ms: f64,
231    /// Time spent in API calls in milliseconds.
232    #[serde(default)]
233    pub duration_api_ms: f64,
234    /// Number of agentic turns taken.
235    #[serde(default)]
236    pub num_turns: u32,
237    /// Session this result belongs to.
238    #[serde(default)]
239    pub session_id: String,
240    /// Token usage statistics for this turn.
241    #[serde(default)]
242    pub usage: Usage,
243    /// Final stop reason from the model.
244    #[serde(default)]
245    pub stop_reason: String,
246    /// Unknown fields preserved for forward-compatibility.
247    #[serde(flatten)]
248    pub extra: Value,
249}
250
251// ── Usage ───────────────────────────────────────────────────────────────
252
253/// Token usage statistics for a completed turn.
254///
255/// All fields default to `0`. Gemini-specific fields (e.g., `thought_tokens`)
256/// are included alongside the standard Anthropic-compatible fields.
257#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
258pub struct Usage {
259    /// Tokens consumed from the input (prompt) context.
260    #[serde(default)]
261    pub input_tokens: u32,
262    /// Tokens generated in the response.
263    #[serde(default)]
264    pub output_tokens: u32,
265    /// Prompt cache read tokens (prompt cache hit).
266    #[serde(default)]
267    pub cache_read_input_tokens: u32,
268    /// Prompt cache write tokens (prompt cache miss, storing new cache).
269    #[serde(default)]
270    pub cache_creation_input_tokens: u32,
271    /// Gemini-specific: tokens consumed by thinking/reasoning steps.
272    #[serde(default)]
273    pub thought_tokens: u32,
274}
275
276// ── Stream Event ────────────────────────────────────────────────────────
277
278/// A rich streaming event for data that does not map 1:1 to content blocks.
279///
280/// Used for tool call lifecycle events, plan updates, usage deltas,
281/// permission requests, and any other structured event the wire format
282/// emits between content blocks. The `data` field carries the raw JSON
283/// payload — consumers should match on `event_type` to deserialize it.
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct StreamEvent {
286    /// Discriminator identifying what `data` contains.
287    pub event_type: String,
288    /// Raw event payload — structure depends on `event_type`.
289    #[serde(default)]
290    pub data: Value,
291    /// Session this event belongs to.
292    #[serde(default)]
293    pub session_id: String,
294}
295
296// ── Session Info ────────────────────────────────────────────────────────
297
298/// Metadata about the established session, returned from `Client::connect()`.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct SessionInfo {
301    /// Unique session identifier.
302    pub session_id: String,
303    /// Model active for this session.
304    #[serde(default)]
305    pub model: String,
306    /// Names of tools available in this session.
307    #[serde(default)]
308    pub tools: Vec<String>,
309    /// Unknown fields preserved for forward-compatibility.
310    #[serde(flatten)]
311    pub extra: Value,
312}
313
314// ── Plan Entry (Gemini-specific) ────────────────────────────────────────
315
316/// A single entry in a Gemini agentic plan.
317///
318/// Gemini CLI emits structured plans during multi-step reasoning. Each entry
319/// represents one planned action. Plans arrive via [`StreamEvent`] with
320/// `event_type = "plan_update"`.
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322pub struct PlanEntry {
323    /// Human-readable description of this plan step.
324    #[serde(default)]
325    pub content: String,
326    /// Execution priority (e.g., `"high"`, `"normal"`, `"low"`).
327    #[serde(default)]
328    pub priority: String,
329    /// Current execution status (e.g., `"pending"`, `"done"`, `"failed"`).
330    #[serde(default)]
331    pub status: String,
332    /// Unknown fields preserved for forward-compatibility.
333    #[serde(flatten)]
334    pub extra: Value,
335}
336
337// ── Tests ───────────────────────────────────────────────────────────────
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::types::content::TextBlock;
343    use serde_json::json;
344
345    // Helper: build a minimal SystemMessage with a known session_id.
346    fn system_msg(session_id: &str) -> Message {
347        Message::System(SystemMessage {
348            subtype: "init".to_owned(),
349            session_id: session_id.to_owned(),
350            cwd: "/tmp".to_owned(),
351            tools: vec![],
352            mcp_servers: vec![],
353            model: "gemini-2.5-pro".to_owned(),
354            extra: Value::Object(Default::default()),
355        })
356    }
357
358    // Helper: build a ResultMessage.
359    fn result_msg(session_id: &str, is_error: bool) -> Message {
360        Message::Result(ResultMessage {
361            subtype: if is_error { "error" } else { "success" }.to_owned(),
362            is_error,
363            duration_ms: 123.4,
364            duration_api_ms: 100.0,
365            num_turns: 1,
366            session_id: session_id.to_owned(),
367            usage: Usage::default(),
368            stop_reason: "end_turn".to_owned(),
369            extra: Value::Object(Default::default()),
370        })
371    }
372
373    // Helper: build an AssistantMessage with given content blocks.
374    fn assistant_msg(session_id: &str, content: Vec<ContentBlock>) -> Message {
375        Message::Assistant(AssistantMessage {
376            message: AssistantMessageInner {
377                role: "assistant".to_owned(),
378                content,
379                model: "gemini-2.5-pro".to_owned(),
380                stop_reason: "end_turn".to_owned(),
381                stop_sequence: None,
382                extra: Value::Object(Default::default()),
383            },
384            session_id: session_id.to_owned(),
385        })
386    }
387
388    // Helper: build a StreamEvent message.
389    fn stream_event_msg(session_id: &str) -> Message {
390        Message::StreamEvent(StreamEvent {
391            event_type: "tool_call_start".to_owned(),
392            data: json!({ "tool": "bash" }),
393            session_id: session_id.to_owned(),
394        })
395    }
396
397    // ── session_id ─────────────────────────────────────────────────────
398
399    #[test]
400    fn test_message_system_session_id() {
401        let msg = system_msg("sess-abc");
402        assert_eq!(msg.session_id(), Some("sess-abc"));
403    }
404
405    #[test]
406    fn test_message_result_session_id() {
407        let msg = result_msg("sess-xyz", false);
408        assert_eq!(msg.session_id(), Some("sess-xyz"));
409    }
410
411    #[test]
412    fn test_message_stream_event_session_id() {
413        let msg = stream_event_msg("sess-ev");
414        assert_eq!(msg.session_id(), Some("sess-ev"));
415    }
416
417    // ── is_error_result ────────────────────────────────────────────────
418
419    #[test]
420    fn test_message_is_error_result_true() {
421        let msg = result_msg("s1", true);
422        assert!(msg.is_error_result(), "is_error=true must return true");
423    }
424
425    #[test]
426    fn test_message_is_error_result_false() {
427        let msg = result_msg("s1", false);
428        assert!(!msg.is_error_result(), "is_error=false must return false");
429    }
430
431    #[test]
432    fn test_message_is_error_result_non_result_variant() {
433        let msg = system_msg("s1");
434        assert!(!msg.is_error_result(), "non-Result variant must return false");
435    }
436
437    // ── is_stream_event ────────────────────────────────────────────────
438
439    #[test]
440    fn test_message_is_stream_event() {
441        let msg = stream_event_msg("s1");
442        assert!(msg.is_stream_event());
443    }
444
445    #[test]
446    fn test_message_is_stream_event_false_for_system() {
447        let msg = system_msg("s1");
448        assert!(!msg.is_stream_event());
449    }
450
451    // ── assistant_text ─────────────────────────────────────────────────
452
453    #[test]
454    fn test_message_assistant_text_single() {
455        let content = vec![ContentBlock::Text(TextBlock::new("hello world"))];
456        let msg = assistant_msg("s1", content);
457        assert_eq!(msg.assistant_text(), Some("hello world".to_owned()));
458    }
459
460    #[test]
461    fn test_message_assistant_text_multiple_blocks_concatenated() {
462        let content = vec![
463            ContentBlock::Text(TextBlock::new("foo")),
464            ContentBlock::Text(TextBlock::new("bar")),
465        ];
466        let msg = assistant_msg("s1", content);
467        assert_eq!(msg.assistant_text(), Some("foobar".to_owned()));
468    }
469
470    #[test]
471    fn test_message_assistant_text_empty() {
472        let msg = assistant_msg("s1", vec![]);
473        assert_eq!(
474            msg.assistant_text(),
475            None,
476            "no content blocks must yield None"
477        );
478    }
479
480    #[test]
481    fn test_message_assistant_text_non_text_blocks_only() {
482        // A thinking block alone — no text → None.
483        use crate::types::content::ThinkingBlock;
484        let content = vec![ContentBlock::Thinking(ThinkingBlock::new("reasoning..."))];
485        let msg = assistant_msg("s1", content);
486        assert_eq!(
487            msg.assistant_text(),
488            None,
489            "no Text blocks must yield None"
490        );
491    }
492
493    #[test]
494    fn test_message_assistant_text_non_assistant_variant() {
495        let msg = system_msg("s1");
496        assert_eq!(msg.assistant_text(), None);
497    }
498
499    // ── Usage::default ─────────────────────────────────────────────────
500
501    #[test]
502    fn test_usage_default() {
503        let usage = Usage::default();
504        assert_eq!(usage.input_tokens, 0);
505        assert_eq!(usage.output_tokens, 0);
506        assert_eq!(usage.cache_read_input_tokens, 0);
507        assert_eq!(usage.cache_creation_input_tokens, 0);
508        assert_eq!(usage.thought_tokens, 0);
509    }
510
511    // ── Serde roundtrip ────────────────────────────────────────────────
512
513    #[test]
514    fn test_message_serde_roundtrip_system() {
515        let original = Message::System(SystemMessage {
516            subtype: "init".to_owned(),
517            session_id: "sess-roundtrip".to_owned(),
518            cwd: "/workspace".to_owned(),
519            tools: vec!["bash".to_owned(), "read_file".to_owned()],
520            mcp_servers: vec![McpServerStatus {
521                name: "filesystem".to_owned(),
522                status: "connected".to_owned(),
523                extra: Value::Object(Default::default()),
524            }],
525            model: "gemini-2.5-pro".to_owned(),
526            extra: Value::Object(Default::default()),
527        });
528
529        let json = serde_json::to_string(&original).expect("serialize");
530        let recovered: Message = serde_json::from_str(&json).expect("deserialize");
531
532        assert_eq!(original, recovered);
533    }
534
535    #[test]
536    fn test_message_serde_roundtrip_result() {
537        let original = Message::Result(ResultMessage {
538            subtype: "success".to_owned(),
539            is_error: false,
540            duration_ms: 450.75,
541            duration_api_ms: 400.0,
542            num_turns: 3,
543            session_id: "sess-rt2".to_owned(),
544            usage: Usage {
545                input_tokens: 512,
546                output_tokens: 128,
547                cache_read_input_tokens: 64,
548                cache_creation_input_tokens: 32,
549                thought_tokens: 256,
550            },
551            stop_reason: "end_turn".to_owned(),
552            extra: Value::Object(Default::default()),
553        });
554
555        let json = serde_json::to_string(&original).expect("serialize");
556        let recovered: Message = serde_json::from_str(&json).expect("deserialize");
557
558        assert_eq!(original, recovered);
559    }
560
561    #[test]
562    fn test_message_serde_roundtrip_stream_event() {
563        let original = Message::StreamEvent(StreamEvent {
564            event_type: "plan_update".to_owned(),
565            data: json!({ "step": 1, "action": "read_file" }),
566            session_id: "sess-rt3".to_owned(),
567        });
568
569        let json = serde_json::to_string(&original).expect("serialize");
570        let recovered: Message = serde_json::from_str(&json).expect("deserialize");
571
572        assert_eq!(original, recovered);
573    }
574
575    // ── Plan Entry ─────────────────────────────────────────────────────
576
577    #[test]
578    fn test_plan_entry_defaults() {
579        let entry: PlanEntry =
580            serde_json::from_str("{}").expect("empty object must deserialize via defaults");
581        assert!(entry.content.is_empty());
582        assert!(entry.priority.is_empty());
583        assert!(entry.status.is_empty());
584    }
585
586    #[test]
587    fn test_plan_entry_roundtrip() {
588        let original = PlanEntry {
589            content: "Analyze the repository structure".to_owned(),
590            priority: "high".to_owned(),
591            status: "pending".to_owned(),
592            extra: Value::Object(Default::default()),
593        };
594
595        let json = serde_json::to_string(&original).expect("serialize");
596        let recovered: PlanEntry = serde_json::from_str(&json).expect("deserialize");
597
598        assert_eq!(original, recovered);
599    }
600
601    // ── Usage thought_tokens (Gemini-specific) ─────────────────────────
602
603    #[test]
604    fn test_usage_thought_tokens_roundtrip() {
605        let usage = Usage {
606            input_tokens: 100,
607            output_tokens: 50,
608            cache_read_input_tokens: 0,
609            cache_creation_input_tokens: 0,
610            thought_tokens: 300,
611        };
612        let json = serde_json::to_string(&usage).expect("serialize");
613        let recovered: Usage = serde_json::from_str(&json).expect("deserialize");
614        assert_eq!(recovered.thought_tokens, 300);
615    }
616}