steer_core/app/
mod.rs

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