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