test(core+tui): end-to-end agent tool scenarios
This commit is contained in:
310
crates/owlen-core/tests/agent_tool_flow.rs
Normal file
310
crates/owlen-core/tests/agent_tool_flow.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::{any::Any, collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use owlen_core::{
|
||||
Config, Error, Mode, Provider,
|
||||
config::McpMode,
|
||||
consent::ConsentScope,
|
||||
mcp::{
|
||||
McpClient, McpToolCall, McpToolDescriptor, McpToolResponse,
|
||||
failover::{FailoverMcpClient, ServerEntry},
|
||||
},
|
||||
session::{ControllerEvent, SessionController, SessionOutcome},
|
||||
storage::StorageManager,
|
||||
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall},
|
||||
ui::NoOpUiController,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
struct StreamingToolProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StreamingToolProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock-streaming-provider"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> owlen_core::Result<Vec<ModelInfo>> {
|
||||
Ok(vec![ModelInfo {
|
||||
id: "mock-model".into(),
|
||||
name: "Mock Model".into(),
|
||||
description: Some("A mock model that emits tool calls".into()),
|
||||
provider: self.name().into(),
|
||||
context_window: Some(4096),
|
||||
capabilities: vec!["chat".into(), "tools".into()],
|
||||
supports_tools: true,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(&self, _request: ChatRequest) -> owlen_core::Result<ChatResponse> {
|
||||
let mut message = Message::assistant("tool-call".to_string());
|
||||
message.tool_calls = Some(vec![ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "resources/write".to_string(),
|
||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
||||
}]);
|
||||
|
||||
Ok(ChatResponse {
|
||||
message,
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_prompt(
|
||||
&self,
|
||||
_request: ChatRequest,
|
||||
) -> owlen_core::Result<owlen_core::ChatStream> {
|
||||
let mut first_chunk = Message::assistant(
|
||||
"Thought: need to update README.\nAction: resources/write".to_string(),
|
||||
);
|
||||
first_chunk.tool_calls = Some(vec![ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "resources/write".to_string(),
|
||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
||||
}]);
|
||||
|
||||
let chunk = ChatResponse {
|
||||
message: first_chunk,
|
||||
usage: None,
|
||||
is_streaming: true,
|
||||
is_final: false,
|
||||
};
|
||||
|
||||
Ok(Box::pin(futures::stream::iter(vec![Ok(chunk)])))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> owlen_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_descriptor() -> McpToolDescriptor {
|
||||
McpToolDescriptor {
|
||||
name: "web_search".to_string(),
|
||||
description: "search".to_string(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
requires_network: true,
|
||||
requires_filesystem: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
struct TimeoutClient;
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for TimeoutClient {
|
||||
async fn list_tools(&self) -> owlen_core::Result<Vec<McpToolDescriptor>> {
|
||||
Ok(vec![tool_descriptor()])
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result<McpToolResponse> {
|
||||
Err(Error::Network(
|
||||
"timeout while contacting remote web search endpoint".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedResponseClient {
|
||||
response: Arc<McpToolResponse>,
|
||||
}
|
||||
|
||||
impl CachedResponseClient {
|
||||
fn new() -> Self {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("source".to_string(), "cache".to_string());
|
||||
metadata.insert("cached".to_string(), "true".to_string());
|
||||
|
||||
let response = McpToolResponse {
|
||||
name: "web_search".to_string(),
|
||||
success: true,
|
||||
output: serde_json::json!({
|
||||
"query": "rust",
|
||||
"results": [
|
||||
{"title": "Rust Programming Language", "url": "https://www.rust-lang.org"}
|
||||
],
|
||||
"note": "cached result"
|
||||
}),
|
||||
metadata,
|
||||
duration_ms: 0,
|
||||
};
|
||||
|
||||
Self {
|
||||
response: Arc::new(response),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for CachedResponseClient {
|
||||
async fn list_tools(&self) -> owlen_core::Result<Vec<McpToolDescriptor>> {
|
||||
Ok(vec![tool_descriptor()])
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result<McpToolResponse> {
|
||||
Ok((*self.response).clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn streaming_file_write_consent_denied_returns_resolution() {
|
||||
let temp_dir = tempdir().expect("temp dir");
|
||||
let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db"))
|
||||
.await
|
||||
.expect("storage");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.general.enable_streaming = true;
|
||||
config.privacy.encrypt_local_data = false;
|
||||
config.privacy.require_consent_per_session = true;
|
||||
config.general.default_model = Some("mock-model".into());
|
||||
config.mcp.mode = McpMode::LocalOnly;
|
||||
config
|
||||
.refresh_mcp_servers(None)
|
||||
.expect("refresh MCP servers");
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(StreamingToolProvider);
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||
|
||||
let mut session = SessionController::new(
|
||||
provider,
|
||||
config,
|
||||
Arc::new(storage),
|
||||
ui,
|
||||
true,
|
||||
Some(event_tx),
|
||||
)
|
||||
.await
|
||||
.expect("session controller");
|
||||
|
||||
session
|
||||
.set_operating_mode(Mode::Code)
|
||||
.await
|
||||
.expect("code mode");
|
||||
|
||||
let outcome = session
|
||||
.send_message(
|
||||
"Please write to README".to_string(),
|
||||
ChatParameters {
|
||||
stream: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("send message");
|
||||
|
||||
let (response_id, mut stream) = if let SessionOutcome::Streaming {
|
||||
response_id,
|
||||
stream,
|
||||
} = outcome
|
||||
{
|
||||
(response_id, stream)
|
||||
} else {
|
||||
panic!("expected streaming outcome");
|
||||
};
|
||||
|
||||
session
|
||||
.mark_stream_placeholder(response_id, "▌")
|
||||
.expect("placeholder");
|
||||
|
||||
let chunk = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("stream chunk")
|
||||
.expect("chunk result");
|
||||
session
|
||||
.apply_stream_chunk(response_id, &chunk)
|
||||
.expect("apply chunk");
|
||||
|
||||
let tool_calls = session
|
||||
.check_streaming_tool_calls(response_id)
|
||||
.expect("tool calls");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "resources/write");
|
||||
|
||||
let event = event_rx.recv().await.expect("controller event");
|
||||
let request_id = match event {
|
||||
ControllerEvent::ToolRequested {
|
||||
request_id,
|
||||
tool_name,
|
||||
data_types,
|
||||
endpoints,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_name, "resources/write");
|
||||
assert!(data_types.iter().any(|t| t.contains("file")));
|
||||
assert!(endpoints.iter().any(|e| e.contains("filesystem")));
|
||||
request_id
|
||||
}
|
||||
};
|
||||
|
||||
let resolution = session
|
||||
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
||||
.expect("resolution");
|
||||
assert_eq!(resolution.scope, ConsentScope::Denied);
|
||||
assert_eq!(resolution.tool_name, "resources/write");
|
||||
assert_eq!(resolution.tool_calls.len(), tool_calls.len());
|
||||
|
||||
let err = session
|
||||
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
||||
.expect_err("second resolution should fail");
|
||||
matches!(err, Error::InvalidInput(_));
|
||||
|
||||
let conversation = session.conversation().clone();
|
||||
let assistant = conversation
|
||||
.messages
|
||||
.iter()
|
||||
.find(|message| message.role == Role::Assistant)
|
||||
.expect("assistant message present");
|
||||
assert!(
|
||||
assistant
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.and_then(|calls| calls.first())
|
||||
.is_some_and(|call| call.name == "resources/write"),
|
||||
"stream chunk should capture the tool call on the assistant message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_tool_timeout_fails_over_to_cached_result() {
|
||||
let primary: Arc<dyn McpClient> = Arc::new(TimeoutClient);
|
||||
let cached = CachedResponseClient::new();
|
||||
let backup: Arc<dyn McpClient> = Arc::new(cached.clone());
|
||||
|
||||
let client = FailoverMcpClient::with_servers(vec![
|
||||
ServerEntry::new("primary".into(), primary, 1),
|
||||
ServerEntry::new("cache".into(), backup, 2),
|
||||
]);
|
||||
|
||||
let call = McpToolCall {
|
||||
name: "web_search".to_string(),
|
||||
arguments: serde_json::json!({ "query": "rust", "max_results": 3 }),
|
||||
};
|
||||
|
||||
let response = client.call_tool(call.clone()).await.expect("fallback");
|
||||
|
||||
assert_eq!(response.name, "web_search");
|
||||
assert_eq!(
|
||||
response.metadata.get("source").map(String::as_str),
|
||||
Some("cache")
|
||||
);
|
||||
assert_eq!(
|
||||
response.output.get("note").and_then(|value| value.as_str()),
|
||||
Some("cached result")
|
||||
);
|
||||
|
||||
let statuses = client.get_server_status().await;
|
||||
assert!(statuses.iter().any(|(name, health)| name == "primary"
|
||||
&& !matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy)));
|
||||
assert!(statuses.iter().any(|(name, health)| name == "cache"
|
||||
&& matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy)));
|
||||
}
|
||||
164
crates/owlen-tui/tests/agent_flow_ui.rs
Normal file
164
crates/owlen-tui/tests/agent_flow_ui.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
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<Vec<owlen_core::types::ModelInfo>> {
|
||||
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<ChatResponse> {
|
||||
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<owlen_core::ChatStream> {
|
||||
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<dyn Provider> = Arc::new(StubProvider);
|
||||
let ui: Arc<dyn UiController> = 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user