feat(agent): event-driven tool consent handshake (explicit UI prompts)

This commit is contained in:
2025-10-17 03:42:13 +02:00
parent 5553e61dbf
commit 3f92b7d963
6 changed files with 425 additions and 144 deletions

View File

@@ -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::<ControllerEvent>();
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(&notice);

View File

@@ -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<String>,
endpoints: Vec<String>,
tool_calls: Vec<ToolCall>,
},
}
#[derive(Clone, Debug)]
struct PendingToolRequest {
message_id: Uuid,
tool_name: String,
data_types: Vec<String>,
endpoints: Vec<String>,
tool_calls: Vec<ToolCall>,
}
#[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<ToolCall>,
}
fn extract_resource_content(value: &Value) -> Option<String> {
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<String>,
event_tx: Option<UnboundedSender<ControllerEvent>>,
pending_tool_requests: HashMap<Uuid, PendingToolRequest>,
}
async fn build_tools(
@@ -331,6 +364,7 @@ impl SessionController {
storage: Arc<StorageManager>,
ui: Arc<dyn UiController>,
enable_code_tools: bool,
event_tx: Option<UnboundedSender<ControllerEvent>>,
) -> Result<Self> {
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<Vec<ToolCall>> {
self.conversation
pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option<Vec<ToolCall>> {
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<ToolConsentResolution> {
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<dyn Provider> = Arc::new(MockProvider::default()) as Arc<dyn Provider>;
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");

View File

@@ -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() {

View File

@@ -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<AppEffect> {
match event {
AppEvent::Composer(event) => update_composer(&mut model.composer, event),
AppEvent::ToolPermission { request_id, scope } => {
vec![AppEffect::ResolveToolConsent { request_id, scope }]
}
}
}

View File

@@ -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<owlen_core::types::ToolCall>,
},
ConsentNeeded {
tool_name: String,
data_types: Vec<String>,
endpoints: Vec<String>,
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<ControllerEvent>,
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // 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<String>, // Cached list of theme names
selected_theme_index: usize, // Index of selected theme in browser
pending_consent: Option<ConsentDialogState>, // Pending consent request
queued_consents: VecDeque<ConsentDialogState>, // 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<String>,
pub endpoints: Vec<String>,
pub callback_id: Uuid, // ID to match callback with the request
pub message_id: Uuid,
pub tool_calls: Vec<owlen_core::types::ToolCall>,
}
#[derive(Clone)]
@@ -505,6 +504,7 @@ impl FileActionPrompt {
impl ChatApp {
pub async fn new(
controller: SessionController,
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
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<String> {
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<std::vec::IntoIter<CoreResult<ChatResponse>>>;
type ListModelsFuture<'a>
= future::Ready<CoreResult<Vec<ModelInfo>>>
where
Self: 'a;
type SendPromptFuture<'a>
= future::Ready<CoreResult<ChatResponse>>
where
Self: 'a;
type StreamPromptFuture<'a>
= future::Ready<CoreResult<Self::Stream>>
where
Self: 'a;
type HealthCheckFuture<'a>
= future::Ready<CoreResult<()>>
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<dyn Provider> = Arc::new(StubProvider);
let ui = Arc::new(NoOpUiController);
let (event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
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);
}

View File

@@ -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<ControllerEvent>,
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
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))
}