Skip to main content

antigravity_sdk_rust/
conversation.rs

1//! Stateful conversation tracking and event chunk streaming.
2//!
3//! This module provides the [`Conversation`] struct, which coordinates an active session's
4//! event stream, aggregates history steps, tracks token usage metadata, and filters thinking/text deltas.
5
6use crate::connection::{AnyConnection, Connection};
7use crate::types::{
8    ChatResponse, Step, StepSource, StepTarget, StepType, StreamChunk, UsageMetadata,
9};
10use futures_util::StreamExt;
11use futures_util::stream::{self, BoxStream};
12use std::collections::HashSet;
13use std::sync::Arc;
14use tokio::sync::Mutex;
15
16const DEFAULT_MAX_HISTORY_SIZE: usize = 10_000;
17
18/// Internal accumulator of conversation steps and usage metrics.
19#[derive(Debug)]
20pub struct ConversationState {
21    /// Ordered list of all executed steps (including prompts, tool calls, results, and responses).
22    pub steps: Vec<Step>,
23    /// Step indices marking the start of each user prompt turn.
24    pub turn_start_indices: Vec<usize>,
25    /// Step indices marking where state compaction was performed.
26    pub compaction_indices: Vec<usize>,
27    /// Total cumulative LLM token consumption across the entire session.
28    pub cumulative_usage: UsageMetadata,
29    /// Token usage metrics for the current active turn, if any.
30    pub turn_usage: Option<UsageMetadata>,
31}
32
33/// A stateful wrapper managing an active agentic session and its history.
34///
35/// `Conversation` consumes step events from an underlying [`Connection`], updates the cumulative history,
36/// tracks token usage, and provides high-level APIs to chat, stream structured events, and wait for run completions.
37pub struct Conversation {
38    conn: AnyConnection,
39    max_history_size: usize,
40    state: Arc<Mutex<ConversationState>>,
41}
42
43impl std::fmt::Debug for Conversation {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("Conversation")
46            .field("conversation_id", &self.conversation_id())
47            .field("max_history_size", &self.max_history_size)
48            .finish_non_exhaustive()
49    }
50}
51
52impl Conversation {
53    /// Creates a new `Conversation` instance wrapping a [`Connection`].
54    ///
55    /// Optionally restricts the memory storage to `max_history_size` steps (default is 10,000 steps).
56    /// If `max_history_size` is set to `0`, state trimming is disabled.
57    pub fn new(conn: AnyConnection, max_history_size: Option<usize>) -> Self {
58        Self {
59            conn,
60            max_history_size: max_history_size.unwrap_or(DEFAULT_MAX_HISTORY_SIZE),
61            state: Arc::new(Mutex::new(ConversationState {
62                steps: Vec::new(),
63                turn_start_indices: Vec::new(),
64                compaction_indices: Vec::new(),
65                cumulative_usage: UsageMetadata::default(),
66                turn_usage: None,
67            })),
68        }
69    }
70
71    /// Returns the underlying [`Connection`].
72    pub fn connection(&self) -> AnyConnection {
73        self.conn.clone()
74    }
75
76    /// Returns the conversation ID assigned to the session.
77    pub fn conversation_id(&self) -> &str {
78        self.conn.conversation_id()
79    }
80
81    /// Returns whether the connection is currently idle.
82    pub fn is_idle(&self) -> bool {
83        self.conn.is_idle()
84    }
85
86    /// Retrieves a copy of the current conversation history steps.
87    pub async fn history(&self) -> Vec<Step> {
88        self.state.lock().await.steps.clone()
89    }
90
91    /// Returns the total number of user-initiated turns executed in this session.
92    pub async fn turn_count(&self) -> usize {
93        self.state.lock().await.turn_start_indices.len()
94    }
95
96    /// Returns the step indices where compaction (history compression) occurred.
97    pub async fn compaction_indices(&self) -> Vec<usize> {
98        self.state.lock().await.compaction_indices.clone()
99    }
100
101    /// Scans the history backward and returns the text content of the last completed model response.
102    pub async fn last_response(&self) -> String {
103        let state = self.state.lock().await;
104        let response = state
105            .steps
106            .iter()
107            .rev()
108            .find(|step| step.is_complete_response == Some(true))
109            .map(|step| step.content.clone())
110            .unwrap_or_default();
111        drop(state);
112        response
113    }
114
115    /// Returns the total token usage accumulated over all turns in the session.
116    pub async fn total_usage(&self) -> UsageMetadata {
117        self.state.lock().await.cumulative_usage.clone()
118    }
119
120    /// Returns the token usage metrics from the last completed turn.
121    pub async fn last_turn_usage(&self) -> Option<UsageMetadata> {
122        self.state.lock().await.turn_usage.clone()
123    }
124
125    /// Resets the conversation state, clearing all steps, compaction boundaries, and usage statistics.
126    pub async fn clear_history(&self) {
127        let mut state = self.state.lock().await;
128        state.steps.clear();
129        state.turn_start_indices.clear();
130        state.compaction_indices.clear();
131        state.cumulative_usage = UsageMetadata::default();
132        state.turn_usage = None;
133    }
134
135    /// Sends a text prompt to the connection and registers the turn start boundary.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the underlying connection fails to transmit the prompt.
140    pub async fn send(&self, prompt: &str) -> Result<(), anyhow::Error> {
141        // If not idle, wait for it
142        if !self.conn.is_idle() {
143            // Note: Unlike Python's runtime RuntimeError handling, in Rust we can just wait
144            // or let the stream run-loop handle it.
145        }
146        let mut state = self.state.lock().await;
147        let len = state.steps.len();
148        state.turn_start_indices.push(len);
149        state.turn_usage = None;
150        drop(state);
151        self.conn.send(prompt).await
152    }
153
154    /// Subscribes to step updates from the connection, inserting them into history and enforcing history limits.
155    pub fn receive_steps(&self) -> BoxStream<'static, Result<Step, anyhow::Error>> {
156        let conn_stream = self.conn.receive_steps();
157        let state = self.state.clone();
158        let max_history = self.max_history_size;
159
160        conn_stream
161            .then(move |step_res| {
162                let state = state.clone();
163                async move {
164                    match step_res {
165                        Ok(step) => {
166                            let mut s = state.lock().await;
167                            s.steps.push(step.clone());
168                            if step.r#type == StepType::Compaction {
169                                let len = s.steps.len();
170                                s.compaction_indices.push(len - 1);
171                            }
172                            if let Some(ref usage) = step.usage_metadata {
173                                s.cumulative_usage.prompt_token_count += usage.prompt_token_count;
174                                s.cumulative_usage.cached_content_token_count +=
175                                    usage.cached_content_token_count;
176                                s.cumulative_usage.candidates_token_count +=
177                                    usage.candidates_token_count;
178                                s.cumulative_usage.thoughts_token_count +=
179                                    usage.thoughts_token_count;
180                                s.cumulative_usage.total_token_count += usage.total_token_count;
181
182                                let mut turn_usage = s.turn_usage.take().unwrap_or_default();
183                                turn_usage.prompt_token_count += usage.prompt_token_count;
184                                turn_usage.cached_content_token_count +=
185                                    usage.cached_content_token_count;
186                                turn_usage.candidates_token_count += usage.candidates_token_count;
187                                turn_usage.thoughts_token_count += usage.thoughts_token_count;
188                                turn_usage.total_token_count += usage.total_token_count;
189                                s.turn_usage = Some(turn_usage);
190                            }
191
192                            // Enforce max history size
193                            if max_history > 0 && s.steps.len() > max_history {
194                                let overflow = s.steps.len() - max_history;
195                                s.steps.drain(0..overflow);
196                                s.turn_start_indices = s
197                                    .turn_start_indices
198                                    .iter()
199                                    .filter_map(|&idx| {
200                                        if idx >= overflow {
201                                            Some(idx - overflow)
202                                        } else {
203                                            None
204                                        }
205                                    })
206                                    .collect();
207                                s.compaction_indices = s
208                                    .compaction_indices
209                                    .iter()
210                                    .filter_map(|&idx| {
211                                        if idx >= overflow {
212                                            Some(idx - overflow)
213                                        } else {
214                                            None
215                                        }
216                                    })
217                                    .collect();
218                            }
219                            drop(s);
220
221                            Ok(step)
222                        }
223                        Err(e) => Err(e),
224                    }
225                }
226            })
227            .boxed()
228    }
229
230    /// Filters and maps the step event stream to yielding high-level [`StreamChunk`] deltas.
231    pub fn receive_chunks(&self) -> BoxStream<'static, Result<StreamChunk, anyhow::Error>> {
232        let steps = self.receive_steps();
233        let mut seen_tool_ids = HashSet::new();
234
235        steps
236            .flat_map(move |step_res| {
237                let mut chunks = Vec::new();
238                match step_res {
239                    Ok(step) => {
240                        let is_model = step.source == StepSource::Model;
241                        let is_target_user = step.target == StepTarget::User;
242
243                        if is_model && is_target_user {
244                            if !step.thinking_delta.is_empty() {
245                                chunks.push(Ok(StreamChunk::Thought {
246                                    step_index: step.step_index,
247                                    text: step.thinking_delta.clone(),
248                                }));
249                            }
250                            if !step.content_delta.is_empty() {
251                                chunks.push(Ok(StreamChunk::Text {
252                                    step_index: step.step_index,
253                                    text: step.content_delta.clone(),
254                                }));
255                            }
256                        }
257
258                        for call in step.tool_calls {
259                            if call.id.is_empty() || seen_tool_ids.insert(call.id.clone()) {
260                                chunks.push(Ok(StreamChunk::ToolCall(call)));
261                            }
262                        }
263                    }
264                    Err(e) => {
265                        chunks.push(Err(e));
266                    }
267                }
268                stream::iter(chunks)
269            })
270            .boxed()
271    }
272
273    /// Starts a prompt turn and returns a stream of [`StreamChunk`] events.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if sending the prompt fails.
278    pub async fn chat(
279        &self,
280        prompt: &str,
281    ) -> Result<BoxStream<'static, Result<StreamChunk, anyhow::Error>>, anyhow::Error> {
282        self.send(prompt).await?;
283        Ok(self.receive_chunks())
284    }
285
286    /// Starts a prompt turn and resolves once the model completes its response.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if sending the prompt or receiving chunk responses fails.
291    pub async fn chat_to_completion(&self, prompt: &str) -> Result<ChatResponse, anyhow::Error> {
292        let mut chunks = self.chat(prompt).await?;
293        let mut text = String::new();
294        let mut thinking = String::new();
295        while let Some(chunk_res) = chunks.next().await {
296            match chunk_res? {
297                StreamChunk::Text { text: delta, .. } => {
298                    text.push_str(&delta);
299                }
300                StreamChunk::Thought { text: delta, .. } => {
301                    thinking.push_str(&delta);
302                }
303                StreamChunk::ToolCall(_) => {}
304            }
305        }
306        let steps = self.history().await;
307        let usage_metadata = self.total_usage().await;
308        Ok(ChatResponse {
309            text,
310            thinking,
311            steps,
312            usage_metadata,
313        })
314    }
315
316    /// Gracefully closes the underlying connection.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if disconnecting the transport layer fails.
321    pub async fn disconnect(&self) -> Result<(), anyhow::Error> {
322        self.conn.disconnect().await
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    #![allow(
329        clippy::unwrap_used,
330        clippy::expect_used,
331        clippy::panic,
332        clippy::field_reassign_with_default,
333        clippy::similar_names,
334        clippy::single_match,
335        clippy::match_wildcard_for_single_variants,
336        clippy::manual_string_new
337    )]
338    use super::*;
339    use crate::connection::{AnyConnection, MockConnection};
340    use crate::types::{StepSource, StepTarget, StepType, ToolCall};
341    use futures_util::StreamExt;
342
343    fn test_setup(
344        id: &str,
345        max_history_size: Option<usize>,
346    ) -> (Arc<MockConnection>, Conversation) {
347        let mock = Arc::new(MockConnection::new(id));
348        let conv = Conversation::new(AnyConnection::Mock(mock.clone()), max_history_size);
349        (mock, conv)
350    }
351
352    #[tokio::test]
353    async fn test_conversation_initialization() {
354        let (_conn, conv) = test_setup("conv-123", Some(10));
355        assert_eq!(conv.conversation_id(), "conv-123");
356        assert!(conv.is_idle());
357        assert_eq!(conv.history().await.len(), 0);
358        assert_eq!(conv.turn_count().await, 0);
359    }
360
361    #[tokio::test]
362    async fn test_send_records_turn_boundary() {
363        let (_conn, conv) = test_setup("conv-123", Some(10));
364        conv.send("hello").await.unwrap();
365        assert_eq!(conv.turn_count().await, 1);
366        conv.send("world").await.unwrap();
367        assert_eq!(conv.turn_count().await, 2);
368    }
369
370    #[tokio::test]
371    async fn test_receive_steps_accumulates_history() {
372        let (conn, conv) = test_setup("conv-123", Some(10));
373        let step1 = Step {
374            id: "1".to_string(),
375            step_index: 1,
376            r#type: StepType::TextResponse,
377            source: StepSource::Model,
378            target: StepTarget::User,
379            content: "hello".to_string(),
380            is_complete_response: Some(true),
381            ..Default::default()
382        };
383        conn.steps_to_yield.lock().unwrap().push(step1);
384
385        let mut steps = conv.receive_steps();
386        while let Some(res) = steps.next().await {
387            res.unwrap();
388        }
389
390        let hist = conv.history().await;
391        assert_eq!(hist.len(), 1);
392        assert_eq!(hist[0].content, "hello");
393        assert_eq!(conv.last_response().await, "hello");
394    }
395
396    #[tokio::test]
397    async fn test_compaction_indices_tracked() {
398        let (conn, conv) = test_setup("conv-123", Some(10));
399        let step1 = Step {
400            id: "1".to_string(),
401            step_index: 1,
402            r#type: StepType::Compaction,
403            source: StepSource::Model,
404            target: StepTarget::User,
405            ..Default::default()
406        };
407        conn.steps_to_yield.lock().unwrap().push(step1);
408
409        let mut steps = conv.receive_steps();
410        while let Some(res) = steps.next().await {
411            res.unwrap();
412        }
413
414        assert_eq!(conv.compaction_indices().await, vec![0]);
415    }
416
417    #[tokio::test]
418    async fn test_max_history_size_trimming() {
419        let (conn, conv) = test_setup("conv-123", Some(3));
420        for i in 0..5 {
421            conn.steps_to_yield.lock().unwrap().push(Step {
422                id: i.to_string(),
423                step_index: i,
424                content: format!("step-{}", i),
425                ..Default::default()
426            });
427        }
428
429        let mut steps = conv.receive_steps();
430        while let Some(res) = steps.next().await {
431            res.unwrap();
432        }
433
434        let hist = conv.history().await;
435        assert_eq!(hist.len(), 3);
436        assert_eq!(hist[0].content, "step-2");
437        assert_eq!(hist[2].content, "step-4");
438    }
439
440    #[tokio::test]
441    async fn test_max_history_size_zero_disables_trimming() {
442        let (conn, conv) = test_setup("conv-123", Some(0));
443        for i in 0..5 {
444            conn.steps_to_yield.lock().unwrap().push(Step {
445                id: i.to_string(),
446                step_index: i,
447                content: format!("step-{}", i),
448                ..Default::default()
449            });
450        }
451
452        let mut steps = conv.receive_steps();
453        while let Some(res) = steps.next().await {
454            res.unwrap();
455        }
456
457        let hist = conv.history().await;
458        assert_eq!(hist.len(), 5);
459    }
460
461    #[tokio::test]
462    async fn test_receive_chunks_routing() {
463        let (conn, conv) = test_setup("conv-123", Some(10));
464        let step = Step {
465            id: "1".to_string(),
466            step_index: 1,
467            r#type: StepType::TextResponse,
468            source: StepSource::Model,
469            target: StepTarget::User,
470            content_delta: "hello".to_string(),
471            thinking_delta: "reasoning".to_string(),
472            ..Default::default()
473        };
474        conn.steps_to_yield.lock().unwrap().push(step);
475
476        let mut chunks = conv.receive_chunks();
477        let mut text = String::new();
478        let mut thought = String::new();
479        while let Some(res) = chunks.next().await {
480            match res.unwrap() {
481                StreamChunk::Text { text: delta, .. } => text.push_str(&delta),
482                StreamChunk::Thought { text: delta, .. } => thought.push_str(&delta),
483                _ => {}
484            }
485        }
486
487        assert_eq!(text, "hello");
488        assert_eq!(thought, "reasoning");
489    }
490
491    #[tokio::test]
492    async fn test_receive_chunks_environmental_filtering() {
493        let (conn, conv) = test_setup("conv-123", Some(10));
494        let step = Step {
495            id: "1".to_string(),
496            step_index: 1,
497            r#type: StepType::TextResponse,
498            source: StepSource::Model,
499            target: StepTarget::Environment,
500            content_delta: "env content".to_string(),
501            ..Default::default()
502        };
503        conn.steps_to_yield.lock().unwrap().push(step);
504
505        let mut chunks = conv.receive_chunks();
506        let mut text = String::new();
507        while let Some(res) = chunks.next().await {
508            match res.unwrap() {
509                StreamChunk::Text { text: delta, .. } => text.push_str(&delta),
510                _ => {}
511            }
512        }
513
514        assert_eq!(text, "");
515    }
516
517    #[tokio::test]
518    async fn test_receive_chunks_tool_calls_deduplication() {
519        let (conn, conv) = test_setup("conv-123", Some(10));
520        let tc = ToolCall {
521            id: "call_a".to_string(),
522            name: "tool_1".to_string(),
523            args: serde_json::Value::Null,
524            canonical_path: None,
525        };
526        let step = Step {
527            id: "1".to_string(),
528            step_index: 1,
529            r#type: StepType::ToolCall,
530            source: StepSource::Model,
531            target: StepTarget::User,
532            tool_calls: vec![tc.clone(), tc.clone()],
533            ..Default::default()
534        };
535        conn.steps_to_yield.lock().unwrap().push(step);
536
537        let mut chunks = conv.receive_chunks();
538        let mut tool_calls = Vec::new();
539        while let Some(res) = chunks.next().await {
540            if let StreamChunk::ToolCall(c) = res.unwrap() {
541                tool_calls.push(c);
542            }
543        }
544
545        assert_eq!(tool_calls.len(), 1);
546        assert_eq!(tool_calls[0].id, "call_a");
547    }
548
549    #[tokio::test]
550    async fn test_receive_chunks_empty_tool_id_no_dedup() {
551        let (conn, conv) = test_setup("conv-123", Some(10));
552        let tc = ToolCall {
553            id: "".to_string(),
554            name: "tool_1".to_string(),
555            args: serde_json::Value::Null,
556            canonical_path: None,
557        };
558        let step = Step {
559            id: "1".to_string(),
560            step_index: 1,
561            r#type: StepType::ToolCall,
562            source: StepSource::Model,
563            target: StepTarget::User,
564            tool_calls: vec![tc.clone(), tc.clone()],
565            ..Default::default()
566        };
567        conn.steps_to_yield.lock().unwrap().push(step);
568
569        let mut chunks = conv.receive_chunks();
570        let mut tool_calls = Vec::new();
571        while let Some(res) = chunks.next().await {
572            if let StreamChunk::ToolCall(c) = res.unwrap() {
573                tool_calls.push(c);
574            }
575        }
576
577        assert_eq!(tool_calls.len(), 2);
578    }
579}