use std::{any::Any, sync::Arc}; use async_trait::async_trait; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use futures_util::stream; use owlen_core::{ Config, Mode, Provider, config::McpMode, session::SessionController, storage::StorageManager, types::{ChatResponse, Message, Role, ToolCall}, ui::{NoOpUiController, UiController}, }; use owlen_tui::ChatApp; use owlen_tui::app::UiRuntime; use owlen_tui::events::Event; use tempfile::tempdir; use tokio::sync::mpsc; struct StubProvider; #[async_trait] impl Provider for StubProvider { fn name(&self) -> &str { "stub-provider" } async fn list_models(&self) -> owlen_core::Result> { Ok(vec![owlen_core::types::ModelInfo { id: "stub-model".into(), name: "Stub Model".into(), description: Some("Stub model for testing".into()), provider: self.name().into(), context_window: Some(4096), capabilities: vec!["chat".into()], supports_tools: true, }]) } async fn send_prompt( &self, _request: owlen_core::types::ChatRequest, ) -> owlen_core::Result { Ok(ChatResponse { message: Message::assistant("stub response".to_string()), usage: None, is_streaming: false, is_final: true, }) } async fn stream_prompt( &self, _request: owlen_core::types::ChatRequest, ) -> owlen_core::Result { Ok(Box::pin(stream::empty())) } async fn health_check(&self) -> owlen_core::Result<()> { Ok(()) } fn as_any(&self) -> &(dyn Any + Send + Sync) { self } } #[tokio::test(flavor = "multi_thread")] async fn denied_consent_appends_apology_message() { let temp_dir = tempdir().expect("temp dir"); let storage = Arc::new( StorageManager::with_database_path(temp_dir.path().join("owlen-tui-tests.db")) .await .expect("storage"), ); let mut config = Config::default(); config.privacy.encrypt_local_data = false; config.general.default_model = Some("stub-model".into()); config.mcp.mode = McpMode::LocalOnly; config .refresh_mcp_servers(None) .expect("refresh MCP servers"); let provider: Arc = Arc::new(StubProvider); let ui: Arc = Arc::new(NoOpUiController); let (event_tx, controller_event_rx) = mpsc::unbounded_channel(); // Pre-populate a pending consent request before handing the controller to the TUI. let mut session = SessionController::new( Arc::clone(&provider), config, Arc::clone(&storage), Arc::clone(&ui), true, Some(event_tx.clone()), ) .await .expect("session controller"); session .set_operating_mode(Mode::Code) .await .expect("code mode"); let tool_call = ToolCall { id: "call-1".to_string(), name: "resources/delete".to_string(), arguments: serde_json::json!({"path": "/tmp/example.txt"}), }; let message_id = session .conversation_mut() .push_assistant_message("Preparing to modify files."); session .conversation_mut() .set_tool_calls_on_message(message_id, vec![tool_call]) .expect("tool calls"); let advertised_calls = session .check_streaming_tool_calls(message_id) .expect("queued consent"); assert_eq!(advertised_calls.len(), 1); let (mut app, mut session_rx) = ChatApp::new(session, controller_event_rx) .await .expect("chat app"); // Session events are not used in this test. session_rx.close(); // Process the controller event emitted by check_streaming_tool_calls. UiRuntime::poll_controller_events(&mut app).expect("poll controller events"); assert!(app.has_pending_consent()); let consent_state = app .consent_dialog() .expect("consent dialog should be visible") .clone(); assert_eq!(consent_state.tool_name, "resources/delete"); // Simulate the user pressing "4" to deny consent. let deny_key = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); UiRuntime::handle_ui_event(&mut app, Event::Key(deny_key)) .await .expect("handle deny key"); assert!(!app.has_pending_consent()); assert!( app.status_message() .to_lowercase() .contains("consent denied") ); let conversation = app.conversation(); let last_message = conversation.messages.last().expect("last message"); assert_eq!(last_message.role, Role::Assistant); assert!( last_message .content .to_lowercase() .contains("consent was denied"), "assistant should acknowledge the denied consent" ); }