1use 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#[derive(Debug)]
20pub struct ConversationState {
21 pub steps: Vec<Step>,
23 pub turn_start_indices: Vec<usize>,
25 pub compaction_indices: Vec<usize>,
27 pub cumulative_usage: UsageMetadata,
29 pub turn_usage: Option<UsageMetadata>,
31}
32
33pub 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 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 pub fn connection(&self) -> AnyConnection {
73 self.conn.clone()
74 }
75
76 pub fn conversation_id(&self) -> &str {
78 self.conn.conversation_id()
79 }
80
81 pub fn is_idle(&self) -> bool {
83 self.conn.is_idle()
84 }
85
86 pub async fn history(&self) -> Vec<Step> {
88 self.state.lock().await.steps.clone()
89 }
90
91 pub async fn turn_count(&self) -> usize {
93 self.state.lock().await.turn_start_indices.len()
94 }
95
96 pub async fn compaction_indices(&self) -> Vec<usize> {
98 self.state.lock().await.compaction_indices.clone()
99 }
100
101 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 pub async fn total_usage(&self) -> UsageMetadata {
117 self.state.lock().await.cumulative_usage.clone()
118 }
119
120 pub async fn last_turn_usage(&self) -> Option<UsageMetadata> {
122 self.state.lock().await.turn_usage.clone()
123 }
124
125 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 pub async fn send(&self, prompt: &str) -> Result<(), anyhow::Error> {
141 if !self.conn.is_idle() {
143 }
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 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 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 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 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 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 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}