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