Skip to main content

codetether_agent/session/
mod.rs

1//! Session management
2//!
3//! Sessions track the conversation history and state for agent interactions.
4
5use crate::agent::ToolUse;
6use crate::provider::{Message, Usage};
7use crate::tool::ToolRegistry;
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12use std::sync::Arc;
13use tokio::fs;
14use uuid::Uuid;
15
16fn is_interactive_tool(tool_name: &str) -> bool {
17    matches!(tool_name, "question")
18}
19
20/// A conversation session
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Session {
23    pub id: String,
24    pub title: Option<String>,
25    pub created_at: DateTime<Utc>,
26    pub updated_at: DateTime<Utc>,
27    pub messages: Vec<Message>,
28    pub tool_uses: Vec<ToolUse>,
29    pub usage: Usage,
30    pub agent: String,
31    pub metadata: SessionMetadata,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct SessionMetadata {
36    pub directory: Option<PathBuf>,
37    pub model: Option<String>,
38    pub shared: bool,
39    pub share_url: Option<String>,
40}
41
42impl Session {
43    fn default_model_for_provider(provider: &str) -> String {
44        match provider {
45            "moonshotai" => "kimi-k2.5".to_string(),
46            "anthropic" => "claude-sonnet-4-20250514".to_string(),
47            "openai" => "gpt-4o".to_string(),
48            "google" => "gemini-2.5-pro".to_string(),
49            "zhipuai" => "glm-4.7".to_string(),
50            "openrouter" => "zhipuai/glm-4.7".to_string(),
51            "novita" => "qwen/qwen3-coder-next".to_string(),
52            "github-copilot" | "github-copilot-enterprise" => "gpt-5-mini".to_string(),
53            _ => "glm-4.7".to_string(),
54        }
55    }
56
57    /// Create a new session
58    pub async fn new() -> Result<Self> {
59        let id = Uuid::new_v4().to_string();
60        let now = Utc::now();
61
62        Ok(Self {
63            id,
64            title: None,
65            created_at: now,
66            updated_at: now,
67            messages: Vec::new(),
68            tool_uses: Vec::new(),
69            usage: Usage::default(),
70            agent: "build".to_string(),
71            metadata: SessionMetadata {
72                directory: Some(std::env::current_dir()?),
73                ..Default::default()
74            },
75        })
76    }
77
78    /// Load an existing session
79    pub async fn load(id: &str) -> Result<Self> {
80        let path = Self::session_path(id)?;
81        let content = fs::read_to_string(&path).await?;
82        let session: Session = serde_json::from_str(&content)?;
83        Ok(session)
84    }
85
86    /// Load the last session
87    pub async fn last() -> Result<Self> {
88        let sessions_dir = Self::sessions_dir()?;
89
90        if !sessions_dir.exists() {
91            anyhow::bail!("No sessions found");
92        }
93
94        let mut entries: Vec<tokio::fs::DirEntry> = Vec::new();
95        let mut read_dir = fs::read_dir(&sessions_dir).await?;
96        while let Some(entry) = read_dir.next_entry().await? {
97            entries.push(entry);
98        }
99
100        if entries.is_empty() {
101            anyhow::bail!("No sessions found");
102        }
103
104        // Sort by modification time (most recent first)
105        // Use std::fs::metadata since we can't await in sort_by_key
106        entries.sort_by_key(|e| {
107            std::cmp::Reverse(
108                std::fs::metadata(e.path())
109                    .ok()
110                    .and_then(|m| m.modified().ok())
111                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
112            )
113        });
114
115        if let Some(entry) = entries.first() {
116            let content: String = fs::read_to_string(entry.path()).await?;
117            let session: Session = serde_json::from_str(&content)?;
118            return Ok(session);
119        }
120
121        anyhow::bail!("No sessions found")
122    }
123
124    /// Save the session to disk
125    pub async fn save(&self) -> Result<()> {
126        let path = Self::session_path(&self.id)?;
127
128        if let Some(parent) = path.parent() {
129            fs::create_dir_all(parent).await?;
130        }
131
132        let content = serde_json::to_string_pretty(self)?;
133        fs::write(&path, content).await?;
134
135        Ok(())
136    }
137
138    /// Add a message to the session
139    pub fn add_message(&mut self, message: Message) {
140        self.messages.push(message);
141        self.updated_at = Utc::now();
142    }
143
144    /// Execute a prompt and get the result
145    pub async fn prompt(&mut self, message: &str) -> Result<SessionResult> {
146        use crate::provider::{
147            CompletionRequest, ContentPart, ProviderRegistry, Role, parse_model_string,
148        };
149
150        // Load providers from Vault
151        let registry = ProviderRegistry::from_vault().await?;
152
153        let providers = registry.list();
154        if providers.is_empty() {
155            anyhow::bail!(
156                "No providers available. Configure API keys in HashiCorp Vault (for Copilot use `codetether auth copilot`)."
157            );
158        }
159
160        tracing::info!("Available providers: {:?}", providers);
161
162        // Parse model string (format: "provider/model", "provider", or just "model")
163        let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
164            let (prov, model) = parse_model_string(model_str);
165            if prov.is_some() {
166                // Format: provider/model
167                (prov.map(|s| s.to_string()), model.to_string())
168            } else if providers.contains(&model) {
169                // Format: just provider name (e.g., "novita")
170                (Some(model.to_string()), String::new())
171            } else {
172                // Format: just model name
173                (None, model.to_string())
174            }
175        } else {
176            (None, String::new())
177        };
178
179        // Determine which provider to use (prefer zhipuai as default)
180        let selected_provider = provider_name
181            .as_deref()
182            .filter(|p| providers.contains(p))
183            .or_else(|| {
184                if providers.contains(&"zhipuai") {
185                    Some("zhipuai")
186                } else {
187                    providers.first().copied()
188                }
189            })
190            .ok_or_else(|| anyhow::anyhow!("No providers available"))?;
191
192        let provider = registry
193            .get(selected_provider)
194            .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
195
196        // Add user message to session using add_message
197        self.add_message(Message {
198            role: Role::User,
199            content: vec![ContentPart::Text {
200                text: message.to_string(),
201            }],
202        });
203
204        // Generate title if this is the first user message and no title exists
205        if self.title.is_none() {
206            self.generate_title().await?;
207        }
208
209        // Determine model to use
210        let model = if !model_id.is_empty() {
211            model_id
212        } else {
213            Self::default_model_for_provider(selected_provider)
214        };
215
216        // Create tool registry with all available tools
217        let tool_registry = ToolRegistry::with_provider_arc(Arc::clone(&provider), model.clone());
218        let tool_definitions: Vec<_> = tool_registry
219            .definitions()
220            .into_iter()
221            .filter(|tool| !is_interactive_tool(&tool.name))
222            .collect();
223
224        // Kimi K2.5 requires temperature=1.0
225        let temperature = if model.starts_with("kimi-k2") {
226            Some(1.0)
227        } else {
228            Some(0.7)
229        };
230
231        tracing::info!("Using model: {} via provider: {}", model, selected_provider);
232        tracing::info!("Available tools: {}", tool_definitions.len());
233
234        // Build system prompt with AGENTS.md
235        let cwd = self
236            .metadata
237            .directory
238            .clone()
239            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
240        let system_prompt = crate::agent::builtin::build_system_prompt(&cwd);
241
242        // Run agentic loop with tool execution
243        let max_steps = 50;
244        let mut final_output = String::new();
245
246        for step in 1..=max_steps {
247            tracing::info!(step = step, "Agent step starting");
248
249            // Build messages with system prompt first
250            let mut messages = vec![Message {
251                role: Role::System,
252                content: vec![ContentPart::Text {
253                    text: system_prompt.clone(),
254                }],
255            }];
256            messages.extend(self.messages.clone());
257
258            // Create completion request with tools
259            let request = CompletionRequest {
260                messages,
261                tools: tool_definitions.clone(),
262                model: model.clone(),
263                temperature,
264                top_p: None,
265                max_tokens: Some(8192),
266                stop: Vec::new(),
267            };
268
269            // Call the provider
270            let response = provider.complete(request).await?;
271
272            // Record token usage
273            crate::telemetry::TOKEN_USAGE.record_model_usage(
274                &model,
275                response.usage.prompt_tokens as u64,
276                response.usage.completion_tokens as u64,
277            );
278
279            // Extract tool calls from response
280            let tool_calls: Vec<(String, String, serde_json::Value)> = response
281                .message
282                .content
283                .iter()
284                .filter_map(|part| {
285                    if let ContentPart::ToolCall {
286                        id,
287                        name,
288                        arguments,
289                    } = part
290                    {
291                        // Parse arguments JSON string into Value
292                        let args: serde_json::Value =
293                            serde_json::from_str(arguments).unwrap_or(serde_json::json!({}));
294                        Some((id.clone(), name.clone(), args))
295                    } else {
296                        None
297                    }
298                })
299                .collect();
300
301            // Collect text output
302            for part in &response.message.content {
303                if let ContentPart::Text { text } = part {
304                    if !text.is_empty() {
305                        final_output.push_str(text);
306                        final_output.push('\n');
307                    }
308                }
309            }
310
311            // If no tool calls, we're done
312            if tool_calls.is_empty() {
313                self.add_message(response.message.clone());
314                break;
315            }
316
317            // Add assistant message with tool calls
318            self.add_message(response.message.clone());
319
320            tracing::info!(
321                step = step,
322                num_tools = tool_calls.len(),
323                "Executing tool calls"
324            );
325
326            // Execute each tool call
327            for (tool_id, tool_name, tool_input) in tool_calls {
328                tracing::info!(tool = %tool_name, tool_id = %tool_id, "Executing tool");
329
330                if is_interactive_tool(&tool_name) {
331                    tracing::warn!(tool = %tool_name, "Blocking interactive tool in session loop");
332                    self.add_message(Message {
333                        role: Role::Tool,
334                        content: vec![ContentPart::ToolResult {
335                            tool_call_id: tool_id,
336                            content: "Error: Interactive tool 'question' is disabled in this interface. Ask the user directly in assistant text.".to_string(),
337                        }],
338                    });
339                    continue;
340                }
341
342                // Get and execute the tool
343                let content = if let Some(tool) = tool_registry.get(&tool_name) {
344                    match tool.execute(tool_input.clone()).await {
345                        Ok(result) => {
346                            tracing::info!(tool = %tool_name, success = result.success, "Tool execution completed");
347                            result.output
348                        }
349                        Err(e) => {
350                            tracing::warn!(tool = %tool_name, error = %e, "Tool execution failed");
351                            format!("Error: {}", e)
352                        }
353                    }
354                } else {
355                    tracing::warn!(tool = %tool_name, "Tool not found");
356                    format!("Error: Unknown tool '{}'", tool_name)
357                };
358
359                // Add tool result message
360                self.add_message(Message {
361                    role: Role::Tool,
362                    content: vec![ContentPart::ToolResult {
363                        tool_call_id: tool_id,
364                        content,
365                    }],
366                });
367            }
368        }
369
370        // Save session after each prompt to persist messages
371        self.save().await?;
372
373        Ok(SessionResult {
374            text: final_output.trim().to_string(),
375            session_id: self.id.clone(),
376        })
377    }
378
379    /// Process a user message with real-time event streaming for UI updates.
380    /// Events are sent through the provided channel as tool calls execute.
381    pub async fn prompt_with_events(
382        &mut self,
383        message: &str,
384        event_tx: tokio::sync::mpsc::Sender<SessionEvent>,
385    ) -> Result<SessionResult> {
386        use crate::provider::{
387            CompletionRequest, ContentPart, ProviderRegistry, Role, parse_model_string,
388        };
389
390        let _ = event_tx.send(SessionEvent::Thinking).await;
391
392        // Load provider registry from Vault
393        let registry = ProviderRegistry::from_vault().await?;
394        let providers = registry.list();
395        if providers.is_empty() {
396            anyhow::bail!(
397                "No providers available. Configure API keys in HashiCorp Vault (for Copilot use `codetether auth copilot`)."
398            );
399        }
400        tracing::info!("Available providers: {:?}", providers);
401
402        // Parse model string (format: "provider/model", "provider", or just "model")
403        let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
404            let (prov, model) = parse_model_string(model_str);
405            if prov.is_some() {
406                (prov.map(|s| s.to_string()), model.to_string())
407            } else if providers.contains(&model) {
408                (Some(model.to_string()), String::new())
409            } else {
410                (None, model.to_string())
411            }
412        } else {
413            (None, String::new())
414        };
415
416        // Determine which provider to use (prefer zhipuai as default)
417        let selected_provider = provider_name
418            .as_deref()
419            .filter(|p| providers.contains(p))
420            .or_else(|| {
421                if providers.contains(&"zhipuai") {
422                    Some("zhipuai")
423                } else {
424                    providers.first().copied()
425                }
426            })
427            .ok_or_else(|| anyhow::anyhow!("No providers available"))?;
428
429        let provider = registry
430            .get(selected_provider)
431            .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
432
433        // Add user message
434        self.add_message(Message {
435            role: Role::User,
436            content: vec![ContentPart::Text {
437                text: message.to_string(),
438            }],
439        });
440
441        // Generate title if needed
442        if self.title.is_none() {
443            self.generate_title().await?;
444        }
445
446        // Determine model
447        let model = if !model_id.is_empty() {
448            model_id
449        } else {
450            Self::default_model_for_provider(selected_provider)
451        };
452
453        // Create tool registry
454        let tool_registry = ToolRegistry::with_provider_arc(Arc::clone(&provider), model.clone());
455        let tool_definitions: Vec<_> = tool_registry
456            .definitions()
457            .into_iter()
458            .filter(|tool| !is_interactive_tool(&tool.name))
459            .collect();
460
461        let temperature = if model.starts_with("kimi-k2") {
462            Some(1.0)
463        } else {
464            Some(0.7)
465        };
466
467        tracing::info!("Using model: {} via provider: {}", model, selected_provider);
468        tracing::info!("Available tools: {}", tool_definitions.len());
469
470        // Build system prompt
471        let cwd = std::env::var("PWD")
472            .map(std::path::PathBuf::from)
473            .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
474        let system_prompt = crate::agent::builtin::build_system_prompt(&cwd);
475
476        let mut final_output = String::new();
477        let max_steps = 50;
478
479        for step in 1..=max_steps {
480            tracing::info!(step = step, "Agent step starting");
481            let _ = event_tx.send(SessionEvent::Thinking).await;
482
483            // Build messages with system prompt first
484            let mut messages = vec![Message {
485                role: Role::System,
486                content: vec![ContentPart::Text {
487                    text: system_prompt.clone(),
488                }],
489            }];
490            messages.extend(self.messages.clone());
491
492            let request = CompletionRequest {
493                messages,
494                tools: tool_definitions.clone(),
495                model: model.clone(),
496                temperature,
497                top_p: None,
498                max_tokens: Some(8192),
499                stop: Vec::new(),
500            };
501
502            let response = provider.complete(request).await?;
503
504            crate::telemetry::TOKEN_USAGE.record_model_usage(
505                &model,
506                response.usage.prompt_tokens as u64,
507                response.usage.completion_tokens as u64,
508            );
509
510            // Extract tool calls
511            let tool_calls: Vec<(String, String, serde_json::Value)> = response
512                .message
513                .content
514                .iter()
515                .filter_map(|part| {
516                    if let ContentPart::ToolCall {
517                        id,
518                        name,
519                        arguments,
520                    } = part
521                    {
522                        let args: serde_json::Value =
523                            serde_json::from_str(arguments).unwrap_or(serde_json::json!({}));
524                        Some((id.clone(), name.clone(), args))
525                    } else {
526                        None
527                    }
528                })
529                .collect();
530
531            // Collect text output
532            for part in &response.message.content {
533                if let ContentPart::Text { text } = part {
534                    if !text.is_empty() {
535                        final_output.push_str(text);
536                        final_output.push('\n');
537                        let _ = event_tx.send(SessionEvent::TextChunk(text.clone())).await;
538                    }
539                }
540            }
541
542            if tool_calls.is_empty() {
543                self.add_message(response.message.clone());
544                break;
545            }
546
547            self.add_message(response.message.clone());
548
549            tracing::info!(
550                step = step,
551                num_tools = tool_calls.len(),
552                "Executing tool calls"
553            );
554
555            // Execute each tool call with events
556            for (tool_id, tool_name, tool_input) in tool_calls {
557                let args_str = serde_json::to_string(&tool_input).unwrap_or_default();
558                let _ = event_tx
559                    .send(SessionEvent::ToolCallStart {
560                        name: tool_name.clone(),
561                        arguments: args_str,
562                    })
563                    .await;
564
565                tracing::info!(tool = %tool_name, tool_id = %tool_id, "Executing tool");
566
567                if is_interactive_tool(&tool_name) {
568                    tracing::warn!(tool = %tool_name, "Blocking interactive tool in session loop");
569                    let content = "Error: Interactive tool 'question' is disabled in this interface. Ask the user directly in assistant text.".to_string();
570                    let _ = event_tx
571                        .send(SessionEvent::ToolCallComplete {
572                            name: tool_name.clone(),
573                            output: content.clone(),
574                            success: false,
575                        })
576                        .await;
577                    self.add_message(Message {
578                        role: Role::Tool,
579                        content: vec![ContentPart::ToolResult {
580                            tool_call_id: tool_id,
581                            content,
582                        }],
583                    });
584                    continue;
585                }
586
587                let (content, success) = if let Some(tool) = tool_registry.get(&tool_name) {
588                    match tool.execute(tool_input.clone()).await {
589                        Ok(result) => {
590                            tracing::info!(tool = %tool_name, success = result.success, "Tool execution completed");
591                            (result.output, result.success)
592                        }
593                        Err(e) => {
594                            tracing::warn!(tool = %tool_name, error = %e, "Tool execution failed");
595                            (format!("Error: {}", e), false)
596                        }
597                    }
598                } else {
599                    tracing::warn!(tool = %tool_name, "Tool not found");
600                    (format!("Error: Unknown tool '{}'", tool_name), false)
601                };
602
603                let _ = event_tx
604                    .send(SessionEvent::ToolCallComplete {
605                        name: tool_name.clone(),
606                        output: content.clone(),
607                        success,
608                    })
609                    .await;
610
611                self.add_message(Message {
612                    role: Role::Tool,
613                    content: vec![ContentPart::ToolResult {
614                        tool_call_id: tool_id,
615                        content,
616                    }],
617                });
618            }
619        }
620
621        self.save().await?;
622
623        let _ = event_tx
624            .send(SessionEvent::TextComplete(final_output.trim().to_string()))
625            .await;
626        let _ = event_tx.send(SessionEvent::Done).await;
627
628        Ok(SessionResult {
629            text: final_output.trim().to_string(),
630            session_id: self.id.clone(),
631        })
632    }
633
634    /// Generate a title for the session based on the first message
635    /// Only sets title if not already set (for initial title generation)
636    pub async fn generate_title(&mut self) -> Result<()> {
637        if self.title.is_some() {
638            return Ok(());
639        }
640
641        // Get first user message
642        let first_message = self
643            .messages
644            .iter()
645            .find(|m| m.role == crate::provider::Role::User);
646
647        if let Some(msg) = first_message {
648            let text: String = msg
649                .content
650                .iter()
651                .filter_map(|p| match p {
652                    crate::provider::ContentPart::Text { text } => Some(text.clone()),
653                    _ => None,
654                })
655                .collect::<Vec<_>>()
656                .join(" ");
657
658            // Truncate to reasonable length
659            self.title = Some(truncate_with_ellipsis(&text, 47));
660        }
661
662        Ok(())
663    }
664
665    /// Regenerate the title based on the first message, even if already set
666    /// Use this for on-demand title updates or after context changes
667    pub async fn regenerate_title(&mut self) -> Result<()> {
668        // Get first user message
669        let first_message = self
670            .messages
671            .iter()
672            .find(|m| m.role == crate::provider::Role::User);
673
674        if let Some(msg) = first_message {
675            let text: String = msg
676                .content
677                .iter()
678                .filter_map(|p| match p {
679                    crate::provider::ContentPart::Text { text } => Some(text.clone()),
680                    _ => None,
681                })
682                .collect::<Vec<_>>()
683                .join(" ");
684
685            // Truncate to reasonable length
686            self.title = Some(truncate_with_ellipsis(&text, 47));
687        }
688
689        Ok(())
690    }
691
692    /// Set a custom title for the session
693    pub fn set_title(&mut self, title: impl Into<String>) {
694        self.title = Some(title.into());
695        self.updated_at = Utc::now();
696    }
697
698    /// Clear the title, allowing it to be regenerated
699    pub fn clear_title(&mut self) {
700        self.title = None;
701        self.updated_at = Utc::now();
702    }
703
704    /// Handle context change - updates metadata and optionally regenerates title
705    /// Call this when the session context changes (e.g., directory change, model change)
706    pub async fn on_context_change(&mut self, regenerate_title: bool) -> Result<()> {
707        self.updated_at = Utc::now();
708
709        if regenerate_title {
710            self.regenerate_title().await?;
711        }
712
713        Ok(())
714    }
715
716    /// Get the sessions directory
717    fn sessions_dir() -> Result<PathBuf> {
718        crate::config::Config::data_dir()
719            .map(|d| d.join("sessions"))
720            .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))
721    }
722
723    /// Get the path for a session file
724    fn session_path(id: &str) -> Result<PathBuf> {
725        Ok(Self::sessions_dir()?.join(format!("{}.json", id)))
726    }
727}
728
729/// Result from a session prompt
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct SessionResult {
732    pub text: String,
733    pub session_id: String,
734}
735
736/// Events emitted during session processing for real-time UI updates
737#[derive(Debug, Clone)]
738pub enum SessionEvent {
739    /// Agent is thinking/processing
740    Thinking,
741    /// Tool call started
742    ToolCallStart { name: String, arguments: String },
743    /// Tool call completed with result
744    ToolCallComplete {
745        name: String,
746        output: String,
747        success: bool,
748    },
749    /// Partial text output (for streaming)
750    TextChunk(String),
751    /// Final text output
752    TextComplete(String),
753    /// Processing complete
754    Done,
755    /// Error occurred
756    Error(String),
757}
758
759/// List all sessions
760pub async fn list_sessions() -> Result<Vec<SessionSummary>> {
761    let sessions_dir = crate::config::Config::data_dir()
762        .map(|d| d.join("sessions"))
763        .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
764
765    if !sessions_dir.exists() {
766        return Ok(Vec::new());
767    }
768
769    let mut summaries = Vec::new();
770    let mut entries = fs::read_dir(&sessions_dir).await?;
771
772    while let Some(entry) = entries.next_entry().await? {
773        let path = entry.path();
774        if path.extension().map(|e| e == "json").unwrap_or(false) {
775            if let Ok(content) = fs::read_to_string(&path).await {
776                if let Ok(session) = serde_json::from_str::<Session>(&content) {
777                    summaries.push(SessionSummary {
778                        id: session.id,
779                        title: session.title,
780                        created_at: session.created_at,
781                        updated_at: session.updated_at,
782                        message_count: session.messages.len(),
783                        agent: session.agent,
784                    });
785                }
786            }
787        }
788    }
789
790    summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
791    Ok(summaries)
792}
793
794/// Summary of a session for listing
795#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct SessionSummary {
797    pub id: String,
798    pub title: Option<String>,
799    pub created_at: DateTime<Utc>,
800    pub updated_at: DateTime<Utc>,
801    pub message_count: usize,
802    pub agent: String,
803}
804
805fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
806    if max_chars == 0 {
807        return String::new();
808    }
809
810    let mut chars = value.chars();
811    let mut output = String::new();
812    for _ in 0..max_chars {
813        if let Some(ch) = chars.next() {
814            output.push(ch);
815        } else {
816            return value.to_string();
817        }
818    }
819
820    if chars.next().is_some() {
821        format!("{output}...")
822    } else {
823        output
824    }
825}
826
827// Async helper for Vec - kept for potential future use
828#[allow(dead_code)]
829use futures::StreamExt;
830
831#[allow(dead_code)]
832trait AsyncCollect<T> {
833    async fn collect(self) -> Vec<T>;
834}
835
836#[allow(dead_code)]
837impl<S, T> AsyncCollect<T> for S
838where
839    S: futures::Stream<Item = T> + Unpin,
840{
841    async fn collect(mut self) -> Vec<T> {
842        let mut items = Vec::new();
843        while let Some(item) = self.next().await {
844            items.push(item);
845        }
846        items
847    }
848}