steer_core/app/
mod.rs

1use crate::api::{Client as ApiClient, Model, ProviderKind, ToolCall};
2use crate::app::cancellation::ActiveTool;
3use crate::app::command::ApprovalType;
4use crate::app::conversation::{AppCommandType, AssistantContent, CompactResult, UserContent};
5use crate::config::LlmConfigProvider;
6use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11use steer_tools::tools::BASH_TOOL_NAME;
12use steer_tools::tools::bash::BashParams;
13use steer_tools::{ToolError, ToolResult};
14use tokio::sync::{Mutex, mpsc, oneshot};
15use tokio_util::sync::CancellationToken;
16
17use tracing::{debug, error, info, warn};
18use uuid;
19
20pub mod adapters;
21mod agent_executor;
22pub mod cancellation;
23pub mod command;
24pub mod context;
25pub mod conversation;
26pub mod io;
27
28pub mod validation;
29
30#[cfg(test)]
31mod tests;
32
33use crate::app::context::TaskOutcome;
34
35pub use cancellation::CancellationInfo;
36pub use command::AppCommand;
37pub use context::OpContext;
38pub use conversation::{Conversation, Message, MessageData};
39pub use steer_workspace::EnvironmentInfo;
40
41pub use agent_executor::{
42    AgentEvent, AgentExecutor, AgentExecutorError, AgentExecutorRunRequest, ApprovalDecision,
43};
44
45#[derive(Debug, Clone)]
46pub enum AppEvent {
47    MessageAdded {
48        message: Message,
49        model: Model,
50    },
51    MessageUpdated {
52        id: String,
53        content: String,
54    },
55    MessagePart {
56        id: String,
57        delta: String,
58    },
59
60    ToolCallStarted {
61        name: String,
62        id: String,
63        parameters: serde_json::Value,
64        model: Model,
65    },
66    ToolCallCompleted {
67        name: String,
68        result: ToolResult,
69        id: String,
70        model: Model,
71    },
72    ToolCallFailed {
73        name: String,
74        error: String,
75        id: String,
76        model: Model,
77    },
78
79    ProcessingStarted,   // Started processing a user message
80    ProcessingCompleted, // Completed processing a user message
81
82    CommandResponse {
83        command: conversation::AppCommandType,
84        response: conversation::CommandResponse,
85        id: String,
86    },
87
88    RequestToolApproval {
89        name: String,
90        parameters: serde_json::Value,
91        id: String,
92    },
93    OperationCancelled {
94        op_id: Option<uuid::Uuid>, // Operation ID if available
95        info: CancellationInfo,
96    },
97
98    ModelChanged {
99        model: Model,
100    },
101    Error {
102        message: String,
103    },
104    WorkspaceChanged,
105    WorkspaceFiles {
106        files: Vec<String>,
107    },
108    Started {
109        id: uuid::Uuid,
110        op: Operation,
111    },
112    Finished {
113        id: uuid::Uuid,
114        outcome: OperationOutcome,
115    },
116    ActiveMessageIdChanged {
117        message_id: Option<String>,
118    },
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub enum Operation {
123    Bash { cmd: String },
124    Compact,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum OperationOutcome {
129    Bash {
130        elapsed: Duration,
131        result: std::result::Result<(), BashError>,
132    },
133    Compact {
134        elapsed: Duration,
135        result: std::result::Result<(), CompactError>,
136    },
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct BashError {
141    pub exit_code: i32,
142    pub stderr: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct CompactError {
147    pub message: String,
148}
149
150#[derive(Clone)]
151pub struct AppConfig {
152    pub llm_config_provider: LlmConfigProvider,
153}
154
155impl Default for AppConfig {
156    fn default() -> Self {
157        // Create an in-memory auth storage for default
158        let storage = Arc::new(crate::test_utils::InMemoryAuthStorage::new());
159        let provider = LlmConfigProvider::new(storage);
160
161        Self {
162            llm_config_provider: provider,
163        }
164    }
165}
166
167pub struct App {
168    pub config: AppConfig,
169    pub conversation: Arc<Mutex<Conversation>>,
170    pub tool_executor: Arc<crate::tools::ToolExecutor>,
171    pub api_client: ApiClient,
172    agent_executor: AgentExecutor,
173    event_sender: mpsc::Sender<AppEvent>,
174    approved_tools: Arc<tokio::sync::RwLock<HashSet<String>>>, // Tracks tools approved with "Always" for the session
175    approved_bash_patterns: std::sync::Arc<tokio::sync::RwLock<HashSet<String>>>, // Tracks bash commands approved for the session
176    current_op_context: Option<OpContext>,
177    current_model: Model,
178    session_config: Option<crate::session::state::SessionConfig>, // For tool visibility filtering
179    workspace: Option<Arc<dyn crate::workspace::Workspace>>, // Workspace for environment and tool execution
180    cached_system_prompt: Option<String>, // Cached system prompt to avoid recomputation
181}
182
183/// Check if a command matches any pattern in the given list
184fn matches_any_pattern(command: &str, patterns: &[String]) -> bool {
185    patterns.iter().any(|pattern| {
186        // Check exact match first
187        if pattern == command {
188            return true;
189        }
190        // Then check glob pattern
191        glob::Pattern::new(pattern)
192            .map(|p| p.matches(command))
193            .unwrap_or(false)
194    })
195}
196
197impl App {
198    pub async fn new_with_conversation(
199        config: AppConfig,
200        event_tx: mpsc::Sender<AppEvent>,
201        initial_model: Model,
202        workspace: Arc<dyn crate::workspace::Workspace>,
203        tool_executor: Arc<crate::tools::ToolExecutor>,
204        session_config: Option<crate::session::state::SessionConfig>,
205        conversation: Conversation,
206    ) -> Result<Self> {
207        let api_client = ApiClient::new_with_provider(config.llm_config_provider.clone());
208        let agent_executor = AgentExecutor::new(Arc::new(api_client.clone()));
209
210        // Initialize approved_bash_patterns from session config
211        let approved_bash_patterns = if let Some(ref sc) = session_config {
212            if let Some(bash_config) = sc.tool_config.tools.get("bash") {
213                let crate::session::state::ToolSpecificConfig::Bash(bash) = bash_config;
214                bash.approved_patterns.iter().cloned().collect()
215            } else {
216                HashSet::new()
217            }
218        } else {
219            HashSet::new()
220        };
221        let approved_bash_patterns =
222            std::sync::Arc::new(tokio::sync::RwLock::new(approved_bash_patterns));
223
224        Ok(Self {
225            config,
226            conversation: Arc::new(Mutex::new(conversation)),
227            tool_executor,
228            api_client,
229            agent_executor,
230            event_sender: event_tx,
231            approved_tools: Arc::new(tokio::sync::RwLock::new(HashSet::new())),
232            approved_bash_patterns,
233            current_op_context: None,
234            current_model: initial_model,
235            session_config,
236            workspace: Some(workspace),
237            cached_system_prompt: None,
238        })
239    }
240
241    pub async fn new(
242        config: AppConfig,
243        event_tx: mpsc::Sender<AppEvent>,
244        initial_model: Model,
245        workspace: Arc<dyn crate::workspace::Workspace>,
246        tool_executor: Arc<crate::tools::ToolExecutor>,
247        session_config: Option<crate::session::state::SessionConfig>,
248    ) -> Result<Self> {
249        let conversation = Conversation::new();
250        Self::new_with_conversation(
251            config,
252            event_tx,
253            initial_model,
254            workspace,
255            tool_executor,
256            session_config,
257            conversation,
258        )
259        .await
260    }
261
262    pub(crate) fn emit_event(&self, event: AppEvent) {
263        match self.event_sender.try_send(event.clone()) {
264            Ok(_) => {
265                // Skip logging message parts for brevity
266                if !matches!(event, AppEvent::MessagePart { .. }) {
267                    debug!(target: "app.emit_event", "Sent event: {:?}", event);
268                }
269            }
270            Err(mpsc::error::TrySendError::Full(_)) => {
271                warn!(target: "app.emit_event", "Event channel full, discarding event: {:?}", event);
272            }
273            Err(mpsc::error::TrySendError::Closed(_)) => {
274                warn!(target: "app.emit_event", "Event channel closed, discarding event: {:?}", event);
275            }
276        }
277    }
278
279    pub(crate) async fn emit_workspace_files(&self) {
280        if let Some(workspace) = &self.workspace {
281            info!(target: "app.emit_workspace_files", "Attempting to list workspace files...");
282            match workspace.list_files(None, Some(10000)).await {
283                Ok(files) => {
284                    info!(target: "app.emit_workspace_files", "Emitting workspace files event with {} files", files.len());
285                    if files.is_empty() {
286                        warn!(target: "app.emit_workspace_files", "No files found in workspace - check if directory is correct");
287                    }
288                    self.emit_event(AppEvent::WorkspaceFiles { files });
289                }
290                Err(e) => {
291                    warn!(target: "app.emit_workspace_files", "Failed to list workspace files: {}", e);
292                }
293            }
294        } else {
295            warn!(target: "app.emit_workspace_files", "No workspace available to list files");
296        }
297    }
298
299    pub fn get_current_model(&self) -> Model {
300        self.current_model
301    }
302
303    /// Gets or creates the system prompt, using cache if available
304    async fn get_or_create_system_prompt(&mut self) -> Result<String> {
305        if let Some(ref cached) = self.cached_system_prompt {
306            debug!(target: "app.get_or_create_system_prompt", "Using cached system prompt");
307            Ok(cached.clone())
308        } else {
309            debug!(target: "app.get_or_create_system_prompt", "Creating new system prompt");
310            let prompt = if let Some(workspace) = &self.workspace {
311                create_system_prompt_with_workspace(Some(self.current_model), workspace.as_ref())
312                    .await?
313            } else {
314                // If no workspace, create a minimal system prompt
315
316                if let Some(model) = Some(self.current_model) {
317                    get_model_system_prompt(model)
318                } else {
319                    crate::prompts::default_system_prompt()
320                }
321            };
322            // Cache the system prompt
323            self.cached_system_prompt = Some(prompt.clone());
324            Ok(prompt)
325        }
326    }
327
328    pub async fn set_model(&mut self, model: Model) -> Result<()> {
329        // Check if the provider is available (has API key or OAuth)
330        let provider = model.provider();
331        let auth = self
332            .config
333            .llm_config_provider
334            .get_auth_for_provider(provider)
335            .await?;
336        if auth.is_none() {
337            return Err(crate::error::Error::Configuration(format!(
338                "Cannot set model to {}: missing authentication for {} provider",
339                model.as_ref(),
340                match provider {
341                    ProviderKind::Anthropic => "Anthropic",
342                    ProviderKind::OpenAI => "OpenAI",
343                    ProviderKind::Google => "Google",
344                    ProviderKind::XAI => "xAI",
345                }
346            )));
347        }
348
349        // Set the model
350        self.current_model = model;
351
352        // Clear cached system prompt when model changes
353        self.cached_system_prompt = None;
354
355        // Emit an event to notify UI of the change
356        self.emit_event(AppEvent::ModelChanged { model });
357
358        Ok(())
359    }
360
361    pub async fn add_message(&self, message: Message) {
362        let mut conversation_guard = self.conversation.lock().await;
363        let message_id = message.id().to_string();
364        conversation_guard.add_message(message.clone());
365        self.emit_event(AppEvent::MessageAdded {
366            message,
367            model: self.current_model,
368        });
369        self.emit_event(AppEvent::ActiveMessageIdChanged {
370            message_id: Some(message_id),
371        });
372    }
373
374    pub async fn add_message_from_data(&self, message_data: MessageData) {
375        let mut conversation_guard = self.conversation.lock().await;
376        let message = conversation_guard.add_message_from_data(message_data);
377
378        self.emit_event(AppEvent::MessageAdded {
379            message: message.clone(),
380            model: self.current_model,
381        });
382        self.emit_event(AppEvent::ActiveMessageIdChanged {
383            message_id: Some(message.id().to_string()),
384        });
385    }
386
387    // Renamed from process_user_message to make it clear it starts an op
388    // Returns the event receiver if a standard agent operation was started
389    pub async fn process_user_message(
390        &mut self,
391        message: String,
392    ) -> Result<Option<mpsc::Receiver<AgentEvent>>> {
393        // Cancel any existing operations first
394        self.cancel_current_processing().await;
395
396        // Create a new operation context
397        let op_context = OpContext::new();
398        self.current_op_context = Some(op_context);
399
400        self.add_message_from_data(MessageData::User {
401            content: vec![UserContent::Text {
402                text: message.clone(),
403            }],
404        })
405        .await;
406
407        // Start thinking and spawn agent operation
408        self.emit_event(AppEvent::ProcessingStarted);
409        match self.spawn_agent_operation().await {
410            Ok(maybe_receiver) => Ok(maybe_receiver), // Return the receiver
411            Err(e) => {
412                error!(target:
413                    "App.start_standard_operation",
414                    "Error spawning agent operation task: {}", e,
415                );
416                self.emit_event(AppEvent::ProcessingCompleted); // Stop thinking on spawn error
417                self.emit_event(AppEvent::Error {
418                    message: format!("Failed to start agent operation: {e}"),
419                });
420                self.current_op_context = None; // Clean up context
421                Err(e)
422            }
423        }
424    }
425
426    async fn spawn_agent_operation(&mut self) -> Result<Option<mpsc::Receiver<AgentEvent>>> {
427        debug!(target:
428            "app.spawn_agent_operation",
429            "Spawning agent operation task...",
430        );
431
432        // Get tools for the operation
433        // Always get all tools from the tool executor (includes workspace tools)
434        let mut tool_schemas = self.tool_executor.get_tool_schemas().await;
435
436        // Then apply visibility filtering if we have a session config
437        if let Some(session_config) = &self.session_config {
438            tool_schemas = session_config.filter_tools_by_visibility(tool_schemas);
439        }
440
441        // Get or create system prompt from cache (before accessing op_context mutably)
442        let system_prompt = self.get_or_create_system_prompt().await?;
443
444        // Get mutable access to OpContext and its token
445        let op_context = match &mut self.current_op_context {
446            Some(ctx) => ctx,
447            None => {
448                return Err(crate::error::Error::InvalidOperation(
449                    "No operation context available to spawn agent operation".to_string(),
450                ));
451            }
452        };
453        let token = op_context.cancel_token.clone();
454
455        // Get messages (snapshot)
456        let api_messages = {
457            let conversation_guard = self.conversation.lock().await;
458            conversation_guard
459                .get_thread_messages()
460                .into_iter()
461                .cloned()
462                .collect()
463        };
464
465        let current_model = self.current_model;
466        let agent_executor = self.agent_executor.clone();
467
468        // --- Tool Approval Callback ---
469        let approved_tools_for_approval = self.approved_tools.clone();
470        let tool_executor_for_approval = self.tool_executor.clone();
471        let command_tx_for_approval = OpContext::command_tx().clone();
472        let approved_bash_patterns_clone = self.approved_bash_patterns.clone(); // Clone for capture
473        let session_config_clone = self.session_config.clone(); // Clone for capture
474
475        let tool_approval_callback = move |tool_call: ToolCall| {
476            let approved_tools = approved_tools_for_approval.clone();
477            let executor = tool_executor_for_approval.clone();
478            let command_tx = command_tx_for_approval.clone();
479            let tool_name = tool_call.name.clone();
480            let tool_id = tool_call.id.clone();
481            let approved_bash_patterns = approved_bash_patterns_clone.clone();
482            let session_config = session_config_clone.clone();
483
484            async move {
485                match executor.requires_approval(&tool_name).await {
486                    Ok(false) => return Ok(ApprovalDecision::Approved),
487                    Ok(true) => {}
488                    Err(e) => {
489                        return Err(ToolError::InternalError(format!(
490                            "Failed to check tool approval status for {tool_name}: {e}"
491                        )));
492                    }
493                };
494
495                if approved_tools.read().await.contains(&tool_name) {
496                    return Ok(ApprovalDecision::Approved);
497                }
498
499                // Check if this is a bash command that matches an approved pattern
500                if tool_name == BASH_TOOL_NAME {
501                    // Extract command from tool parameters
502                    let params: BashParams = match serde_json::from_value(
503                        tool_call.parameters.clone(),
504                    ) {
505                        Ok(p) => p,
506                        Err(e) => {
507                            debug!(tool_id=%tool_id, tool_name=%tool_name, "Failed to parse BashParams from tool_call.parameters: {}", e);
508                            return Err(ToolError::invalid_params(
509                                "bash",
510                                format!("Failed to parse BashParams: {e}"),
511                            ));
512                        }
513                    };
514
515                    // Check against static patterns from session config
516                    let static_patterns = if let Some(ref session_config) = session_config {
517                        if let Some(bash_config) = session_config.tool_config.tools.get("bash") {
518                            let crate::session::state::ToolSpecificConfig::Bash(bash) = bash_config;
519                            &bash.approved_patterns
520                        } else {
521                            &Vec::new()
522                        }
523                    } else {
524                        &Vec::new()
525                    };
526
527                    if matches_any_pattern(params.command.as_str(), static_patterns) {
528                        debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} matches static patterns: {:?}", params.command, static_patterns);
529                        return Ok(ApprovalDecision::Approved);
530                    } else {
531                        debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} does not match static patterns: {:?}", params.command, static_patterns);
532                    }
533
534                    // Check against dynamically approved patterns (convert HashSet to Vec)
535                    let dynamic_patterns: Vec<String> = {
536                        let patterns = approved_bash_patterns.read().await;
537                        debug!(tool_id=%tool_id, tool_name=%tool_name, "Dynamic patterns: {:?}", patterns);
538                        patterns.iter().cloned().collect()
539                    };
540
541                    if matches_any_pattern(params.command.as_str(), &dynamic_patterns) {
542                        debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} matches dynamic patterns: {:?}", params.command, dynamic_patterns);
543                        return Ok(ApprovalDecision::Approved);
544                    } else {
545                        debug!(tool_id=%tool_id, tool_name=%tool_name, "Bash command {} does not match dynamic patterns: {:?}", params.command, dynamic_patterns);
546                    }
547                }
548
549                // Needs interactive approval - create oneshot channel for receiving the decision
550                let (tx, rx) = oneshot::channel();
551
552                // Send approval request to the actor loop via command channel
553                if let Err(e) = command_tx
554                    .send(AppCommand::RequestToolApprovalInternal {
555                        tool_call,
556                        responder: tx,
557                    })
558                    .await
559                {
560                    // If we can't send the request, treat as an error
561                    error!(tool_id=%tool_id, tool_name=%tool_name, "Failed to send tool approval request: {}", e);
562                    return Err(ToolError::InternalError(format!(
563                        "Failed to request tool approval: {e}"
564                    )));
565                }
566
567                // Wait for the decision
568                match rx.await {
569                    Ok(d) => Ok(d), // User made a choice
570                    Err(_) => {
571                        // Responder was dropped (likely due to cancellation elsewhere or shutdown)
572                        warn!(tool_id=%tool_id, tool_name=%tool_name, "Approval decision channel closed for tool.");
573                        Ok(ApprovalDecision::Denied) // Treat as denied
574                    }
575                }
576            }
577        };
578
579        // --- Tool Execution Callback ---
580        let tool_executor_for_execution = self.tool_executor.clone();
581        let tool_execution_callback =
582            move |tool_call: ToolCall, callback_token: CancellationToken| {
583                let executor = tool_executor_for_execution.clone();
584                let tool_name = tool_call.name.clone();
585                let tool_id = tool_call.id.clone();
586
587                async move {
588                    info!(tool_id=%tool_id, tool_name=%tool_name, "Executing tool via callback.");
589                    executor
590                        .execute_tool_with_cancellation(&tool_call, callback_token)
591                        .await
592                }
593            };
594
595        let (agent_event_tx, agent_event_rx) = mpsc::channel(100);
596        op_context.tasks.spawn(async move {
597            debug!(target:
598                "spawn_agent_operation task",
599                "Agent operation task started.",
600            );
601            let request = AgentExecutorRunRequest {
602                model: current_model,
603                initial_messages: api_messages,
604                system_prompt: Some(system_prompt),
605                available_tools: tool_schemas,
606                tool_approval_callback,
607                tool_execution_callback,
608            };
609            let operation_result = agent_executor
610                .run(request, agent_event_tx, token)
611                .await;
612
613            debug!(target: "spawn_agent_operation task", "Agent operation task finished with result: {:?}", operation_result.is_ok());
614
615            TaskOutcome::AgentOperationComplete {
616                result: operation_result,
617            }
618        });
619
620        debug!(target:
621            "app.spawn_agent_operation",
622            "Agent operation task successfully spawned.",
623        );
624        Ok(Some(agent_event_rx))
625    }
626
627    // Modified handle_command to return only the response string (or None)
628    // It now starts tasks directly but doesn't return the receiver.
629    pub async fn handle_command(
630        &mut self,
631        command: AppCommandType,
632    ) -> Result<Option<conversation::CommandResponse>> {
633        // Cancel any previous operation before starting a command
634        // Note: This is also called by start_standard_operation if user input isn't a command
635        self.cancel_current_processing().await;
636
637        match command {
638            AppCommandType::Clear => {
639                self.conversation.lock().await.clear();
640                self.approved_tools.write().await.clear(); // Also clear tool approvals
641                self.cached_system_prompt = None; // Clear cached system prompt
642                self.emit_event(AppEvent::ActiveMessageIdChanged { message_id: None });
643
644                Ok(Some(conversation::CommandResponse::Text(
645                    "Conversation and tool approvals cleared.".to_string(),
646                )))
647            }
648            AppCommandType::Compact => {
649                // Create unique operation ID
650                let op_id = uuid::Uuid::new_v4();
651                let start_time = Instant::now();
652
653                // Emit Started event
654                self.emit_event(AppEvent::Started {
655                    id: op_id,
656                    op: Operation::Compact,
657                });
658
659                // Create OpContext for cancellable command
660                let op_context = OpContext::new_with_id(op_id);
661                self.current_op_context = Some(op_context);
662                let token = self
663                    .current_op_context
664                    .as_ref()
665                    .unwrap()
666                    .cancel_token
667                    .clone();
668
669                // Spawn the compaction task within the context
670                // TODO: Add TaskOutcome::CompactResult and handle in actor loop
671                // For now, await directly and clear context
672                warn!(target:
673                    "handle_command",
674                    "Compact command task spawning needs TaskOutcome handling in actor loop.",
675                );
676                let result = match self.compact_conversation(token).await {
677                    Ok(result) => {
678                        let elapsed = start_time.elapsed();
679                        // Emit Finished event on success
680                        self.emit_event(AppEvent::Finished {
681                            id: op_id,
682                            outcome: OperationOutcome::Compact {
683                                elapsed,
684                                result: Ok(()),
685                            },
686                        });
687                        Ok(Some(conversation::CommandResponse::Compact(result)))
688                    }
689                    Err(e) => {
690                        let elapsed = start_time.elapsed();
691                        error!(target:
692                            "App.handle_command",
693                            "Error during compact: {}", e,
694                        );
695                        // Emit Finished event with error
696                        self.emit_event(AppEvent::Finished {
697                            id: op_id,
698                            outcome: OperationOutcome::Compact {
699                                elapsed,
700                                result: Err(CompactError {
701                                    message: e.to_string(),
702                                }),
703                            },
704                        });
705                        Err(e) // Propagate actual errors
706                    }
707                }?;
708                self.current_op_context = None; // Clear context after command
709                Ok(result)
710            }
711            AppCommandType::Model { target } => {
712                if target.is_none() {
713                    // If no model specified, list available models
714                    use crate::api::Model;
715                    use strum::IntoEnumIterator;
716
717                    let current_model = self.get_current_model();
718                    let available_models: Vec<String> = Model::iter()
719                        .map(|m| {
720                            let model_str = m.as_ref();
721                            let aliases = m.aliases();
722                            let alias_str = if aliases.is_empty() {
723                                String::new()
724                            } else if aliases.len() == 1 {
725                                format!(" (alias: {})", aliases[0])
726                            } else {
727                                format!(" (aliases: {})", aliases.join(", "))
728                            };
729
730                            if m == current_model {
731                                format!("* {model_str}{alias_str}") // Mark current model with asterisk
732                            } else {
733                                format!("  {model_str}{alias_str}")
734                            }
735                        })
736                        .collect();
737
738                    Ok(Some(conversation::CommandResponse::Text(format!(
739                        "Current model: {}\nAvailable models:\n{}",
740                        current_model.as_ref(),
741                        available_models.join("\n")
742                    ))))
743                } else if let Some(ref model_name) = target {
744                    // Try to set the model
745                    use crate::api::Model;
746                    use std::str::FromStr;
747
748                    match Model::from_str(model_name) {
749                        Ok(model) => match self.set_model(model).await {
750                            Ok(()) => Ok(Some(conversation::CommandResponse::Text(format!(
751                                "Model changed to {}",
752                                model.as_ref()
753                            )))),
754                            Err(e) => Ok(Some(conversation::CommandResponse::Text(format!(
755                                "Failed to set model: {e}"
756                            )))),
757                        },
758                        Err(_) => Ok(Some(conversation::CommandResponse::Text(format!(
759                            "Unknown model: {model_name}"
760                        )))),
761                    }
762                } else {
763                    // This should not happen with the current enum structure
764                    Ok(None)
765                }
766            }
767        }
768    }
769
770    pub async fn compact_conversation(
771        &mut self,
772        token: CancellationToken,
773    ) -> Result<CompactResult> {
774        info!(target:"App.compact_conversation", "Compacting conversation...");
775        let client = self.api_client.clone();
776        let conversation_arc = self.conversation.clone();
777        let model = self.current_model;
778
779        // Run directly but make it cancellable.
780        let result = tokio::select! {
781            biased;
782            res = async { conversation_arc.lock().await.compact(&client, model, token.clone()).await } => res.map_err(|e| Error::InvalidOperation(format!("Compact failed: {e}")))?,
783            _ = token.cancelled() => {
784                 info!(target:"App.compact_conversation", "Compaction cancelled.");
785                 return Ok(CompactResult::Cancelled);
786             }
787        };
788
789        // If compaction succeeded, emit active message ID change event
790        if matches!(result, CompactResult::Success(_)) {
791            let active_id = conversation_arc.lock().await.active_message_id.clone();
792            self.emit_event(AppEvent::ActiveMessageIdChanged {
793                message_id: active_id,
794            });
795        }
796
797        info!(target:"App.compact_conversation", "Conversation compacted.");
798        Ok(result)
799    }
800
801    pub async fn cancel_current_processing(&mut self) {
802        // Use operation context for cancellation if available
803        if let Some(mut op_context) = self.current_op_context.take() {
804            info!(target:
805                "App.cancel_current_processing",
806                "Cancelling current operation via OpContext",
807            );
808
809            // Capture the current state for the cancellation info
810            let active_tools = op_context
811                .active_tools
812                .values()
813                .map(|(_, _, name)| ActiveTool {
814                    id: name.clone(), // Using name as ID for cancellation info
815                    name: name.clone(),
816                })
817                .collect();
818            // TODO: Get accurate pending approval status from the actor loop's ApprovalState
819            let cancellation_info = CancellationInfo {
820                api_call_in_progress: false, // Handled by AgentExecutor now
821                active_tools,
822                pending_tool_approvals: false, // TODO: Update this based on actor state
823            };
824
825            // Get the operation ID before canceling
826            let op_id = op_context.operation_id;
827
828            op_context.cancel_and_shutdown().await;
829
830            self.emit_event(AppEvent::OperationCancelled {
831                op_id,
832                info: cancellation_info,
833            });
834
835            // Inject cancelled tool results after cancelling
836            self.inject_cancelled_tool_results().await;
837
838            // Emit ProcessingCompleted only when we actually cancelled something
839            self.emit_event(AppEvent::ProcessingCompleted);
840            // Don't return here, actor loop needs to clear receiver if present
841        } else {
842            warn!(target:
843                "App.cancel_current_processing",
844                "Attempted to cancel processing, but no active operation context was found.",
845            );
846        }
847    }
848
849    /// Inject cancelled tool results for any incomplete tool calls in the conversation.
850    /// This ensures that LLM APIs receive proper tool_result blocks for every tool_use block.
851    pub async fn inject_cancelled_tool_results(&mut self) {
852        let incomplete_tool_calls = {
853            let conversation_guard = self.conversation.lock().await;
854            self.find_incomplete_tool_calls(&conversation_guard)
855        };
856
857        if !incomplete_tool_calls.is_empty() {
858            info!(target: "App.inject_cancelled_tool_results",
859                  "Found {} incomplete tool calls, injecting cancellation results",
860                  incomplete_tool_calls.len());
861
862            for tool_call in incomplete_tool_calls {
863                self.add_message_from_data(MessageData::Tool {
864                    tool_use_id: tool_call.id.clone(),
865                    result: ToolResult::Error(ToolError::Cancelled(tool_call.name.clone())),
866                })
867                .await;
868
869                // Emit ToolCallFailed event so UI can render cancellation result
870                self.emit_event(AppEvent::ToolCallFailed {
871                    id: tool_call.id.clone(),
872                    name: tool_call.name.clone(),
873                    error: "Cancelled".to_string(),
874                    model: self.current_model,
875                });
876
877                debug!(target: "App.inject_cancelled_tool_results",
878                       "Injected cancellation result for tool call: {} ({})",
879                       tool_call.name, tool_call.id);
880            }
881        }
882    }
883
884    /// Find tool calls that don't have corresponding tool results
885    fn find_incomplete_tool_calls(&self, conversation: &Conversation) -> Vec<ToolCall> {
886        let mut tool_calls = Vec::new();
887        let mut tool_results = std::collections::HashSet::new();
888
889        // First pass: collect all tool results
890        for message in &conversation.messages {
891            if let MessageData::Tool { tool_use_id, .. } = &message.data {
892                tool_results.insert(tool_use_id.clone());
893            }
894        }
895
896        // Second pass: find tool calls without results
897        for message in &conversation.messages {
898            if let MessageData::Assistant { content, .. } = &message.data {
899                for block in content {
900                    if let AssistantContent::ToolCall { tool_call } = block {
901                        if !tool_results.contains(&tool_call.id) {
902                            tool_calls.push(tool_call.clone());
903                        }
904                    }
905                }
906            }
907        }
908
909        tool_calls
910    }
911
912    async fn get_active_message_id(&self) -> Option<String> {
913        let conv = self.conversation.lock().await;
914        conv.active_message_id.clone()
915    }
916}
917
918// Approval queue helper struct
919struct ApprovalQueue {
920    current: Option<(String, ToolCall, oneshot::Sender<ApprovalDecision>)>,
921    queued: std::collections::VecDeque<(String, ToolCall, oneshot::Sender<ApprovalDecision>)>,
922}
923
924impl ApprovalQueue {
925    fn new() -> Self {
926        Self {
927            current: None,
928            queued: std::collections::VecDeque::new(),
929        }
930    }
931
932    fn add_request(
933        &mut self,
934        id: String,
935        tool_call: ToolCall,
936        responder: oneshot::Sender<ApprovalDecision>,
937    ) {
938        self.queued.push_back((id, tool_call, responder));
939    }
940
941    fn cancel_all(&mut self) {
942        // Drop current request responder to signal cancellation
943        if let Some((id, _, _)) = self.current.take() {
944            info!(target: "ApprovalQueue", "Cancelled active tool approval request for ID '{}'", id);
945        }
946        // Drop all queued responders
947        if !self.queued.is_empty() {
948            info!(target: "ApprovalQueue", "Clearing {} queued tool approval requests", self.queued.len());
949            self.queued.clear();
950        }
951    }
952}
953
954// Define the App actor loop function with minimal refactoring
955pub async fn app_actor_loop(mut app: App, mut command_rx: mpsc::Receiver<AppCommand>) {
956    info!(target: "app_actor_loop", "App actor loop started.");
957
958    // Emit initial workspace files for the UI
959    app.emit_workspace_files().await;
960
961    // Approval queue state
962    let mut approval_queue = ApprovalQueue::new();
963
964    // Active agent event receiver
965    let mut active_agent_event_rx: Option<mpsc::Receiver<AgentEvent>> = None;
966
967    // Track if the associated task for the active receiver has completed
968    let mut agent_task_completed = false;
969
970    loop {
971        tokio::select! {
972            // Handle incoming commands from the UI/Main thread
973            Some(command) = command_rx.recv() => {
974                match handle_app_command(
975                    &mut app,
976                    command,
977                    &mut approval_queue,
978                    &mut active_agent_event_rx,
979                )
980                .await
981                {
982                    Ok(should_exit) => {
983                        if should_exit {
984                            break; // Exit loop if Shutdown command was received
985                        }
986                    }
987                    Err(e) => {
988                        error!(target: "app_actor_loop", "Error handling app command: {}", e);
989                        app.emit_event(AppEvent::Error {
990                            message: format!("Command failed: {e}"),
991                        });
992                    }
993                }
994                // Reset task completion flag only if a new operation started
995                if active_agent_event_rx.is_some() {
996                    debug!(target: "app_actor_loop", "Resetting agent_task_completed flag due to new operation.");
997                    agent_task_completed = false;
998                }
999            }
1000
1001            // Poll for completed tasks (Agent Operations) from OpContext
1002            // This arm MUST be polled *before* the event receiver arm
1003            result = async {
1004                if let Some(ctx) = app.current_op_context.as_mut() {
1005                    if ctx.tasks.is_empty() { None } else { ctx.tasks.join_next().await }
1006                } else {
1007                    None
1008                }
1009            }, if app.current_op_context.is_some() => {
1010                if let Some(join_result) = result {
1011                    match join_result {
1012                        Ok(task_outcome) => {
1013                            let is_standard_op = matches!(task_outcome, TaskOutcome::AgentOperationComplete{..});
1014
1015                            handle_task_outcome(&mut app, task_outcome).await;
1016
1017                            if is_standard_op {
1018                                debug!(target: "app_actor_loop", "Agent task completed flag set to true.");
1019                                agent_task_completed = true;
1020                            }
1021
1022                            // Check if we should signal ProcessingCompleted now
1023                            if agent_task_completed && active_agent_event_rx.is_none() {
1024                                debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (Task done, receiver drained).");
1025                                app.emit_event(AppEvent::ProcessingCompleted);
1026                                agent_task_completed = false;
1027                            }
1028                        }
1029                        Err(join_err) => {
1030                            error!(target: "app_actor_loop", "Task join error: {}", join_err);
1031                            app.current_op_context = None;
1032                            active_agent_event_rx = None;
1033                            agent_task_completed = false;
1034                            app.emit_event(AppEvent::ProcessingCompleted);
1035                            app.emit_event(AppEvent::Error {
1036                                message: format!("A task failed unexpectedly: {join_err}")
1037                            });
1038                        }
1039                    }
1040                } else {
1041                    // JoinSet returned None - all tasks finished
1042                    if let Some(_ctx) = app.current_op_context.take() {
1043                        debug!(target: "app_actor_loop", "JoinSet empty. Clearing context.");
1044                        agent_task_completed = true;
1045
1046                        if agent_task_completed && active_agent_event_rx.is_none() {
1047                            debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (JoinSet empty, receiver drained).");
1048                            app.emit_event(AppEvent::ProcessingCompleted);
1049                            agent_task_completed = false;
1050                        }
1051                    }
1052                }
1053            }
1054
1055            // Poll for incoming AgentEvents
1056            // Poll this *after* task completion to ensure correct ordering
1057            maybe_agent_event = async { active_agent_event_rx.as_mut().unwrap().recv().await },
1058            if active_agent_event_rx.is_some() => {
1059                match maybe_agent_event {
1060                    Some(event) => {
1061                        handle_agent_event(&mut app, event).await;
1062                    }
1063                    None => {
1064                        // Channel closed
1065                        debug!(target: "app_actor_loop", "Agent event channel closed.");
1066                        active_agent_event_rx = None;
1067
1068                        if agent_task_completed {
1069                            debug!(target: "app_actor_loop", "Signaling ProcessingCompleted (Receiver closed, task completed).");
1070                            app.emit_event(AppEvent::ProcessingCompleted);
1071                            agent_task_completed = false;
1072                        }
1073                    }
1074                }
1075            }
1076
1077            // Default branch if no other arms are ready
1078            else => {}
1079        }
1080    }
1081    info!(target: "app_actor_loop", "App actor loop finished.");
1082}
1083
1084// Process the next approval request from the queue
1085async fn process_next_approval_request(app: &mut App, queue: &mut ApprovalQueue) {
1086    if queue.current.is_some() {
1087        debug!(target: "process_next_approval", "An approval request is already active.");
1088        return;
1089    }
1090
1091    while let Some((id, tool_call, responder)) = queue.queued.pop_front() {
1092        if app.approved_tools.read().await.contains(&tool_call.name) {
1093            info!(target: "process_next_approval", "Auto-approving tool '{}' (ID: {})", tool_call.name, id);
1094            if responder.send(ApprovalDecision::Approved).is_err() {
1095                warn!(target: "process_next_approval", "Failed to send auto-approval for tool ID '{}'", id);
1096            }
1097        } else {
1098            // Not auto-approved, send to UI
1099            info!(target: "process_next_approval", "Sending tool approval request to UI for '{}' (ID: {})", tool_call.name, id);
1100            let parameters = tool_call.parameters.clone();
1101            let name = tool_call.name.clone();
1102
1103            queue.current = Some((id.clone(), tool_call, responder));
1104
1105            app.emit_event(AppEvent::RequestToolApproval {
1106                name,
1107                parameters,
1108                id,
1109            });
1110            return;
1111        }
1112    }
1113    debug!(target: "process_next_approval", "Approval queue processed.");
1114}
1115
1116// Handle app commands
1117async fn handle_app_command(
1118    app: &mut App,
1119    command: AppCommand,
1120    approval_queue: &mut ApprovalQueue,
1121    active_agent_event_rx: &mut Option<mpsc::Receiver<AgentEvent>>,
1122) -> Result<bool> {
1123    debug!(target: "handle_app_command", "Received command: {:?}", command);
1124
1125    match command {
1126        AppCommand::ProcessUserInput(message) => {
1127            if message.starts_with('/') {
1128                // Clear previous receiver if any before running command
1129                if active_agent_event_rx.is_some() {
1130                    warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to new command input.");
1131                    *active_agent_event_rx = None;
1132                }
1133                match AppCommandType::parse(&message) {
1134                    Ok(cmd) => {
1135                        handle_slash_command(app, cmd).await;
1136                    }
1137                    Err(e) => {
1138                        app.emit_event(AppEvent::Error {
1139                            message: format!("Error parsing command: {e:?}"),
1140                        });
1141                    }
1142                }
1143            } else {
1144                // Regular user message, start a standard operation
1145                if active_agent_event_rx.is_some() {
1146                    warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to new user input.");
1147                    *active_agent_event_rx = None;
1148                }
1149                match app.process_user_message(message).await {
1150                    Ok(maybe_receiver) => {
1151                        *active_agent_event_rx = maybe_receiver;
1152                    }
1153                    Err(e) => {
1154                        error!(target: "handle_app_command", "Error starting standard operation: {}", e);
1155                    }
1156                }
1157            }
1158            Ok(false)
1159        }
1160        AppCommand::EditMessage {
1161            message_id,
1162            new_content,
1163        } => {
1164            debug!(target: "handle_app_command", "Editing message {} with new content", message_id);
1165
1166            // Cancel any existing operations first
1167            app.cancel_current_processing().await;
1168            if active_agent_event_rx.is_some() {
1169                warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to message edit.");
1170                *active_agent_event_rx = None;
1171            }
1172
1173            // Edit the message in the conversation. This removes the old branch and adds the new one.
1174            let (new_message_id, edited_message_opt, active_id) = {
1175                let mut conversation = app.conversation.lock().await;
1176                let result = conversation.edit_message(
1177                    &message_id,
1178                    vec![UserContent::Text {
1179                        text: new_content.clone(),
1180                    }],
1181                );
1182
1183                // Attempt to fetch the newly created edited message (it will be the latest User message)
1184                let edited_msg = if result.is_some() {
1185                    conversation
1186                        .messages
1187                        .iter()
1188                        .rev()
1189                        .find(|m| matches!(m.data, MessageData::User { .. }))
1190                        .cloned()
1191                } else {
1192                    None
1193                };
1194
1195                (result, edited_msg, conversation.active_message_id.clone())
1196            };
1197
1198            // Notify the UI about the newly edited user message so it can appear immediately
1199            if let Some(edited_message) = edited_message_opt {
1200                app.emit_event(AppEvent::MessageAdded {
1201                    message: edited_message,
1202                    model: app.current_model,
1203                });
1204            }
1205
1206            if new_message_id.is_some() {
1207                debug!(target: "handle_app_command", "Successfully edited message and created new branch");
1208
1209                // Emit active message ID change event
1210                app.emit_event(AppEvent::ActiveMessageIdChanged {
1211                    message_id: active_id,
1212                });
1213
1214                // This message is now the latest in the conversation.
1215                // We can now start a new agent operation.
1216                // This logic is adapted from `process_user_message`, but without adding a new message.
1217                app.current_op_context = Some(OpContext::new());
1218                app.emit_event(AppEvent::ProcessingStarted);
1219
1220                match app.spawn_agent_operation().await {
1221                    Ok(maybe_receiver) => {
1222                        *active_agent_event_rx = maybe_receiver;
1223                    }
1224                    Err(e) => {
1225                        error!(target: "handle_app_command", "Error processing edited message: {}", e);
1226                        app.current_op_context = None;
1227                        app.emit_event(AppEvent::ProcessingCompleted);
1228                    }
1229                }
1230            } else {
1231                error!(target: "handle_app_command", "Failed to edit message {}", message_id);
1232            }
1233            Ok(false)
1234        }
1235        AppCommand::RestoreConversation {
1236            messages,
1237            approved_tools,
1238            approved_bash_patterns,
1239            active_message_id,
1240        } => {
1241            // Atomically restore entire conversation state
1242            debug!(target:"handle_app_command", "Restoring conversation with {} messages, {} approved tools, and {} approved bash patterns",
1243                messages.len(), approved_tools.len(), approved_bash_patterns.len());
1244
1245            // Restore messages and active_message_id
1246            let mut conversation_guard = app.conversation.lock().await;
1247            conversation_guard.messages = messages;
1248            conversation_guard.active_message_id = active_message_id;
1249            drop(conversation_guard);
1250
1251            // Restore approved tools
1252            *app.approved_tools.write().await = approved_tools;
1253
1254            // Restore approved bash patterns
1255            *app.approved_bash_patterns.write().await = approved_bash_patterns;
1256
1257            debug!(target:"handle_app_command", "Conversation restoration complete");
1258            Ok(false)
1259        }
1260
1261        AppCommand::HandleToolResponse { id, approval } => {
1262            if let Some((current_id, current_tool_call, responder)) = approval_queue.current.take()
1263            {
1264                debug!(target: "handle_app_command", "Handling tool response for ID '{}', approval: {:?}", id, approval);
1265                if current_id != id {
1266                    error!(target: "handle_app_command", "Mismatched tool ID. Expected '{}', got '{}'", current_id, id);
1267                    approval_queue
1268                        .queued
1269                        .push_front((current_id, current_tool_call, responder));
1270                } else {
1271                    let decision = match approval {
1272                        ApprovalType::Once => {
1273                            debug!(target: "handle_app_command", "Approving tool call with ID '{}' once.", id);
1274                            ApprovalDecision::Approved
1275                        }
1276                        ApprovalType::AlwaysTool => {
1277                            debug!(target: "handle_app_command", "Approving tool call with ID '{}' always.", id);
1278                            app.approved_tools
1279                                .write()
1280                                .await
1281                                .insert(current_tool_call.name.clone());
1282                            ApprovalDecision::Approved
1283                        }
1284                        ApprovalType::AlwaysBashPattern(pattern) => {
1285                            debug!(target: "handle_app_command", "Approving bash command '{}' always.", pattern);
1286                            app.approved_bash_patterns
1287                                .write()
1288                                .await
1289                                .insert(pattern.clone());
1290                            ApprovalDecision::Approved
1291                        }
1292                        ApprovalType::Denied => {
1293                            debug!(target: "handle_app_command", "Denying tool call with ID '{}'.", id);
1294                            ApprovalDecision::Denied
1295                        }
1296                    };
1297
1298                    debug!(target: "handle_app_command", "Sending approval decision for tool ID '{}': {:?}", id, decision);
1299                    if responder.send(decision).is_err() {
1300                        error!(target: "handle_app_command", "Failed to send approval decision for tool ID '{}'", id);
1301                    }
1302                }
1303            } else {
1304                error!(target: "handle_app_command", "Received tool response for ID '{}' but no current approval request was active.", id);
1305            }
1306
1307            process_next_approval_request(app, approval_queue).await;
1308            Ok(false)
1309        }
1310
1311        AppCommand::CancelProcessing => {
1312            debug!(target: "handle_app_command", "Handling CancelProcessing command.");
1313            app.cancel_current_processing().await;
1314            approval_queue.cancel_all();
1315
1316            if active_agent_event_rx.is_some() {
1317                debug!(target: "handle_app_command", "Clearing active agent event receiver due to cancellation.");
1318                *active_agent_event_rx = None;
1319            }
1320            app.emit_event(AppEvent::ProcessingCompleted);
1321            Ok(false)
1322        }
1323
1324        AppCommand::ExecuteBashCommand { command } => {
1325            debug!(target: "handle_app_command", "Executing bash command: {}", command);
1326
1327            // Cancel any existing operations first
1328            app.cancel_current_processing().await;
1329
1330            // Create unique operation ID
1331            let op_id = uuid::Uuid::new_v4();
1332            let start_time = Instant::now();
1333
1334            // Emit Started event
1335            app.emit_event(AppEvent::Started {
1336                id: op_id,
1337                op: Operation::Bash {
1338                    cmd: command.clone(),
1339                },
1340            });
1341
1342            // Create OpContext for cancellable operation
1343            let mut op_context = OpContext::new_with_id(op_id);
1344            let token = op_context.cancel_token.clone();
1345
1346            // Create a tool call for the bash command
1347            let tool_call = ToolCall {
1348                id: format!("bash_{}", uuid::Uuid::new_v4()),
1349                name: BASH_TOOL_NAME.to_string(),
1350                parameters: serde_json::to_value(BashParams {
1351                    command: command.clone(),
1352                    timeout: None,
1353                })
1354                .map_err(|e| Error::BashCommandError(e.to_string()))?,
1355            };
1356
1357            // Clone necessary values for the spawned task
1358            let tool_executor = app.tool_executor.clone();
1359            let command_clone = command.clone();
1360
1361            // Spawn the bash execution task
1362            op_context.tasks.spawn(async move {
1363                let result = tool_executor.execute_tool_direct(&tool_call, token).await;
1364
1365                TaskOutcome::BashCommandComplete {
1366                    op_id,
1367                    command: command_clone,
1368                    start_time,
1369                    result,
1370                }
1371            });
1372
1373            // Store the operation context
1374            app.current_op_context = Some(op_context);
1375
1376            Ok(false)
1377        }
1378        AppCommand::Shutdown => {
1379            info!(target: "handle_app_command", "Received Shutdown command.");
1380            app.cancel_current_processing().await;
1381            approval_queue.cancel_all();
1382            *active_agent_event_rx = None;
1383            Ok(true)
1384        }
1385        AppCommand::GetCurrentConversation => {
1386            // This command is now handled synchronously via RPC
1387            // Should not be called directly anymore
1388            warn!(target:"handle_app_command", "GetCurrentConversation command received - this should use the sync RPC instead");
1389            Ok(false)
1390        }
1391        AppCommand::RequestToolApprovalInternal {
1392            tool_call,
1393            responder,
1394        } => {
1395            let tool_id = tool_call.id.clone();
1396            let tool_name = tool_call.name.clone();
1397
1398            info!(target: "handle_app_command", "Received internal request for tool approval: '{}' (ID: {})", tool_name, tool_id);
1399
1400            approval_queue.add_request(tool_id, tool_call, responder);
1401            process_next_approval_request(app, approval_queue).await;
1402            Ok(false)
1403        }
1404
1405        AppCommand::ExecuteCommand(cmd) => {
1406            warn!(target: "handle_app_command", "Received ExecuteCommand: {}", cmd.as_command_str());
1407            if active_agent_event_rx.is_some() {
1408                warn!(target: "handle_app_command", "Clearing previous active agent event receiver due to ExecuteCommand.");
1409                *active_agent_event_rx = None;
1410            }
1411            handle_slash_command(app, cmd).await;
1412            Ok(false)
1413        }
1414
1415        AppCommand::RequestWorkspaceFiles => {
1416            info!(target: "app.handle_app_command", "Received RequestWorkspaceFiles command");
1417            app.emit_workspace_files().await;
1418            Ok(false)
1419        }
1420    }
1421}
1422
1423// Handle slash commands
1424async fn handle_slash_command(app: &mut App, command: AppCommandType) {
1425    let command_str = command.as_command_str();
1426    match app.handle_command(command.clone()).await {
1427        Ok(response_option) => {
1428            if let Some(response) = response_option {
1429                app.emit_event(AppEvent::CommandResponse {
1430                    command,
1431                    response,
1432                    id: format!("cmd_resp_{}", uuid::Uuid::new_v4()),
1433                });
1434            }
1435        }
1436        Err(e) => {
1437            error!(target: "handle_slash_command", "Error running command '{}': {}", command_str, e);
1438            app.emit_event(AppEvent::Error {
1439                message: format!("Command failed: {e}"),
1440            });
1441        }
1442    }
1443}
1444// Handle agent events
1445async fn handle_agent_event(app: &mut App, event: AgentEvent) {
1446    debug!(target: "handle_agent_event", "Handling event: {:?}", event);
1447
1448    match event {
1449        AgentEvent::MessageFinal(message) => match message.data {
1450            MessageData::Tool {
1451                ref tool_use_id,
1452                ref result,
1453            } => {
1454                let conversation_guard = app.conversation.lock().await;
1455                let tool_name = conversation_guard
1456                    .find_tool_name_by_id(tool_use_id)
1457                    .unwrap_or_else(|| "unknown_tool".to_string());
1458                drop(conversation_guard);
1459
1460                let is_error = matches!(result, ToolResult::Error(_));
1461                if is_error {
1462                    if let steer_tools::result::ToolResult::Error(e) = result {
1463                        app.emit_event(AppEvent::ToolCallFailed {
1464                            id: tool_use_id.clone(),
1465                            name: tool_name.clone(),
1466                            error: e.to_string(),
1467                            model: app.current_model,
1468                        });
1469                    }
1470                } else {
1471                    app.emit_event(AppEvent::ToolCallCompleted {
1472                        id: tool_use_id.clone(),
1473                        name: tool_name.clone(),
1474                        result: result.clone(),
1475                        model: app.current_model,
1476                    });
1477
1478                    let mutating_tools =
1479                        ["edit", "replace", "bash", "write_file", "multi_edit_file"];
1480                    if mutating_tools.contains(&tool_name.as_str()) {
1481                        app.emit_event(AppEvent::WorkspaceChanged);
1482                        app.emit_workspace_files().await;
1483                    }
1484                }
1485                app.add_message(message.clone()).await;
1486            }
1487            MessageData::Assistant { .. } => {
1488                app.add_message(message).await;
1489            }
1490            MessageData::User { .. } => {
1491                warn!(target: "handle_agent_event", "Received MessageFinal for user message. This should not happen.");
1492            }
1493        },
1494        AgentEvent::ExecutingTool {
1495            tool_call_id,
1496            name,
1497            parameters,
1498        } => {
1499            app.emit_event(AppEvent::ToolCallStarted {
1500                id: tool_call_id,
1501                name,
1502                parameters,
1503                model: app.current_model,
1504            });
1505        }
1506    }
1507}
1508
1509// Handle task outcomes
1510async fn handle_task_outcome(app: &mut App, task_outcome: TaskOutcome) {
1511    match task_outcome {
1512        TaskOutcome::AgentOperationComplete { result } => {
1513            info!(target: "handle_task_outcome", "Standard agent operation task completed processing.");
1514
1515            match result {
1516                Ok(_) => {
1517                    info!(target: "handle_task_outcome", "Agent operation task reported success.");
1518                }
1519                Err(e) => {
1520                    error!(target: "handle_task_outcome", "Agent operation task reported failure: {}", e);
1521                    // Emit error event only if it wasn't a cancellation
1522                    if !matches!(e, AgentExecutorError::Cancelled) {
1523                        app.emit_event(AppEvent::Error {
1524                            message: e.to_string(),
1525                        });
1526                    }
1527                }
1528            }
1529
1530            // Clear the context
1531            debug!(target: "handle_task_outcome", "Clearing OpContext for completed standard operation.");
1532            app.current_op_context = None;
1533        }
1534        TaskOutcome::DispatchAgentResult { result } => {
1535            info!(target: "handle_task_outcome", "Dispatch agent operation task completed.");
1536
1537            match result {
1538                Ok(response_text) => {
1539                    info!(target: "handle_task_outcome", "Dispatch agent successful.");
1540
1541                    app.add_message_from_data(MessageData::Assistant {
1542                        content: vec![AssistantContent::Text {
1543                            text: format!("Dispatch Agent Result:\n{response_text}"),
1544                        }],
1545                    })
1546                    .await;
1547                }
1548                Err(e) => {
1549                    error!(target: "handle_task_outcome", "Dispatch agent failed: {}", e);
1550                    app.emit_event(AppEvent::Error {
1551                        message: e.to_string(),
1552                    });
1553                }
1554            }
1555
1556            // Clear context and stop thinking immediately for dispatch operations
1557            debug!(target: "handle_task_outcome", "Clearing OpContext and signaling ProcessingCompleted for dispatch operation.");
1558            app.current_op_context = None;
1559        }
1560        TaskOutcome::BashCommandComplete {
1561            op_id,
1562            command,
1563            start_time,
1564            result,
1565        } => {
1566            info!(target: "handle_task_outcome", "Bash command task completed.");
1567
1568            let elapsed = start_time.elapsed();
1569
1570            match result {
1571                Ok(output) => {
1572                    // Get the formatted output from the typed result
1573                    let output_str = output.llm_format();
1574
1575                    // Parse the output to extract stdout/stderr/exit code
1576                    let (stdout, stderr, exit_code) = parse_bash_output(&output_str);
1577
1578                    app.add_message_from_data(MessageData::User {
1579                        content: vec![UserContent::CommandExecution {
1580                            command: command.clone(),
1581                            stdout,
1582                            stderr,
1583                            exit_code,
1584                        }],
1585                    })
1586                    .await;
1587
1588                    // Emit Finished event
1589                    app.emit_event(AppEvent::Finished {
1590                        id: op_id,
1591                        outcome: OperationOutcome::Bash {
1592                            elapsed,
1593                            result: Ok(()),
1594                        },
1595                    });
1596
1597                    // A bash command can mutate the workspace; notify listeners.
1598                    app.emit_event(AppEvent::WorkspaceChanged);
1599                    app.emit_workspace_files().await;
1600                }
1601                Err(e) => {
1602                    error!(target: "handle_task_outcome", "Failed to execute bash command: {}", e);
1603
1604                    // Handle cancellation specially - no error message
1605                    if matches!(e, steer_tools::ToolError::Cancelled(_)) {
1606                        // Emit Finished event with cancellation
1607                        app.emit_event(AppEvent::Finished {
1608                            id: op_id,
1609                            outcome: OperationOutcome::Bash {
1610                                elapsed,
1611                                result: Err(BashError {
1612                                    exit_code: -130, // Traditional exit code for SIGINT
1613                                    stderr: "Cancelled".to_string(),
1614                                }),
1615                            },
1616                        });
1617                    } else {
1618                        // Add error as a command execution with error output
1619                        let parent_id = app.get_active_message_id().await;
1620                        let error_message = format!("Error executing command: {e}");
1621                        let message = Message {
1622                            data: MessageData::User {
1623                                content: vec![UserContent::CommandExecution {
1624                                    command: command.clone(),
1625                                    stdout: String::new(),
1626                                    stderr: error_message.clone(),
1627                                    exit_code: -1,
1628                                }],
1629                            },
1630                            timestamp: Message::current_timestamp(),
1631                            id: Message::generate_id("user", Message::current_timestamp()),
1632                            parent_message_id: parent_id,
1633                        };
1634                        app.add_message(message).await;
1635
1636                        // Emit Finished event with error
1637                        app.emit_event(AppEvent::Finished {
1638                            id: op_id,
1639                            outcome: OperationOutcome::Bash {
1640                                elapsed,
1641                                result: Err(BashError {
1642                                    exit_code: -1,
1643                                    stderr: error_message,
1644                                }),
1645                            },
1646                        });
1647
1648                        // A bash command can mutate the workspace; notify listeners.
1649                        app.emit_event(AppEvent::WorkspaceChanged);
1650                        app.emit_workspace_files().await;
1651                    }
1652                }
1653            }
1654
1655            debug!(target: "handle_task_outcome", "Clearing OpContext and signaling ProcessingCompleted for bash operation.");
1656            app.current_op_context = None;
1657        }
1658    }
1659}
1660
1661async fn create_system_prompt_with_workspace(
1662    model: Option<Model>,
1663    workspace: &dyn crate::workspace::Workspace,
1664) -> Result<String> {
1665    let env_info = workspace.environment().await?;
1666
1667    // Use model-specific prompt if available, otherwise use default
1668    let system_prompt_body = if let Some(model) = model {
1669        get_model_system_prompt(model)
1670    } else {
1671        crate::prompts::default_system_prompt()
1672    };
1673
1674    let prompt = format!(
1675        r#"{}
1676
1677{}"#,
1678        system_prompt_body,
1679        env_info.as_context()
1680    );
1681    Ok(prompt)
1682}
1683
1684fn get_model_system_prompt(model: Model) -> String {
1685    match model {
1686        Model::O3_20250416 | Model::Gpt4_1_20250414 | Model::O4Mini20250416 | Model::Grok4_0709 => {
1687            crate::prompts::o3_system_prompt()
1688        }
1689        Model::Gemini2_5FlashPreview0417
1690        | Model::Gemini2_5ProPreview0506
1691        | Model::Gemini2_5ProPreview0605 => crate::prompts::gemini_system_prompt(),
1692        Model::ClaudeSonnet4_20250514 | Model::ClaudeOpus4_20250514 => {
1693            crate::prompts::claude_system_prompt()
1694        }
1695        _ => crate::prompts::default_system_prompt(),
1696    }
1697}
1698
1699fn parse_bash_output(output: &str) -> (String, String, i32) {
1700    // Check if this is an error output from the bash tool
1701    if output.starts_with("Command failed with exit code") {
1702        // Parse the error format:
1703        // "Command failed with exit code {}\n--- STDOUT ---\n{}\n--- STDERR ---\n{}"
1704        let mut stdout = String::new();
1705        let mut stderr = String::new();
1706        let mut exit_code = -1;
1707
1708        let lines: Vec<&str> = output.lines().collect();
1709        let mut i = 0;
1710
1711        // Extract exit code from first line
1712        if let Some(first_line) = lines.first() {
1713            if let Some(code_str) = first_line.strip_prefix("Command failed with exit code ") {
1714                exit_code = code_str.parse().unwrap_or(-1);
1715            }
1716        }
1717
1718        // Find stdout section
1719        while i < lines.len() {
1720            if lines[i] == "--- STDOUT ---" {
1721                i += 1;
1722                while i < lines.len() && lines[i] != "--- STDERR ---" {
1723                    if !stdout.is_empty() {
1724                        stdout.push('\n');
1725                    }
1726                    stdout.push_str(lines[i]);
1727                    i += 1;
1728                }
1729            } else if lines[i] == "--- STDERR ---" {
1730                i += 1;
1731                while i < lines.len() {
1732                    if !stderr.is_empty() {
1733                        stderr.push('\n');
1734                    }
1735                    stderr.push_str(lines[i]);
1736                    i += 1;
1737                }
1738            } else {
1739                i += 1;
1740            }
1741        }
1742
1743        (stdout, stderr, exit_code)
1744    } else {
1745        // Success case - output is just stdout
1746        (output.to_string(), String::new(), 0)
1747    }
1748}
1749
1750#[cfg(test)]
1751mod pattern_tests {
1752    use super::*;
1753
1754    #[test]
1755    fn test_matches_any_pattern_exact_match() {
1756        let patterns = vec!["git status".to_string(), "git log".to_string()];
1757
1758        assert!(matches_any_pattern("git status", &patterns));
1759        assert!(matches_any_pattern("git log", &patterns));
1760        assert!(!matches_any_pattern("git push", &patterns));
1761    }
1762
1763    #[test]
1764    fn test_matches_any_pattern_glob_patterns() {
1765        let patterns = vec![
1766            "git *".to_string(),
1767            "npm run*".to_string(),
1768            "cargo test*".to_string(),
1769        ];
1770
1771        // Test glob matching
1772        assert!(matches_any_pattern("git status", &patterns));
1773        assert!(matches_any_pattern("git log -10", &patterns));
1774        assert!(matches_any_pattern("npm run test", &patterns));
1775        assert!(matches_any_pattern("npm run build", &patterns));
1776        assert!(matches_any_pattern("cargo test", &patterns));
1777        assert!(matches_any_pattern("cargo test --all", &patterns));
1778
1779        // Test non-matches
1780        assert!(!matches_any_pattern("npm install", &patterns));
1781        assert!(!matches_any_pattern("cargo build", &patterns));
1782        assert!(!matches_any_pattern("ls -la", &patterns));
1783    }
1784
1785    #[test]
1786    fn test_matches_any_pattern_complex_patterns() {
1787        let patterns = vec![
1788            "docker run*".to_string(),
1789            "kubectl apply -f*".to_string(),
1790            "terraform *".to_string(),
1791        ];
1792
1793        // Test glob matches
1794        assert!(matches_any_pattern("docker run nginx", &patterns));
1795        assert!(matches_any_pattern("docker run -it ubuntu bash", &patterns));
1796        assert!(matches_any_pattern(
1797            "kubectl apply -f deployment.yaml",
1798            &patterns
1799        ));
1800        assert!(matches_any_pattern("terraform plan", &patterns));
1801        assert!(matches_any_pattern("terraform apply", &patterns));
1802
1803        // Test non-matches
1804        assert!(!matches_any_pattern("docker ps", &patterns));
1805        assert!(!matches_any_pattern("kubectl get pods", &patterns));
1806        assert!(!matches_any_pattern("git status", &patterns));
1807    }
1808
1809    #[test]
1810    fn test_matches_any_pattern_empty_patterns() {
1811        let patterns: Vec<String> = vec![];
1812        assert!(!matches_any_pattern("git status", &patterns));
1813    }
1814
1815    #[test]
1816    fn test_matches_any_pattern_special_chars() {
1817        let patterns = vec![
1818            "echo \"hello world\"".to_string(),
1819            "python -c 'print(\"test\")'".to_string(),
1820            "ls | grep .txt".to_string(),
1821        ];
1822
1823        // Test exact matches with special characters
1824        assert!(matches_any_pattern("echo \"hello world\"", &patterns));
1825        assert!(matches_any_pattern(
1826            "python -c 'print(\"test\")'",
1827            &patterns
1828        ));
1829        assert!(matches_any_pattern("ls | grep .txt", &patterns));
1830
1831        // Test non-matches
1832        assert!(!matches_any_pattern("echo hello world", &patterns));
1833    }
1834}