feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
276
crates/core/agent/tests/streaming.rs
Normal file
276
crates/core/agent/tests/streaming.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use agent_core::{create_event_channel, run_agent_loop_streaming, AgentEvent, ToolContext};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::stream;
|
||||
use llm_core::{
|
||||
ChatMessage, ChatOptions, LlmError, StreamChunk, LlmProvider, Tool, ToolCallDelta,
|
||||
};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Mock LLM provider for testing streaming
|
||||
struct MockStreamingProvider {
|
||||
responses: Vec<MockResponse>,
|
||||
}
|
||||
|
||||
enum MockResponse {
|
||||
/// Text-only response (no tool calls)
|
||||
Text(Vec<String>), // Chunks of text
|
||||
/// Tool call response
|
||||
ToolCall {
|
||||
text_chunks: Vec<String>,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
tool_args: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockStreamingProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_options: &ChatOptions,
|
||||
_tools: Option<&[Tool]>,
|
||||
) -> Result<Pin<Box<dyn futures_util::Stream<Item = Result<StreamChunk, LlmError>> + Send>>, LlmError> {
|
||||
// Determine which response to use based on message count
|
||||
let response_idx = (messages.len() / 2).min(self.responses.len() - 1);
|
||||
let response = &self.responses[response_idx];
|
||||
|
||||
let chunks: Vec<Result<StreamChunk, LlmError>> = match response {
|
||||
MockResponse::Text(text_chunks) => text_chunks
|
||||
.iter()
|
||||
.map(|text| {
|
||||
Ok(StreamChunk {
|
||||
content: Some(text.clone()),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
MockResponse::ToolCall {
|
||||
text_chunks,
|
||||
tool_id,
|
||||
tool_name,
|
||||
tool_args,
|
||||
} => {
|
||||
let mut result = vec![];
|
||||
|
||||
// First emit text chunks
|
||||
for text in text_chunks {
|
||||
result.push(Ok(StreamChunk {
|
||||
content: Some(text.clone()),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Then emit tool call in chunks
|
||||
result.push(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: 0,
|
||||
id: Some(tool_id.clone()),
|
||||
function_name: Some(tool_name.clone()),
|
||||
arguments_delta: None,
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
|
||||
// Emit args in chunks
|
||||
for chunk in tool_args.chars().collect::<Vec<_>>().chunks(5) {
|
||||
result.push(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: 0,
|
||||
id: None,
|
||||
function_name: None,
|
||||
arguments_delta: Some(chunk.iter().collect()),
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Box::pin(stream::iter(chunks)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_text_only() {
|
||||
let provider = MockStreamingProvider {
|
||||
responses: vec![MockResponse::Text(vec![
|
||||
"Hello".to_string(),
|
||||
" ".to_string(),
|
||||
"world".to_string(),
|
||||
"!".to_string(),
|
||||
])],
|
||||
};
|
||||
|
||||
let perms = PermissionManager::new(Mode::Plan);
|
||||
let ctx = ToolContext::default();
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Spawn the agent loop
|
||||
let handle = tokio::spawn(async move {
|
||||
run_agent_loop_streaming(
|
||||
&provider,
|
||||
"Say hello",
|
||||
&ChatOptions::default(),
|
||||
&perms,
|
||||
&ctx,
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// Collect events
|
||||
let mut text_deltas = vec![];
|
||||
let mut done_response = None;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => {
|
||||
text_deltas.push(text);
|
||||
}
|
||||
AgentEvent::Done { final_response } => {
|
||||
done_response = Some(final_response);
|
||||
break;
|
||||
}
|
||||
AgentEvent::Error(e) => {
|
||||
panic!("Unexpected error: {}", e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for agent loop to complete
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify events
|
||||
assert_eq!(text_deltas, vec!["Hello", " ", "world", "!"]);
|
||||
assert_eq!(done_response, Some("Hello world!".to_string()));
|
||||
assert_eq!(result.unwrap(), "Hello world!");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_with_tool_call() {
|
||||
let provider = MockStreamingProvider {
|
||||
responses: vec![
|
||||
MockResponse::ToolCall {
|
||||
text_chunks: vec!["Let me ".to_string(), "check...".to_string()],
|
||||
tool_id: "call_123".to_string(),
|
||||
tool_name: "glob".to_string(),
|
||||
tool_args: r#"{"pattern":"*.rs"}"#.to_string(),
|
||||
},
|
||||
MockResponse::Text(vec!["Found ".to_string(), "the files!".to_string()]),
|
||||
],
|
||||
};
|
||||
|
||||
let perms = PermissionManager::new(Mode::Plan);
|
||||
let ctx = ToolContext::default();
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Spawn the agent loop
|
||||
let handle = tokio::spawn(async move {
|
||||
run_agent_loop_streaming(
|
||||
&provider,
|
||||
"Find Rust files",
|
||||
&ChatOptions::default(),
|
||||
&perms,
|
||||
&ctx,
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// Collect events
|
||||
let mut text_deltas = vec![];
|
||||
let mut tool_starts = vec![];
|
||||
let mut tool_outputs = vec![];
|
||||
let mut tool_ends = vec![];
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => {
|
||||
text_deltas.push(text);
|
||||
}
|
||||
AgentEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
} => {
|
||||
tool_starts.push((tool_name, tool_id));
|
||||
}
|
||||
AgentEvent::ToolOutput {
|
||||
tool_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
tool_outputs.push((tool_id, content, is_error));
|
||||
}
|
||||
AgentEvent::ToolEnd { tool_id, success } => {
|
||||
tool_ends.push((tool_id, success));
|
||||
}
|
||||
AgentEvent::Done { .. } => {
|
||||
break;
|
||||
}
|
||||
AgentEvent::Error(e) => {
|
||||
panic!("Unexpected error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for agent loop to complete
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify we got text deltas from both responses
|
||||
assert!(text_deltas.contains(&"Let me ".to_string()));
|
||||
assert!(text_deltas.contains(&"check...".to_string()));
|
||||
assert!(text_deltas.contains(&"Found ".to_string()));
|
||||
assert!(text_deltas.contains(&"the files!".to_string()));
|
||||
|
||||
// Verify tool events
|
||||
assert_eq!(tool_starts.len(), 1);
|
||||
assert_eq!(tool_starts[0].0, "glob");
|
||||
assert_eq!(tool_starts[0].1, "call_123");
|
||||
|
||||
assert_eq!(tool_outputs.len(), 1);
|
||||
assert_eq!(tool_outputs[0].0, "call_123");
|
||||
assert!(!tool_outputs[0].2); // not an error
|
||||
|
||||
assert_eq!(tool_ends.len(), 1);
|
||||
assert_eq!(tool_ends[0].0, "call_123");
|
||||
assert!(tool_ends[0].1); // success
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_creation() {
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Test that channel works
|
||||
tx.send(AgentEvent::TextDelta("test".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => assert_eq!(text, "test"),
|
||||
_ => panic!("Wrong event type"),
|
||||
}
|
||||
}
|
||||
114
crates/core/agent/tests/tool_context.rs
Normal file
114
crates/core/agent/tests/tool_context.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
// Test that ToolContext properly wires up the placeholder tools
|
||||
use agent_core::{ToolContext, execute_tool};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use tools_todo::{TodoList, TodoStatus};
|
||||
use tools_bash::ShellManager;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_write_with_context() {
|
||||
let todo_list = TodoList::new();
|
||||
let ctx = ToolContext::new().with_todo_list(todo_list.clone());
|
||||
let perms = PermissionManager::new(Mode::Code); // Allow all tools
|
||||
|
||||
let arguments = json!({
|
||||
"todos": [
|
||||
{
|
||||
"content": "First task",
|
||||
"status": "pending",
|
||||
"active_form": "Working on first task"
|
||||
},
|
||||
{
|
||||
"content": "Second task",
|
||||
"status": "in_progress",
|
||||
"active_form": "Working on second task"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = execute_tool("todo_write", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "TodoWrite should succeed: {:?}", result);
|
||||
|
||||
// Verify the todos were written
|
||||
let todos = todo_list.read();
|
||||
assert_eq!(todos.len(), 2);
|
||||
assert_eq!(todos[0].content, "First task");
|
||||
assert_eq!(todos[1].status, TodoStatus::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_write_without_context() {
|
||||
let ctx = ToolContext::new(); // No todo_list
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"todos": []
|
||||
});
|
||||
|
||||
let result = execute_tool("todo_write", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "TodoWrite should fail without TodoList");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bash_output_with_context() {
|
||||
let manager = ShellManager::new();
|
||||
let ctx = ToolContext::new().with_shell_manager(manager.clone());
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Start a shell and run a command
|
||||
let shell_id = manager.start_shell().await.unwrap();
|
||||
let _ = manager.execute(&shell_id, "echo test", None).await.unwrap();
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": shell_id
|
||||
});
|
||||
|
||||
let result = execute_tool("bash_output", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "BashOutput should succeed: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bash_output_without_context() {
|
||||
let ctx = ToolContext::new(); // No shell_manager
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": "fake-id"
|
||||
});
|
||||
|
||||
let result = execute_tool("bash_output", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "BashOutput should fail without ShellManager");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_kill_shell_with_context() {
|
||||
let manager = ShellManager::new();
|
||||
let ctx = ToolContext::new().with_shell_manager(manager.clone());
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Start a shell
|
||||
let shell_id = manager.start_shell().await.unwrap();
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": shell_id
|
||||
});
|
||||
|
||||
let result = execute_tool("kill_shell", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "KillShell should succeed: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_user_without_context() {
|
||||
let ctx = ToolContext::new(); // No ask_sender
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"questions": []
|
||||
});
|
||||
|
||||
let result = execute_tool("ask_user", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "AskUser should fail without AskSender");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
Reference in New Issue
Block a user