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