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