1use 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#[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 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 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 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 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 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 pub fn add_message(&mut self, message: Message) {
140 self.messages.push(message);
141 self.updated_at = Utc::now();
142 }
143
144 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 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 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 (prov.map(|s| s.to_string()), model.to_string())
168 } else if providers.contains(&model) {
169 (Some(model.to_string()), String::new())
171 } else {
172 (None, model.to_string())
174 }
175 } else {
176 (None, String::new())
177 };
178
179 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 self.add_message(Message {
198 role: Role::User,
199 content: vec![ContentPart::Text {
200 text: message.to_string(),
201 }],
202 });
203
204 if self.title.is_none() {
206 self.generate_title().await?;
207 }
208
209 let model = if !model_id.is_empty() {
211 model_id
212 } else {
213 Self::default_model_for_provider(selected_provider)
214 };
215
216 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 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 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 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 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 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 let response = provider.complete(request).await?;
271
272 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 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 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 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 tool_calls.is_empty() {
313 self.add_message(response.message.clone());
314 break;
315 }
316
317 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 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 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 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 self.save().await?;
372
373 Ok(SessionResult {
374 text: final_output.trim().to_string(),
375 session_id: self.id.clone(),
376 })
377 }
378
379 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 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 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 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 self.add_message(Message {
435 role: Role::User,
436 content: vec![ContentPart::Text {
437 text: message.to_string(),
438 }],
439 });
440
441 if self.title.is_none() {
443 self.generate_title().await?;
444 }
445
446 let model = if !model_id.is_empty() {
448 model_id
449 } else {
450 Self::default_model_for_provider(selected_provider)
451 };
452
453 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 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 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 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 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 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 pub async fn generate_title(&mut self) -> Result<()> {
637 if self.title.is_some() {
638 return Ok(());
639 }
640
641 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 self.title = Some(truncate_with_ellipsis(&text, 47));
660 }
661
662 Ok(())
663 }
664
665 pub async fn regenerate_title(&mut self) -> Result<()> {
668 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 self.title = Some(truncate_with_ellipsis(&text, 47));
687 }
688
689 Ok(())
690 }
691
692 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 pub fn clear_title(&mut self) {
700 self.title = None;
701 self.updated_at = Utc::now();
702 }
703
704 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 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 fn session_path(id: &str) -> Result<PathBuf> {
725 Ok(Self::sessions_dir()?.join(format!("{}.json", id)))
726 }
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct SessionResult {
732 pub text: String,
733 pub session_id: String,
734}
735
736#[derive(Debug, Clone)]
738pub enum SessionEvent {
739 Thinking,
741 ToolCallStart { name: String, arguments: String },
743 ToolCallComplete {
745 name: String,
746 output: String,
747 success: bool,
748 },
749 TextChunk(String),
751 TextComplete(String),
753 Done,
755 Error(String),
757}
758
759pub 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#[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#[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}