diff --git a/crates/owlen-cli/src/bootstrap.rs b/crates/owlen-cli/src/bootstrap.rs index 6cef493..7282bcf 100644 --- a/crates/owlen-cli/src/bootstrap.rs +++ b/crates/owlen-cli/src/bootstrap.rs @@ -17,7 +17,7 @@ use owlen_core::{ mode::Mode, provider::ProviderManager, providers::OllamaProvider, - session::SessionController, + session::{ControllerEvent, SessionController}, storage::StorageManager, types::{ChatRequest, ChatResponse, Message, ModelInfo}, }; @@ -88,11 +88,19 @@ pub async fn launch(initial_mode: Mode) -> Result<()> { } }; - let controller = - SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?; + let (controller_event_tx, controller_event_rx) = mpsc::unbounded_channel::(); + let controller = SessionController::new( + provider, + cfg, + storage.clone(), + tui_controller, + false, + Some(controller_event_tx), + ) + .await?; let provider_manager = Arc::new(ProviderManager::default()); let mut runtime = RuntimeApp::new(provider_manager); - let (mut app, mut session_rx) = ChatApp::new(controller).await?; + let (mut app, mut session_rx) = ChatApp::new(controller, controller_event_rx).await?; app.initialize_models().await?; if let Some(notice) = offline_notice.clone() { app.set_status_message(¬ice); diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index b695a68..72fa624 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -1,5 +1,5 @@ use crate::config::{Config, McpResourceConfig, McpServerConfig}; -use crate::consent::ConsentManager; +use crate::consent::{ConsentManager, ConsentScope}; use crate::conversation::ConversationManager; use crate::credentials::CredentialManager; use crate::encryption::{self, VaultHandle}; @@ -34,6 +34,7 @@ use std::env; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; +use tokio::sync::mpsc::UnboundedSender; use uuid::Uuid; pub enum SessionOutcome { @@ -44,6 +45,36 @@ pub enum SessionOutcome { }, } +#[derive(Debug, Clone)] +pub enum ControllerEvent { + ToolRequested { + request_id: Uuid, + message_id: Uuid, + tool_name: String, + data_types: Vec, + endpoints: Vec, + tool_calls: Vec, + }, +} + +#[derive(Clone, Debug)] +struct PendingToolRequest { + message_id: Uuid, + tool_name: String, + data_types: Vec, + endpoints: Vec, + tool_calls: Vec, +} + +#[derive(Debug, Clone)] +pub struct ToolConsentResolution { + pub request_id: Uuid, + pub message_id: Uuid, + pub tool_name: String, + pub scope: ConsentScope, + pub tool_calls: Vec, +} + fn extract_resource_content(value: &Value) -> Option { match value { Value::Null => Some(String::new()), @@ -111,6 +142,8 @@ pub struct SessionController { enable_code_tools: bool, current_mode: Mode, missing_oauth_servers: Vec, + event_tx: Option>, + pending_tool_requests: HashMap, } async fn build_tools( @@ -331,6 +364,7 @@ impl SessionController { storage: Arc, ui: Arc, enable_code_tools: bool, + event_tx: Option>, ) -> Result { let config_arc = Arc::new(TokioMutex::new(config)); // Acquire the config asynchronously to avoid blocking the runtime. @@ -435,6 +469,8 @@ impl SessionController { enable_code_tools, current_mode: initial_mode, missing_oauth_servers, + event_tx, + pending_tool_requests: HashMap::new(), }) } @@ -1222,14 +1258,84 @@ impl SessionController { .append_stream_chunk(message_id, &chunk.message.content, chunk.is_final) } - pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option> { - self.conversation + pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option> { + let maybe_calls = self + .conversation .active() .messages .iter() .find(|m| m.id == message_id) .and_then(|m| m.tool_calls.clone()) - .filter(|calls| !calls.is_empty()) + .filter(|calls| !calls.is_empty()); + + let calls = maybe_calls?; + + if !self + .pending_tool_requests + .values() + .any(|pending| pending.message_id == message_id) + { + if let Some((tool_name, data_types, endpoints)) = + self.check_tools_consent_needed(&calls).into_iter().next() + { + let request_id = Uuid::new_v4(); + let pending = PendingToolRequest { + message_id, + tool_name: tool_name.clone(), + data_types: data_types.clone(), + endpoints: endpoints.clone(), + tool_calls: calls.clone(), + }; + self.pending_tool_requests.insert(request_id, pending); + + if let Some(tx) = &self.event_tx { + let _ = tx.send(ControllerEvent::ToolRequested { + request_id, + message_id, + tool_name, + data_types, + endpoints, + tool_calls: calls.clone(), + }); + } + } + } + + Some(calls) + } + + pub fn resolve_tool_consent( + &mut self, + request_id: Uuid, + scope: ConsentScope, + ) -> Result { + let pending = self + .pending_tool_requests + .remove(&request_id) + .ok_or_else(|| { + Error::InvalidInput(format!("Unknown tool consent request: {}", request_id)) + })?; + + let PendingToolRequest { + message_id, + tool_name, + data_types, + endpoints, + tool_calls, + .. + } = pending; + + if !matches!(scope, ConsentScope::Denied) { + self.grant_consent_with_scope(&tool_name, data_types, endpoints, scope.clone()); + } + + Ok(ToolConsentResolution { + request_id, + message_id, + tool_name, + scope, + tool_calls, + }) } pub fn cancel_stream(&mut self, message_id: Uuid, notice: &str) -> Result<()> { @@ -1352,7 +1458,7 @@ mod tests { let provider: Arc = Arc::new(MockProvider::default()) as Arc; let ui = Arc::new(NoOpUiController); - let session = SessionController::new(provider, config, storage, ui, false) + let session = SessionController::new(provider, config, storage, ui, false, None) .await .expect("session"); diff --git a/crates/owlen-tui/src/app/mod.rs b/crates/owlen-tui/src/app/mod.rs index a46376d..b17d7f3 100644 --- a/crates/owlen-tui/src/app/mod.rs +++ b/crates/owlen-tui/src/app/mod.rs @@ -34,6 +34,7 @@ pub trait UiRuntime: MessageState { async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>; async fn process_pending_llm_request(&mut self) -> Result<()>; async fn process_pending_tool_execution(&mut self) -> Result<()>; + fn poll_controller_events(&mut self) -> Result<()>; fn advance_loading_animation(&mut self); fn streaming_count(&self) -> usize; } @@ -116,6 +117,7 @@ impl App { state.process_pending_llm_request().await?; state.process_pending_tool_execution().await?; + state.poll_controller_events()?; loop { match session_rx.try_recv() { diff --git a/crates/owlen-tui/src/app/mvu.rs b/crates/owlen-tui/src/app/mvu.rs index d11b9fb..277bce9 100644 --- a/crates/owlen-tui/src/app/mvu.rs +++ b/crates/owlen-tui/src/app/mvu.rs @@ -1,4 +1,5 @@ -use owlen_core::ui::InputMode; +use owlen_core::{consent::ConsentScope, ui::InputMode}; +use uuid::Uuid; #[derive(Debug, Clone, Default)] pub struct AppModel { @@ -25,6 +26,10 @@ impl Default for ComposerModel { #[derive(Debug, Clone)] pub enum AppEvent { Composer(ComposerEvent), + ToolPermission { + request_id: Uuid, + scope: ConsentScope, + }, } #[derive(Debug, Clone)] @@ -46,11 +51,18 @@ pub enum SubmissionOutcome { pub enum AppEffect { SetStatus(String), RequestSubmit, + ResolveToolConsent { + request_id: Uuid, + scope: ConsentScope, + }, } pub fn update(model: &mut AppModel, event: AppEvent) -> Vec { match event { AppEvent::Composer(event) => update_composer(&mut model.composer, event), + AppEvent::ToolPermission { request_id, scope } => { + vec![AppEffect::ResolveToolConsent { request_id, scope }] + } } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 4533454..2b761bf 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -5,6 +5,7 @@ use crossterm::{ event::KeyEvent, terminal::{disable_raw_mode, enable_raw_mode}, }; +use owlen_core::consent::ConsentScope; use owlen_core::facade::llm_client::LlmClient; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; @@ -17,7 +18,7 @@ use owlen_core::{ config::McpResourceConfig, model::DetailedModelInfo, oauth::{DeviceAuthorization, DevicePollState}, - session::{SessionController, SessionOutcome}, + session::{ControllerEvent, SessionController, SessionOutcome, ToolConsentResolution}, storage::SessionMeta, theme::Theme, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, @@ -63,7 +64,7 @@ use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`. use std::collections::hash_map::DefaultHasher; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::env; use std::fs; use std::fs::OpenOptions; @@ -260,12 +261,6 @@ pub enum SessionEvent { message_id: Uuid, tool_calls: Vec, }, - ConsentNeeded { - tool_name: String, - data_types: Vec, - endpoints: Vec, - callback_id: Uuid, - }, /// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION) AgentUpdate { content: String }, /// Agent execution completed with final answer @@ -318,6 +313,7 @@ pub struct ChatApp { textarea: TextArea<'static>, // Advanced text input widget mvu_model: AppModel, keymap: Keymap, + controller_event_rx: mpsc::UnboundedReceiver, pending_llm_request: bool, // Flag to indicate LLM request needs to be processed pending_tool_execution: Option<(Uuid, Vec)>, // Pending tool execution (message_id, tool_calls) loading_animation_frame: usize, // Frame counter for loading animation @@ -362,6 +358,7 @@ pub struct ChatApp { available_themes: Vec, // Cached list of theme names selected_theme_index: usize, // Index of selected theme in browser pending_consent: Option, // Pending consent request + queued_consents: VecDeque, // Backlog of consent requests system_status: String, // System/status messages (tool execution, status, etc) toasts: ToastManager, /// Simple execution budget: maximum number of tool calls allowed per session. @@ -378,10 +375,12 @@ pub struct ChatApp { #[derive(Clone, Debug)] pub struct ConsentDialogState { + pub request_id: Uuid, pub tool_name: String, pub data_types: Vec, pub endpoints: Vec, - pub callback_id: Uuid, // ID to match callback with the request + pub message_id: Uuid, + pub tool_calls: Vec, } #[derive(Clone)] @@ -505,6 +504,7 @@ impl FileActionPrompt { impl ChatApp { pub async fn new( controller: SessionController, + controller_event_rx: mpsc::UnboundedReceiver, ) -> Result<(Self, mpsc::UnboundedReceiver)> { let (session_tx, session_rx) = mpsc::unbounded_channel(); let mut textarea = TextArea::default(); @@ -572,6 +572,7 @@ impl ChatApp { textarea, mvu_model: AppModel::default(), keymap, + controller_event_rx, pending_llm_request: false, pending_tool_execution: None, loading_animation_frame: 0, @@ -615,6 +616,7 @@ impl ChatApp { available_themes: Vec::new(), selected_theme_index: 0, pending_consent: None, + queued_consents: VecDeque::new(), system_status: if show_onboarding { ONBOARDING_SYSTEM_STATUS.to_string() } else { @@ -672,6 +674,89 @@ impl ChatApp { self.pending_consent.as_ref() } + fn enqueue_consent_request(&mut self, consent: ConsentDialogState) { + if self.pending_consent.is_none() { + self.pending_consent = Some(consent); + } else { + self.queued_consents.push_back(consent); + } + } + + fn advance_consent_queue(&mut self) { + if self.pending_consent.is_some() { + return; + } + if let Some(next) = self.queued_consents.pop_front() { + self.pending_consent = Some(next); + } + } + + fn handle_controller_event(&mut self, event: ControllerEvent) -> Result<()> { + match event { + ControllerEvent::ToolRequested { + request_id, + message_id, + tool_name, + data_types, + endpoints, + tool_calls, + } => { + self.enqueue_consent_request(ConsentDialogState { + request_id, + message_id, + tool_name, + data_types, + endpoints, + tool_calls, + }); + } + } + Ok(()) + } + + fn apply_tool_consent_resolution(&mut self, resolution: ToolConsentResolution) -> Result<()> { + let ToolConsentResolution { + message_id, + tool_name, + scope, + tool_calls, + .. + } = resolution; + + match scope { + ConsentScope::Denied => { + self.pending_tool_execution = None; + self.status = format!("✗ Consent denied for {}", tool_name); + self.set_system_status(format!("✗ Consent denied: {}", tool_name)); + self.error = Some(format!("Tool {} was blocked by user", tool_name)); + + self.controller + .conversation_mut() + .push_assistant_message(format!( + "I could not execute `{tool_name}` because consent was denied. \ + Replying without running the tool." + )); + self.notify_new_activity(); + } + ConsentScope::Once | ConsentScope::Session | ConsentScope::Permanent => { + let scope_label = match scope { + ConsentScope::Once => "once", + ConsentScope::Session => "session", + ConsentScope::Permanent => "permanent", + ConsentScope::Denied => unreachable!("handled above"), + }; + self.status = format!("✓ Consent granted ({scope_label}) for {}", tool_name); + self.set_system_status(format!("✓ Consent granted ({scope_label}): {}", tool_name)); + self.error = None; + self.pending_tool_execution = Some((message_id, tool_calls)); + } + } + + self.pending_consent = None; + self.advance_consent_queue(); + Ok(()) + } + pub fn status_message(&self) -> &str { &self.status } @@ -2854,6 +2939,10 @@ impl ChatApp { } } } + AppEffect::ResolveToolConsent { request_id, scope } => { + let resolution = self.controller.resolve_tool_consent(request_id, scope)?; + self.apply_tool_consent_resolution(resolution)?; + } } } @@ -4605,83 +4694,21 @@ impl ChatApp { // Handle consent dialog first (highest priority) if let Some(consent_state) = &self.pending_consent { - match key.code { - KeyCode::Char('1') => { - // Allow once - let tool_name = consent_state.tool_name.clone(); - let data_types = consent_state.data_types.clone(); - let endpoints = consent_state.endpoints.clone(); + let scope = match key.code { + KeyCode::Char('1') => Some(ConsentScope::Once), + KeyCode::Char('2') => Some(ConsentScope::Session), + KeyCode::Char('3') => Some(ConsentScope::Permanent), + KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied), + _ => None, + }; - self.controller.grant_consent_with_scope( - &tool_name, - data_types, - endpoints, - owlen_core::consent::ConsentScope::Once, - ); - self.pending_consent = None; - self.status = format!("✓ Consent granted (once) for {}", tool_name); - self.set_system_status(format!( - "✓ Consent granted (once): {}", - tool_name - )); - return Ok(AppState::Running); - } - KeyCode::Char('2') => { - // Allow session - let tool_name = consent_state.tool_name.clone(); - let data_types = consent_state.data_types.clone(); - let endpoints = consent_state.endpoints.clone(); - - self.controller.grant_consent_with_scope( - &tool_name, - data_types, - endpoints, - owlen_core::consent::ConsentScope::Session, - ); - self.pending_consent = None; - self.status = format!("✓ Consent granted (session) for {}", tool_name); - self.set_system_status(format!( - "✓ Consent granted (session): {}", - tool_name - )); - return Ok(AppState::Running); - } - KeyCode::Char('3') => { - // Allow always (permanent) - let tool_name = consent_state.tool_name.clone(); - let data_types = consent_state.data_types.clone(); - let endpoints = consent_state.endpoints.clone(); - - self.controller.grant_consent_with_scope( - &tool_name, - data_types, - endpoints, - owlen_core::consent::ConsentScope::Permanent, - ); - self.pending_consent = None; - self.status = - format!("✓ Consent granted (permanent) for {}", tool_name); - self.set_system_status(format!( - "✓ Consent granted (permanent): {}", - tool_name - )); - return Ok(AppState::Running); - } - KeyCode::Char('4') | KeyCode::Esc => { - // Deny consent - clear both consent and pending tool execution to prevent retry - let tool_name = consent_state.tool_name.clone(); - self.pending_consent = None; - self.pending_tool_execution = None; // Clear to prevent infinite retry - self.status = format!("✗ Consent denied for {}", tool_name); - self.set_system_status(format!("✗ Consent denied: {}", tool_name)); - self.error = Some(format!("Tool {} was blocked by user", tool_name)); - return Ok(AppState::Running); - } - _ => { - // Ignore other keys when consent dialog is shown - return Ok(AppState::Running); - } + if let Some(scope) = scope { + let request_id = consent_state.request_id; + let effects = + self.apply_app_event(AppEvent::ToolPermission { request_id, scope }); + self.handle_app_effects(effects).await?; } + return Ok(AppState::Running); } if self.try_execute_command(&key).await? { @@ -7539,21 +7566,6 @@ impl ChatApp { // Store tool execution for async processing on next event loop iteration self.pending_tool_execution = Some((message_id, tool_calls)); } - SessionEvent::ConsentNeeded { - tool_name, - data_types, - endpoints, - callback_id, - } => { - // Show consent dialog - self.pending_consent = Some(ConsentDialogState { - tool_name, - data_types, - endpoints, - callback_id, - }); - self.status = "Consent required - Press Y to allow, N to deny".to_string(); - } SessionEvent::AgentUpdate { content } => { // Update agent actions panel with latest ReAct iteration self.set_agent_actions(content); @@ -9088,6 +9100,7 @@ impl ChatApp { self.stop_loading_animation(); self.pending_tool_execution = None; self.pending_consent = None; + self.queued_consents.clear(); self.current_thinking = None; self.agent_actions = None; self.status = "Generation cancelled".to_string(); @@ -9105,6 +9118,7 @@ impl ChatApp { self.pending_llm_request = false; self.pending_tool_execution = None; self.pending_consent = None; + self.queued_consents.clear(); self.pending_key = None; self.visual_start = None; self.visual_end = None; @@ -9296,42 +9310,20 @@ impl ChatApp { return Ok(()); }; + // If a consent dialog is active, keep the execution queued until it resolves + if self.pending_consent.is_some() { + self.pending_tool_execution = Some((message_id, tool_calls)); + return Ok(()); + } + // Check if consent is needed for any of these tools let consent_needed = self.controller.check_tools_consent_needed(&tool_calls); if !consent_needed.is_empty() { - // If a consent dialog is already being shown, don't send another request - // Just re-queue the tool execution and wait for user response - if self.pending_consent.is_some() { - self.pending_tool_execution = Some((message_id, tool_calls)); - return Ok(()); - } - - // Show consent for the first tool that needs it - // After consent is granted, the next iteration will check remaining tools - if let Some((tool_name, data_types, endpoints)) = consent_needed.into_iter().next() { - let callback_id = Uuid::new_v4(); - let sender = self.session_tx.clone(); - let _ = sender.send(SessionEvent::ConsentNeeded { - tool_name: tool_name.clone(), - data_types: data_types.clone(), - endpoints: endpoints.clone(), - callback_id, - }); - self.pending_consent = Some(ConsentDialogState { - tool_name, - data_types, - endpoints, - callback_id, - }); - // Re-queue the tool execution for after consent is granted - self.pending_tool_execution = Some((message_id, tool_calls)); - return Ok(()); - } else { - // No consent entries found; treat as no-op and continue execution. - self.pending_tool_execution = Some((message_id, tool_calls)); - return Ok(()); - } + // Re-queue the execution and ensure a controller event is emitted + self.pending_tool_execution = Some((message_id, tool_calls)); + self.controller.check_streaming_tool_calls(message_id); + return Ok(()); } // Show tool execution status @@ -11253,9 +11245,25 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String { #[cfg(test)] mod tests { - use super::{render_markdown_lines, wrap_unicode}; + use super::{ChatApp, render_markdown_lines, wrap_unicode}; + use crate::app::UiRuntime; + use futures_util::{future, stream}; + use owlen_core::{ + Provider, Result as CoreResult, + config::Config, + consent::ConsentScope, + llm::LlmProvider, + session::{ControllerEvent, SessionController}, + storage::StorageManager, + types::{ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall}, + ui::NoOpUiController, + }; use ratatui::style::Style; use ratatui::text::Line; + use serde_json::json; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::sync::mpsc; fn lines_to_strings(lines: &[Line<'_>]) -> Vec { lines @@ -11328,6 +11336,139 @@ mod tests { let wrapped = wrap_unicode("hello", 0); assert!(wrapped.is_empty()); } + + struct StubProvider; + + impl LlmProvider for StubProvider { + type Stream = stream::Iter>>; + + type ListModelsFuture<'a> + = future::Ready>> + where + Self: 'a; + + type SendPromptFuture<'a> + = future::Ready> + where + Self: 'a; + + type StreamPromptFuture<'a> + = future::Ready> + where + Self: 'a; + + type HealthCheckFuture<'a> + = future::Ready> + where + Self: 'a; + + fn name(&self) -> &str { + "stub-provider" + } + + fn list_models(&self) -> Self::ListModelsFuture<'_> { + future::ready(Ok(vec![])) + } + + fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> { + let response = ChatResponse { + message: Message::assistant("stub response".to_string()), + usage: None, + is_streaming: false, + is_final: true, + }; + future::ready(Ok(response)) + } + + fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> { + let response = ChatResponse { + message: Message::assistant("stub response".to_string()), + usage: None, + is_streaming: false, + is_final: true, + }; + future::ready(Ok(stream::iter(vec![Ok(response)]))) + } + + fn health_check(&self) -> Self::HealthCheckFuture<'_> { + future::ready(Ok(())) + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn tool_consent_denied_generates_fallback_message() { + let temp_dir = tempdir().expect("tempdir"); + let storage_path = temp_dir.path().join("owlen-test.db"); + let storage = Arc::new( + StorageManager::with_database_path(storage_path) + .await + .expect("storage"), + ); + + let mut config = Config::default(); + config.privacy.encrypt_local_data = false; + let provider: Arc = Arc::new(StubProvider); + let ui = Arc::new(NoOpUiController); + let (event_tx, controller_event_rx) = mpsc::unbounded_channel::(); + + let session = SessionController::new(provider, config, storage, ui, false, Some(event_tx)) + .await + .expect("session"); + + let (mut app, session_rx) = ChatApp::new(session, controller_event_rx) + .await + .expect("chat app"); + // Session events are not needed for this test + drop(session_rx); + + let tool_call = ToolCall { + id: "call-1".to_string(), + name: "file_delete".to_string(), + arguments: json!({"path": "/tmp/example.txt"}), + }; + + let message_id = app + .controller + .conversation_mut() + .push_assistant_message("Preparing to modify files."); + app.controller + .conversation_mut() + .set_tool_calls_on_message(message_id, vec![tool_call.clone()]) + .expect("tool calls"); + + app.pending_tool_execution = Some((message_id, vec![tool_call.clone()])); + app.controller.check_streaming_tool_calls(message_id); + + UiRuntime::poll_controller_events(&mut app).expect("poll controller events"); + + let consent_state = app + .pending_consent + .as_ref() + .expect("pending consent") + .clone(); + + assert_eq!(consent_state.tool_name, "file_delete"); + + let resolution = app + .controller + .resolve_tool_consent(consent_state.request_id, ConsentScope::Denied) + .expect("resolution"); + + app.apply_tool_consent_resolution(resolution) + .expect("apply resolution"); + + assert!(app.pending_consent.is_none()); + assert!(app.pending_tool_execution.is_none()); + assert!(app.status.to_lowercase().contains("consent denied")); + + let conversation = app.controller.conversation(); + let last_message = conversation.messages.last().expect("last message"); + assert_eq!(last_message.role, Role::Assistant); + assert!( + last_message.content.contains("consent was denied"), + "fallback message should acknowledge denial" + ); + } } fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> { @@ -11397,6 +11538,17 @@ impl UiRuntime for ChatApp { ChatApp::process_pending_tool_execution(self).await } + fn poll_controller_events(&mut self) -> Result<()> { + loop { + match self.controller_event_rx.try_recv() { + Ok(event) => self.handle_controller_event(event)?, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + Ok(()) + } + fn advance_loading_animation(&mut self) { ChatApp::advance_loading_animation(self); } diff --git a/crates/owlen-tui/src/code_app.rs b/crates/owlen-tui/src/code_app.rs index 137026b..ebfac72 100644 --- a/crates/owlen-tui/src/code_app.rs +++ b/crates/owlen-tui/src/code_app.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use owlen_core::session::SessionController; +use owlen_core::session::{ControllerEvent, SessionController}; use owlen_core::ui::{AppState, InputMode}; use tokio::sync::mpsc; @@ -16,11 +16,12 @@ pub struct CodeApp { impl CodeApp { pub async fn new( mut controller: SessionController, + controller_event_rx: mpsc::UnboundedReceiver, ) -> Result<(Self, mpsc::UnboundedReceiver)> { controller .conversation_mut() .push_system_message(DEFAULT_SYSTEM_PROMPT.to_string()); - let (inner, rx) = ChatApp::new(controller).await?; + let (inner, rx) = ChatApp::new(controller, controller_event_rx).await?; Ok((Self { inner }, rx)) }