- gitea: create_branch, delete_branch, list verification on studio/sol
5776 lines
239 KiB
Rust
5776 lines
239 KiB
Rust
//! End-to-end integration tests against the real Mistral API.
|
|
//!
|
|
//! Requires SOL_MISTRAL_API_KEY in .env file.
|
|
//! Run: cargo test integration_test -- --test-threads=1
|
|
|
|
#![cfg(test)]
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crate::config::Config;
|
|
use crate::conversations::ConversationRegistry;
|
|
use crate::orchestrator::event::*;
|
|
use crate::orchestrator::Orchestrator;
|
|
use crate::persistence::Store;
|
|
use crate::tools::ToolRegistry;
|
|
|
|
// ── Test harness ────────────────────────────────────────────────────────
|
|
|
|
struct TestHarness {
|
|
orchestrator: Arc<Orchestrator>,
|
|
event_rx: tokio::sync::broadcast::Receiver<OrchestratorEvent>,
|
|
}
|
|
|
|
impl TestHarness {
|
|
async fn new() -> Self {
|
|
// Load .env from project root
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
std::env::set_var(key.trim(), value.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
let api_key = std::env::var("SOL_MISTRAL_API_KEY")
|
|
.expect("SOL_MISTRAL_API_KEY must be set in .env");
|
|
|
|
let config = test_config();
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None)
|
|
.expect("Failed to create Mistral client"),
|
|
);
|
|
let store = Arc::new(Store::open_memory().expect("Failed to create in-memory store"));
|
|
let conversations = Arc::new(ConversationRegistry::new(
|
|
config.agents.orchestrator_model.clone(),
|
|
config.agents.compaction_threshold,
|
|
store,
|
|
));
|
|
|
|
let tools = Arc::new(ToolRegistry::new_minimal(config.clone()));
|
|
|
|
let orchestrator = Arc::new(Orchestrator::new(
|
|
config,
|
|
tools,
|
|
mistral,
|
|
conversations,
|
|
"you are sol. respond briefly and concisely. lowercase only.".into(),
|
|
));
|
|
let event_rx = orchestrator.subscribe();
|
|
|
|
Self { orchestrator, event_rx }
|
|
}
|
|
|
|
async fn collect_events_for(
|
|
&mut self,
|
|
request_id: &RequestId,
|
|
timeout_secs: u64,
|
|
) -> Vec<OrchestratorEvent> {
|
|
let mut events = Vec::new();
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
|
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, self.event_rx.recv()).await {
|
|
Ok(Ok(event)) => {
|
|
if event.request_id() != request_id {
|
|
continue;
|
|
}
|
|
let is_terminal = matches!(
|
|
event,
|
|
OrchestratorEvent::Done { .. } | OrchestratorEvent::Failed { .. }
|
|
);
|
|
events.push(event);
|
|
if is_terminal {
|
|
break;
|
|
}
|
|
}
|
|
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
|
|
Ok(Err(_)) => break,
|
|
Err(_) => panic!("Timeout after {timeout_secs}s waiting for events"),
|
|
}
|
|
}
|
|
|
|
events
|
|
}
|
|
}
|
|
|
|
fn test_config() -> Arc<Config> {
|
|
let toml = r#"
|
|
[matrix]
|
|
homeserver_url = "http://localhost:8008"
|
|
user_id = "@test:localhost"
|
|
state_store_path = "/tmp/sol-test-state"
|
|
db_path = ":memory:"
|
|
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "sol_test"
|
|
|
|
[mistral]
|
|
default_model = "mistral-medium-latest"
|
|
max_tool_iterations = 10
|
|
|
|
[behavior]
|
|
instant_responses = true
|
|
memory_extraction_enabled = false
|
|
|
|
[agents]
|
|
orchestrator_model = "mistral-medium-latest"
|
|
use_conversations_api = true
|
|
agent_prefix = "test"
|
|
|
|
[grpc]
|
|
listen_addr = "0.0.0.0:0"
|
|
dev_mode = true
|
|
"#;
|
|
Arc::new(Config::from_str(toml).expect("Failed to parse test config"))
|
|
}
|
|
|
|
fn make_request(text: &str) -> GenerateRequest {
|
|
GenerateRequest {
|
|
request_id: RequestId::new(),
|
|
text: text.into(),
|
|
user_id: "test-user".into(),
|
|
display_name: None,
|
|
conversation_key: format!("test-{}", uuid::Uuid::new_v4()),
|
|
is_direct: true,
|
|
image: None,
|
|
metadata: Metadata::new(),
|
|
}
|
|
}
|
|
|
|
fn make_request_with_key(text: &str, conversation_key: &str) -> GenerateRequest {
|
|
GenerateRequest {
|
|
request_id: RequestId::new(),
|
|
text: text.into(),
|
|
user_id: "test-user".into(),
|
|
display_name: None,
|
|
conversation_key: conversation_key.into(),
|
|
is_direct: true,
|
|
image: None,
|
|
metadata: Metadata::new(),
|
|
}
|
|
}
|
|
|
|
// ── Test 1: Simple chat round-trip ──────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_chat_roundtrip() {
|
|
let mut h = TestHarness::new().await;
|
|
let request = make_request("what is 2+2? answer with just the number.");
|
|
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let gen = tokio::spawn(async move { orch.generate(&request).await });
|
|
|
|
let events = h.collect_events_for(&rid, 30).await;
|
|
let result = gen.await.unwrap();
|
|
|
|
assert!(result.is_some(), "Expected a response");
|
|
let text = result.unwrap();
|
|
assert!(text.contains('4'), "Expected '4' in response, got: {text}");
|
|
|
|
assert!(events.iter().any(|e| matches!(e, OrchestratorEvent::Started { .. })));
|
|
assert!(events.iter().any(|e| matches!(e, OrchestratorEvent::Thinking { .. })));
|
|
|
|
let done = events.iter().find(|e| matches!(e, OrchestratorEvent::Done { .. }));
|
|
assert!(done.is_some(), "Missing Done event");
|
|
if let Some(OrchestratorEvent::Done { usage, .. }) = done {
|
|
assert!(usage.prompt_tokens > 0);
|
|
assert!(usage.completion_tokens > 0);
|
|
}
|
|
}
|
|
|
|
// ── Test 2: Conversation continuity ─────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_continuity() {
|
|
let mut h = TestHarness::new().await;
|
|
let conv_key = format!("test-{}", uuid::Uuid::new_v4());
|
|
|
|
// Turn 1
|
|
let r1 = make_request_with_key("my favorite color is cerulean. just acknowledge.", &conv_key);
|
|
let rid1 = r1.request_id.clone();
|
|
let orch1 = h.orchestrator.clone();
|
|
let gen1 = tokio::spawn(async move { orch1.generate(&r1).await });
|
|
h.collect_events_for(&rid1, 30).await;
|
|
let result1 = gen1.await.unwrap();
|
|
assert!(result1.is_some(), "Turn 1 should get a response");
|
|
|
|
// Turn 2
|
|
let r2 = make_request_with_key("what is my favorite color?", &conv_key);
|
|
let rid2 = r2.request_id.clone();
|
|
let orch2 = h.orchestrator.clone();
|
|
let gen2 = tokio::spawn(async move { orch2.generate(&r2).await });
|
|
h.collect_events_for(&rid2, 30).await;
|
|
let result2 = gen2.await.unwrap();
|
|
|
|
assert!(result2.is_some(), "Turn 2 should get a response");
|
|
let text = result2.unwrap().to_lowercase();
|
|
assert!(text.contains("cerulean"), "Expected 'cerulean', got: {text}");
|
|
}
|
|
|
|
// ── Test 3: Client-side tool dispatch ───────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_client_tool_dispatch() {
|
|
use mistralai_client::v1::conversations::{
|
|
ConversationInput, CreateConversationRequest,
|
|
};
|
|
use mistralai_client::v1::agents::AgentTool;
|
|
|
|
let mut h = TestHarness::new().await;
|
|
|
|
// Create conversation directly with file_read tool defined
|
|
let api_key = std::env::var("SOL_MISTRAL_API_KEY")
|
|
.expect("SOL_MISTRAL_API_KEY must be set");
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None)
|
|
.expect("Failed to create Mistral client");
|
|
|
|
let file_read_tool = AgentTool::function(
|
|
"file_read".into(),
|
|
"Read a file's contents. Use path for the file path.".into(),
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": { "type": "string", "description": "File path to read" }
|
|
},
|
|
"required": ["path"]
|
|
}),
|
|
);
|
|
|
|
let req = CreateConversationRequest {
|
|
inputs: ConversationInput::Text(
|
|
"use your file_read tool to read the file at path 'README.md'".into(),
|
|
),
|
|
model: Some("mistral-medium-latest".into()),
|
|
agent_id: None,
|
|
agent_version: None,
|
|
name: Some("test-client-tool".into()),
|
|
description: None,
|
|
instructions: Some("you are a coding assistant. use tools when asked.".into()),
|
|
completion_args: None,
|
|
tools: Some(vec![file_read_tool]),
|
|
handoff_execution: None,
|
|
metadata: None,
|
|
store: Some(true),
|
|
stream: false,
|
|
};
|
|
|
|
let conv_response = mistral
|
|
.create_conversation_async(&req)
|
|
.await
|
|
.expect("Failed to create conversation");
|
|
|
|
// Now pass to orchestrator
|
|
let request = make_request("use your file_read tool to read README.md");
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let orch_submit = h.orchestrator.clone();
|
|
|
|
let gen = tokio::spawn(async move {
|
|
orch.generate_from_response(&request, conv_response).await
|
|
});
|
|
|
|
let mut got_client_tool = false;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
|
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, h.event_rx.recv()).await {
|
|
Ok(Ok(event)) => {
|
|
if event.request_id() != &rid { continue; }
|
|
match event {
|
|
OrchestratorEvent::ToolCallDetected {
|
|
side: ToolSide::Client, call_id, ref name, ..
|
|
} => {
|
|
assert_eq!(name, "file_read");
|
|
got_client_tool = true;
|
|
orch_submit
|
|
.submit_tool_result(&call_id, ToolResultPayload {
|
|
text: "# Sol\n\nVirtual librarian for Sunbeam.".into(),
|
|
is_error: false,
|
|
})
|
|
.await
|
|
.expect("Failed to submit tool result");
|
|
}
|
|
OrchestratorEvent::Done { .. } | OrchestratorEvent::Failed { .. } => break,
|
|
_ => continue,
|
|
}
|
|
}
|
|
Ok(Err(_)) => break,
|
|
Err(_) => panic!("Timeout waiting for client tool dispatch"),
|
|
}
|
|
}
|
|
|
|
assert!(got_client_tool, "Expected client-side file_read tool call");
|
|
let result = gen.await.unwrap();
|
|
assert!(result.is_some(), "Expected response after tool execution");
|
|
}
|
|
|
|
// ── Test 4: Event ordering ───────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_event_ordering() {
|
|
let mut h = TestHarness::new().await;
|
|
let request = make_request("say hello");
|
|
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let gen = tokio::spawn(async move { orch.generate(&request).await });
|
|
|
|
let events = h.collect_events_for(&rid, 30).await;
|
|
let _ = gen.await;
|
|
|
|
// Verify strict ordering: Started → Thinking → Done
|
|
assert!(events.len() >= 3, "Expected at least 3 events, got {}", events.len());
|
|
assert!(matches!(events[0], OrchestratorEvent::Started { .. }), "First event should be Started");
|
|
assert!(matches!(events[1], OrchestratorEvent::Thinking { .. }), "Second event should be Thinking");
|
|
assert!(matches!(events.last().unwrap(), OrchestratorEvent::Done { .. }), "Last event should be Done");
|
|
}
|
|
|
|
// ── Test 5: Metadata pass-through ───────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_metadata_passthrough() {
|
|
let mut h = TestHarness::new().await;
|
|
let mut request = make_request("hi");
|
|
request.metadata.insert("room_id", "!test-room:localhost");
|
|
request.metadata.insert("custom_key", "custom_value");
|
|
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let gen = tokio::spawn(async move { orch.generate(&request).await });
|
|
|
|
let events = h.collect_events_for(&rid, 30).await;
|
|
let _ = gen.await;
|
|
|
|
// Started event should carry metadata
|
|
let started = events.iter().find(|e| matches!(e, OrchestratorEvent::Started { .. }));
|
|
assert!(started.is_some(), "Missing Started event");
|
|
if let Some(OrchestratorEvent::Started { metadata, .. }) = started {
|
|
assert_eq!(metadata.get("room_id"), Some("!test-room:localhost"));
|
|
assert_eq!(metadata.get("custom_key"), Some("custom_value"));
|
|
}
|
|
}
|
|
|
|
// ── Test 6: Token usage accuracy ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_token_usage_accuracy() {
|
|
let mut h = TestHarness::new().await;
|
|
|
|
// Short prompt → small token count
|
|
let r1 = make_request("say ok");
|
|
let rid1 = r1.request_id.clone();
|
|
let orch1 = h.orchestrator.clone();
|
|
let gen1 = tokio::spawn(async move { orch1.generate(&r1).await });
|
|
let events1 = h.collect_events_for(&rid1, 30).await;
|
|
let _ = gen1.await;
|
|
|
|
let done1 = events1.iter().find_map(|e| match e {
|
|
OrchestratorEvent::Done { usage, .. } => Some(usage.clone()),
|
|
_ => None,
|
|
}).expect("Missing Done event");
|
|
|
|
// Longer prompt → larger token count
|
|
let r2 = make_request(
|
|
"write a haiku about the sun setting over the ocean. include imagery of waves."
|
|
);
|
|
let rid2 = r2.request_id.clone();
|
|
let orch2 = h.orchestrator.clone();
|
|
let gen2 = tokio::spawn(async move { orch2.generate(&r2).await });
|
|
let events2 = h.collect_events_for(&rid2, 30).await;
|
|
let _ = gen2.await;
|
|
|
|
let done2 = events2.iter().find_map(|e| match e {
|
|
OrchestratorEvent::Done { usage, .. } => Some(usage.clone()),
|
|
_ => None,
|
|
}).expect("Missing Done event");
|
|
|
|
// Both should have non-zero tokens
|
|
assert!(done1.prompt_tokens > 0);
|
|
assert!(done1.completion_tokens > 0);
|
|
assert!(done2.prompt_tokens > 0);
|
|
assert!(done2.completion_tokens > 0);
|
|
|
|
// The longer prompt should use more completion tokens (haiku vs "ok")
|
|
assert!(
|
|
done2.completion_tokens > done1.completion_tokens,
|
|
"Longer request should produce more completion tokens: {} vs {}",
|
|
done2.completion_tokens, done1.completion_tokens
|
|
);
|
|
}
|
|
|
|
// ── Test 7: Failed tool result ──────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_failed_tool_result() {
|
|
use mistralai_client::v1::conversations::{
|
|
ConversationInput, CreateConversationRequest,
|
|
};
|
|
use mistralai_client::v1::agents::AgentTool;
|
|
|
|
let mut h = TestHarness::new().await;
|
|
|
|
let api_key = std::env::var("SOL_MISTRAL_API_KEY").unwrap();
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
|
|
let tool = AgentTool::function(
|
|
"file_read".into(),
|
|
"Read a file.".into(),
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": { "path": { "type": "string" } },
|
|
"required": ["path"]
|
|
}),
|
|
);
|
|
|
|
let req = CreateConversationRequest {
|
|
inputs: ConversationInput::Text("read the file at /nonexistent/path".into()),
|
|
model: Some("mistral-medium-latest".into()),
|
|
agent_id: None,
|
|
agent_version: None,
|
|
name: Some("test-failed-tool".into()),
|
|
description: None,
|
|
instructions: Some("use tools when asked.".into()),
|
|
completion_args: None,
|
|
tools: Some(vec![tool]),
|
|
handoff_execution: None,
|
|
metadata: None,
|
|
store: Some(true),
|
|
stream: false,
|
|
};
|
|
|
|
let conv_response = mistral.create_conversation_async(&req).await.unwrap();
|
|
let request = make_request("read /nonexistent/path");
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let orch_submit = h.orchestrator.clone();
|
|
|
|
let gen = tokio::spawn(async move {
|
|
orch.generate_from_response(&request, conv_response).await
|
|
});
|
|
|
|
// Submit error result when tool is called
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, h.event_rx.recv()).await {
|
|
Ok(Ok(event)) => {
|
|
if event.request_id() != &rid { continue; }
|
|
match event {
|
|
OrchestratorEvent::ToolCallDetected { side: ToolSide::Client, call_id, .. } => {
|
|
orch_submit.submit_tool_result(&call_id, ToolResultPayload {
|
|
text: "Error: file not found".into(),
|
|
is_error: true,
|
|
}).await.unwrap();
|
|
}
|
|
OrchestratorEvent::ToolCompleted { success, .. } => {
|
|
assert!(!success, "Expected tool to report failure");
|
|
}
|
|
OrchestratorEvent::Done { .. } | OrchestratorEvent::Failed { .. } => break,
|
|
_ => continue,
|
|
}
|
|
}
|
|
Ok(Err(_)) => break,
|
|
Err(_) => panic!("Timeout"),
|
|
}
|
|
}
|
|
|
|
// Model should still produce a response (explaining the error)
|
|
let result = gen.await.unwrap();
|
|
assert!(result.is_some(), "Expected response even after tool error");
|
|
}
|
|
|
|
// ── Test 8: Server-side tool execution (search_web) ─────────────────────
|
|
// Note: run_script requires deno sandbox + tool definitions from the agent.
|
|
// search_web is more reliably available in test conversations.
|
|
|
|
#[tokio::test]
|
|
async fn test_server_tool_execution() {
|
|
use mistralai_client::v1::conversations::{
|
|
ConversationInput, CreateConversationRequest,
|
|
};
|
|
use mistralai_client::v1::agents::AgentTool;
|
|
|
|
let mut h = TestHarness::new().await;
|
|
|
|
let api_key = std::env::var("SOL_MISTRAL_API_KEY").unwrap();
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
|
|
// Create conversation with search_web tool
|
|
let tool = AgentTool::function(
|
|
"search_web".into(),
|
|
"Search the web. Returns titles, URLs, and snippets.".into(),
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"query": { "type": "string", "description": "Search query" }
|
|
},
|
|
"required": ["query"]
|
|
}),
|
|
);
|
|
|
|
let req = CreateConversationRequest {
|
|
inputs: ConversationInput::Text("search the web for 'rust programming language'".into()),
|
|
model: Some("mistral-medium-latest".into()),
|
|
agent_id: None,
|
|
agent_version: None,
|
|
name: Some("test-server-tool".into()),
|
|
description: None,
|
|
instructions: Some("use tools when asked. always use the search_web tool for any web search request.".into()),
|
|
completion_args: None,
|
|
tools: Some(vec![tool]),
|
|
handoff_execution: None,
|
|
metadata: None,
|
|
store: Some(true),
|
|
stream: false,
|
|
};
|
|
|
|
let conv_response = mistral.create_conversation_async(&req).await.unwrap();
|
|
let request = make_request("search the web for rust");
|
|
let rid = request.request_id.clone();
|
|
let orch = h.orchestrator.clone();
|
|
let gen = tokio::spawn(async move {
|
|
orch.generate_from_response(&request, conv_response).await
|
|
});
|
|
|
|
let events = h.collect_events_for(&rid, 60).await;
|
|
let result = gen.await.unwrap();
|
|
|
|
// May or may not produce a result (search_web needs SearXNG running)
|
|
// But we should at least see the tool call events
|
|
let tool_detected = events.iter().find(|e| matches!(e, OrchestratorEvent::ToolCallDetected { .. }));
|
|
assert!(tool_detected.is_some(), "Expected ToolCallDetected for search_web");
|
|
|
|
if let Some(OrchestratorEvent::ToolCallDetected { side, name, .. }) = tool_detected {
|
|
assert_eq!(*side, ToolSide::Server);
|
|
assert_eq!(name, "search_web");
|
|
}
|
|
|
|
assert!(events.iter().any(|e| matches!(e, OrchestratorEvent::ToolStarted { .. })));
|
|
assert!(events.iter().any(|e| matches!(e, OrchestratorEvent::ToolCompleted { .. })));
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// gRPC integration tests — full round-trip through the gRPC server
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod grpc_tests {
|
|
use super::*;
|
|
use crate::grpc::{self, GrpcState};
|
|
use crate::grpc::code_agent_client::CodeAgentClient;
|
|
use crate::grpc::*;
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
/// Start a gRPC server on a random port and return the endpoint URL.
|
|
async fn start_test_server() -> (String, Arc<GrpcState>) {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
let api_key = std::env::var("SOL_MISTRAL_API_KEY")
|
|
.expect("SOL_MISTRAL_API_KEY must be set");
|
|
|
|
let config = test_config();
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let conversations = Arc::new(ConversationRegistry::new(
|
|
config.agents.orchestrator_model.clone(),
|
|
config.agents.compaction_threshold,
|
|
store.clone(),
|
|
));
|
|
let tools = Arc::new(ToolRegistry::new_minimal(config.clone()));
|
|
let orch = Arc::new(Orchestrator::new(
|
|
config.clone(), tools.clone(), mistral.clone(), conversations,
|
|
"you are sol. respond briefly. lowercase only.".into(),
|
|
));
|
|
|
|
let grpc_state = Arc::new(GrpcState {
|
|
config: config.clone(),
|
|
tools,
|
|
store,
|
|
mistral,
|
|
matrix: None,
|
|
opensearch: None, // breadcrumbs disabled in tests
|
|
system_prompt: "you are sol. respond briefly. lowercase only.".into(),
|
|
orchestrator_agent_id: String::new(),
|
|
orchestrator: Some(orch),
|
|
});
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let endpoint = format!("http://{addr}");
|
|
|
|
let state = grpc_state.clone();
|
|
tokio::spawn(async move {
|
|
let svc = crate::grpc::service::CodeAgentService::new(state);
|
|
let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
|
|
tonic::transport::Server::builder()
|
|
.add_service(CodeAgentServer::new(svc))
|
|
.serve_with_incoming(incoming)
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
(endpoint, grpc_state)
|
|
}
|
|
|
|
/// Connect a gRPC client and send StartSession. Returns (tx, rx, session_ready).
|
|
async fn connect_session(
|
|
endpoint: &str,
|
|
) -> (
|
|
mpsc::Sender<ClientMessage>,
|
|
tonic::Streaming<ServerMessage>,
|
|
SessionReady,
|
|
) {
|
|
let mut client = CodeAgentClient::connect(endpoint.to_string()).await.unwrap();
|
|
|
|
let (tx, client_rx) = mpsc::channel::<ClientMessage>(32);
|
|
let stream = ReceiverStream::new(client_rx);
|
|
let response = client.session(stream).await.unwrap();
|
|
let mut rx = response.into_inner();
|
|
|
|
// Send StartSession
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Start(StartSession {
|
|
project_path: "/tmp/test-project".into(),
|
|
prompt_md: String::new(),
|
|
config_toml: String::new(),
|
|
git_branch: "main".into(),
|
|
git_status: String::new(),
|
|
file_tree: vec![],
|
|
model: "mistral-medium-latest".into(),
|
|
client_tools: vec![],
|
|
capabilities: vec![],
|
|
})),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Wait for SessionReady
|
|
let ready = loop {
|
|
match rx.message().await.unwrap() {
|
|
Some(ServerMessage { payload: Some(server_message::Payload::Ready(r)) }) => break r,
|
|
Some(ServerMessage { payload: Some(server_message::Payload::Error(e)) }) => {
|
|
panic!("Session start failed: {}", e.message);
|
|
}
|
|
_ => continue,
|
|
}
|
|
};
|
|
|
|
(tx, rx, ready)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grpc_simple_roundtrip() {
|
|
let (endpoint, _state) = start_test_server().await;
|
|
let (tx, mut rx, ready) = connect_session(&endpoint).await;
|
|
|
|
assert!(!ready.session_id.is_empty());
|
|
assert!(!ready.room_id.is_empty());
|
|
|
|
// Send a message
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Input(UserInput {
|
|
text: "what is 3+3? answer with just the number.".into(),
|
|
})),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Collect server messages until TextDone
|
|
let mut got_status = false;
|
|
let mut got_done = false;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
|
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::Status(_)) => got_status = true,
|
|
Some(server_message::Payload::Done(d)) => {
|
|
got_done = true;
|
|
assert!(d.full_text.contains('6'), "Expected '6', got: {}", d.full_text);
|
|
assert!(d.input_tokens > 0, "Expected non-zero input tokens");
|
|
assert!(d.output_tokens > 0, "Expected non-zero output tokens");
|
|
break;
|
|
}
|
|
Some(server_message::Payload::Error(e)) => {
|
|
panic!("Server error: {}", e.message);
|
|
}
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => panic!("Stream closed before Done"),
|
|
Ok(Err(e)) => panic!("Stream error: {e}"),
|
|
Err(_) => panic!("Timeout waiting for gRPC response"),
|
|
}
|
|
}
|
|
|
|
assert!(got_status, "Expected at least one Status message");
|
|
assert!(got_done, "Expected TextDone message");
|
|
|
|
// Clean disconnect
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::End(EndSession {})),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grpc_client_tool_relay() {
|
|
let (endpoint, _state) = start_test_server().await;
|
|
let (tx, mut rx, _ready) = connect_session(&endpoint).await;
|
|
|
|
// Send a message that should trigger file_read
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Input(UserInput {
|
|
text: "use file_read to read README.md".into(),
|
|
})),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut got_tool_call = false;
|
|
let mut got_done = false;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
|
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::ToolCall(tc)) => {
|
|
assert!(tc.is_local, "Expected local tool, got: {}", tc.name);
|
|
// Model may call file_read, list_directory, or other client tools
|
|
got_tool_call = true;
|
|
|
|
// Send tool result back
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::ToolResult(
|
|
crate::grpc::ToolResult {
|
|
call_id: tc.call_id,
|
|
result: "# Sol\nVirtual librarian.".into(),
|
|
is_error: false,
|
|
},
|
|
)),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
Some(server_message::Payload::Done(d)) => {
|
|
got_done = true;
|
|
assert!(!d.full_text.is_empty(), "Expected non-empty response");
|
|
break;
|
|
}
|
|
Some(server_message::Payload::Error(e)) => {
|
|
panic!("Server error: {}", e.message);
|
|
}
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => break,
|
|
Ok(Err(e)) => panic!("Stream error: {e}"),
|
|
Err(_) => panic!("Timeout"),
|
|
}
|
|
}
|
|
|
|
assert!(got_tool_call, "Expected ToolCall for file_read");
|
|
assert!(got_done, "Expected TextDone after tool execution");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grpc_token_counts() {
|
|
let (endpoint, _state) = start_test_server().await;
|
|
let (tx, mut rx, _ready) = connect_session(&endpoint).await;
|
|
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Input(UserInput {
|
|
text: "say hello".into(),
|
|
})),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::Done(d)) => {
|
|
assert!(d.input_tokens > 0, "input_tokens should be > 0, got {}", d.input_tokens);
|
|
assert!(d.output_tokens > 0, "output_tokens should be > 0, got {}", d.output_tokens);
|
|
break;
|
|
}
|
|
Some(server_message::Payload::Error(e)) => panic!("Error: {}", e.message),
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => panic!("Stream closed"),
|
|
Ok(Err(e)) => panic!("Stream error: {e}"),
|
|
Err(_) => panic!("Timeout"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grpc_session_resume() {
|
|
let (endpoint, _state) = start_test_server().await;
|
|
|
|
// Session 1: establish context
|
|
let (tx1, mut rx1, ready1) = connect_session(&endpoint).await;
|
|
tx1.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Input(UserInput {
|
|
text: "my secret code is 42. remember it.".into(),
|
|
})),
|
|
}).await.unwrap();
|
|
|
|
// Wait for response
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx1.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::Done(_)) => break,
|
|
Some(server_message::Payload::Error(e)) => panic!("Error: {}", e.message),
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => break,
|
|
Ok(Err(e)) => panic!("Error: {e}"),
|
|
Err(_) => panic!("Timeout"),
|
|
}
|
|
}
|
|
|
|
// Disconnect (don't send End — keeps session active)
|
|
drop(tx1);
|
|
drop(rx1);
|
|
|
|
// Session 2: reconnect — should resume the same session
|
|
let (tx2, mut rx2, ready2) = connect_session(&endpoint).await;
|
|
|
|
// Should be the same session (same project path → same room)
|
|
assert_eq!(ready2.room_id, ready1.room_id, "Should resume same room");
|
|
assert!(ready2.resumed, "Should indicate resumed session");
|
|
|
|
// History requires Matrix (not available in tests) — just check session resumed
|
|
|
|
// Ask for recall
|
|
tx2.send(ClientMessage {
|
|
payload: Some(client_message::Payload::Input(UserInput {
|
|
text: "what is my secret code?".into(),
|
|
})),
|
|
}).await.unwrap();
|
|
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx2.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::Done(d)) => {
|
|
assert!(
|
|
d.full_text.contains("42"),
|
|
"Expected model to recall '42', got: {}",
|
|
d.full_text
|
|
);
|
|
break;
|
|
}
|
|
Some(server_message::Payload::Error(e)) => panic!("Error: {}", e.message),
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => panic!("Stream closed"),
|
|
Ok(Err(e)) => panic!("Error: {e}"),
|
|
Err(_) => panic!("Timeout"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_grpc_clean_disconnect() {
|
|
let (endpoint, _state) = start_test_server().await;
|
|
let (tx, mut rx, ready) = connect_session(&endpoint).await;
|
|
|
|
assert!(!ready.session_id.is_empty());
|
|
|
|
// Clean disconnect
|
|
tx.send(ClientMessage {
|
|
payload: Some(client_message::Payload::End(EndSession {})),
|
|
}).await.unwrap();
|
|
|
|
// Should get SessionEnd
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
let mut got_end = false;
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, rx.message()).await {
|
|
Ok(Ok(Some(msg))) => match msg.payload {
|
|
Some(server_message::Payload::End(_)) => { got_end = true; break; }
|
|
_ => continue,
|
|
},
|
|
Ok(Ok(None)) => break,
|
|
Ok(Err(_)) => break,
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
assert!(got_end, "Server should send SessionEnd on clean disconnect");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Code index + breadcrumb integration tests (requires local OpenSearch)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod code_index_tests {
|
|
use super::*;
|
|
use crate::code_index::schema::{self, SymbolDocument};
|
|
use crate::code_index::indexer::CodeIndexer;
|
|
use crate::breadcrumbs;
|
|
|
|
pub(super) fn os_client() -> Option<opensearch::OpenSearch> {
|
|
use opensearch::http::transport::{SingleNodeConnectionPool, TransportBuilder};
|
|
let url = url::Url::parse("http://localhost:9200").ok()?;
|
|
let transport = TransportBuilder::new(SingleNodeConnectionPool::new(url))
|
|
.build()
|
|
.ok()?;
|
|
Some(opensearch::OpenSearch::new(transport))
|
|
}
|
|
|
|
pub(super) async fn setup_test_index(client: &opensearch::OpenSearch) -> String {
|
|
let index = format!("sol_code_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(client, &index).await.unwrap();
|
|
index
|
|
}
|
|
|
|
pub(super) async fn refresh_index(client: &opensearch::OpenSearch, index: &str) {
|
|
let _ = client
|
|
.indices()
|
|
.refresh(opensearch::indices::IndicesRefreshParts::Index(&[index]))
|
|
.send()
|
|
.await;
|
|
}
|
|
|
|
pub(super) async fn cleanup_index(client: &opensearch::OpenSearch, index: &str) {
|
|
let _ = client
|
|
.indices()
|
|
.delete(opensearch::indices::IndicesDeleteParts::Index(&[index]))
|
|
.send()
|
|
.await;
|
|
}
|
|
|
|
fn sample_symbols() -> Vec<SymbolDocument> {
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
vec![
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/mod.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "generate".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "pub async fn generate(&self, req: &GenerateRequest) -> Option<String>".into(),
|
|
docstring: "Generate a response using the ConversationRegistry.".into(),
|
|
start_line: 80,
|
|
end_line: 120,
|
|
content: "pub async fn generate(&self, req: &GenerateRequest) -> Option<String> { ... }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/engine.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "run_tool_loop".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "pub async fn run_tool_loop(orch: &Orchestrator, req: &GenerateRequest, resp: ConversationResponse) -> Option<(String, TokenUsage)>".into(),
|
|
docstring: "Unified Mistral tool loop. Emits events for every state transition.".into(),
|
|
start_line: 20,
|
|
end_line: 160,
|
|
content: "pub async fn run_tool_loop(...) { ... tool iteration ... }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/tool_dispatch.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "route".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "pub fn route(tool_name: &str) -> ToolSide".into(),
|
|
docstring: "Route a tool call to server or client.".into(),
|
|
start_line: 17,
|
|
end_line: 23,
|
|
content: "pub fn route(tool_name: &str) -> ToolSide { if CLIENT_TOOLS.contains ... }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/event.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "ToolSide".into(),
|
|
symbol_kind: "enum".into(),
|
|
signature: "pub enum ToolSide { Server, Client }".into(),
|
|
docstring: "Whether a tool executes on the server or on a connected client.".into(),
|
|
start_line: 68,
|
|
end_line: 72,
|
|
content: "pub enum ToolSide { Server, Client }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/event.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "OrchestratorEvent".into(),
|
|
symbol_kind: "enum".into(),
|
|
signature: "pub enum OrchestratorEvent { Started, Thinking, ToolCallDetected, ToolStarted, ToolCompleted, Done, Failed }".into(),
|
|
docstring: "An event emitted by the orchestrator during response generation.".into(),
|
|
start_line: 110,
|
|
end_line: 170,
|
|
content: "pub enum OrchestratorEvent { ... }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
// Feature branch symbol — should be preferred when querying feat/code
|
|
SymbolDocument {
|
|
file_path: "src/orchestrator/mod.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "generate_from_response".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "pub async fn generate_from_response(&self, req: &GenerateRequest, resp: ConversationResponse) -> Option<String>".into(),
|
|
docstring: "Generate from a pre-built ConversationResponse. Caller manages conversation.".into(),
|
|
start_line: 125,
|
|
end_line: 160,
|
|
content: "pub async fn generate_from_response(...) { ... }".into(),
|
|
branch: "feat/code".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
},
|
|
]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_index_and_search_symbols() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available at localhost:9200");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Search for "tool loop" — should find run_tool_loop
|
|
let results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "tool loop"}"#,
|
|
Some("sol"), Some("mainline"),
|
|
).await.unwrap();
|
|
assert!(results.contains("run_tool_loop"), "Expected run_tool_loop in results, got:\n{results}");
|
|
|
|
// Search for "ToolSide" — should find the enum
|
|
let results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "ToolSide"}"#,
|
|
Some("sol"), None,
|
|
).await.unwrap();
|
|
assert!(results.contains("ToolSide"), "Expected ToolSide in results, got:\n{results}");
|
|
|
|
// Search for "generate response" — should find generate()
|
|
let results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "generate response"}"#,
|
|
Some("sol"), None,
|
|
).await.unwrap();
|
|
assert!(results.contains("generate"), "Expected generate in results, got:\n{results}");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumb_project_outline() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline", "hi", 4000
|
|
).await;
|
|
|
|
// Default outline should have project name
|
|
assert!(result.outline.contains("sol"), "Outline should mention project name");
|
|
// Short message → no adaptive expansion
|
|
assert!(result.relevant.is_empty(), "Short message should not trigger expansion");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumb_adaptive_expansion() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline",
|
|
"how does the tool loop handle client-side tools?",
|
|
4000,
|
|
).await;
|
|
|
|
// Adaptive expansion should find relevant symbols
|
|
assert!(!result.relevant.is_empty(), "Substantive message should trigger expansion");
|
|
|
|
// Formatted output should contain relevant context section
|
|
assert!(result.formatted.contains("relevant context"), "Should have relevant context section");
|
|
|
|
// Should include tool-related symbols
|
|
let symbol_names: Vec<&str> = result.relevant.iter().map(|s| s.symbol_name.as_str()).collect();
|
|
assert!(
|
|
symbol_names.iter().any(|n| n.contains("tool") || n.contains("route") || n.contains("ToolSide")),
|
|
"Expected tool-related symbols, got: {:?}", symbol_names
|
|
);
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumb_token_budget() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Very small budget — should only fit the outline
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline",
|
|
"how does the tool loop work?",
|
|
100, // tiny budget
|
|
).await;
|
|
|
|
assert!(result.formatted.len() <= 100, "Should respect token budget, got {} chars", result.formatted.len());
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_branch_scoping() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Search on feat/code branch — should find generate_from_response (branch-specific)
|
|
let results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "generate from response", "branch": "feat/code"}"#,
|
|
Some("sol"), None,
|
|
).await.unwrap();
|
|
assert!(
|
|
results.contains("generate_from_response"),
|
|
"Should find branch-specific symbol, got:\n{results}"
|
|
);
|
|
|
|
// Should also find mainline symbols as fallback
|
|
assert!(
|
|
results.contains("generate") || results.contains("run_tool_loop"),
|
|
"Should also find mainline symbols as fallback"
|
|
);
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_delete_branch_symbols() {
|
|
let Some(client) = os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = setup_test_index(&client).await;
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 100);
|
|
for doc in sample_symbols() {
|
|
indexer.add(doc).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Delete feat/code branch symbols
|
|
indexer.delete_branch("sol", "feat/code").await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Should no longer find generate_from_response
|
|
let results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "generate_from_response"}"#,
|
|
Some("sol"), Some("feat/code"),
|
|
).await.unwrap();
|
|
|
|
// Mainline symbols should still exist
|
|
let mainline_results = crate::tools::code_search::search_code(
|
|
&client, &index,
|
|
r#"{"query": "generate"}"#,
|
|
Some("sol"), Some("mainline"),
|
|
).await.unwrap();
|
|
assert!(mainline_results.contains("generate"), "Mainline symbols should survive branch deletion");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Gitea SDK + devtools integration tests (requires local Gitea)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod gitea_tests {
|
|
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
pub(super) fn load_env() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn gitea_available() -> bool {
|
|
load_env();
|
|
let url = std::env::var("GITEA_URL").unwrap_or_default();
|
|
if url.is_empty() { return false; }
|
|
std::process::Command::new("curl")
|
|
.args(["-sf", &format!("{url}/api/v1/version")])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(super) fn gitea_client() -> Option<Arc<crate::sdk::gitea::GiteaClient>> {
|
|
if !gitea_available() { return None; }
|
|
let url = std::env::var("GITEA_URL").ok()?;
|
|
let user = std::env::var("GITEA_ADMIN_USERNAME").ok()?;
|
|
let pass = std::env::var("GITEA_ADMIN_PASSWORD").ok()?;
|
|
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
// Create a minimal vault client (won't be used — admin uses basic auth)
|
|
let vault = Arc::new(crate::sdk::vault::VaultClient::new(
|
|
"http://localhost:8200".into(), "test".into(), "secret".into(),
|
|
));
|
|
let token_store = Arc::new(crate::sdk::tokens::TokenStore::new(store, vault));
|
|
Some(Arc::new(crate::sdk::gitea::GiteaClient::new(
|
|
url, user, pass, token_store,
|
|
)))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_repos() {
|
|
let Some(gitea) = gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
|
|
let repos = gitea.list_repos("sol", None, Some("studio"), Some(50)).await;
|
|
assert!(repos.is_ok(), "list_repos should succeed: {:?}", repos.err());
|
|
let repos = repos.unwrap();
|
|
assert!(!repos.is_empty(), "Should find repos in studio org");
|
|
|
|
// Should find sol repo
|
|
let sol = repos.iter().find(|r| r.full_name.contains("sol"));
|
|
assert!(sol.is_some(), "Should find studio/sol repo");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_repo() {
|
|
let Some(gitea) = gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
|
|
let repo = gitea.get_repo("sol", "studio", "sol").await;
|
|
assert!(repo.is_ok(), "get_repo should succeed: {:?}", repo.err());
|
|
let repo = repo.unwrap();
|
|
assert!(!repo.default_branch.is_empty(), "Should have a default branch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_directory() {
|
|
let Some(gitea) = gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
|
|
// List repo root — SDK returns parse error for directory listings (known issue),
|
|
// but the API call itself should succeed
|
|
let result = gitea.get_file("sol", "studio", "sol", "", None).await;
|
|
// Directory listing returns an array, SDK expects single object — may error
|
|
// Just verify we can call it without panic
|
|
let _ = result;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file_content() {
|
|
let Some(gitea) = gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
|
|
let result = gitea.get_file("sol", "studio", "sol", "Cargo.toml", None).await;
|
|
assert!(result.is_ok(), "Should get Cargo.toml: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_code_indexing() {
|
|
let Some(gitea) = gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let Some(os) = super::code_index_tests::os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
let index = super::code_index_tests::setup_test_index(&os).await;
|
|
let mut indexer = crate::code_index::indexer::CodeIndexer::new(
|
|
os.clone(), index.clone(), String::new(), 50,
|
|
);
|
|
|
|
// Index the mistralai-client-rs repo (small, Rust)
|
|
let result = crate::code_index::gitea::index_repo(
|
|
&gitea, &mut indexer, "sol", "studio", "mistralai-client-rs", "main",
|
|
).await;
|
|
|
|
assert!(result.is_ok(), "Indexing should succeed: {:?}", result.err());
|
|
let count = result.unwrap();
|
|
indexer.flush().await;
|
|
|
|
// Should have found symbols
|
|
assert!(count > 0, "Should extract symbols from Rust repo, got 0");
|
|
|
|
// Verify we can search them
|
|
super::code_index_tests::refresh_index(&os, &index).await;
|
|
let search_result = crate::tools::code_search::search_code(
|
|
&os, &index,
|
|
r#"{"query": "Client"}"#,
|
|
Some("mistralai-client-rs"), None,
|
|
).await.unwrap();
|
|
assert!(!search_result.contains("No code results"), "Should find Client in results: {search_result}");
|
|
|
|
super::code_index_tests::cleanup_index(&os, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Devtools (Gitea tool) integration tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod devtools_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_repos_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_list_repos", r#"{"org": "studio"}"#, &ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "list_repos tool should succeed: {:?}", result.err());
|
|
let text = result.unwrap();
|
|
assert!(text.contains("sol"), "Should find sol repo in results: {text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_get_repo_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_get_repo", r#"{"owner": "studio", "repo": "sol"}"#, &ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "get_repo tool should succeed: {:?}", result.err());
|
|
let text = result.unwrap();
|
|
assert!(text.contains("sol"), "Should contain repo name: {text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_get_file_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_get_file",
|
|
r#"{"owner": "studio", "repo": "sol", "path": "Cargo.toml"}"#,
|
|
&ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "get_file tool should succeed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_branches_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_list_branches",
|
|
r#"{"owner": "studio", "repo": "sol"}"#,
|
|
&ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "list_branches tool should succeed: {:?}", result.err());
|
|
let text = result.unwrap();
|
|
assert!(text.contains("mainline") || text.contains("main"), "Should find default branch: {text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_issues_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_list_issues",
|
|
r#"{"owner": "studio", "repo": "sol"}"#,
|
|
&ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "list_issues should succeed: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_orgs_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else {
|
|
eprintln!("Skipping: Gitea not available");
|
|
return;
|
|
};
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test".into(),
|
|
};
|
|
|
|
let result = crate::tools::devtools::execute(
|
|
&gitea, "gitea_list_orgs", r#"{"username": "sol"}"#, &ctx,
|
|
).await;
|
|
assert!(result.is_ok(), "list_orgs should succeed: {:?}", result.err());
|
|
let text = result.unwrap();
|
|
assert!(text.contains("studio"), "Should find studio org: {text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_get_org_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_get_org", r#"{"org": "studio"}"#, &ctx).await;
|
|
assert!(result.is_ok(), "get_org: {:?}", result.err());
|
|
assert!(result.unwrap().contains("studio"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_pulls_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_list_pulls", r#"{"owner":"studio","repo":"sol"}"#, &ctx).await;
|
|
assert!(result.is_ok(), "list_pulls: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_comments_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
// List comments on issue #1 (created by bootstrap-gitea.sh)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_list_comments", r#"{"owner":"studio","repo":"sol","number":1}"#, &ctx).await;
|
|
assert!(result.is_ok(), "list_comments: {:?}", result.err());
|
|
let text = result.unwrap();
|
|
assert!(text.contains("Bootstrap test comment"), "Should find bootstrap comment: {text}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_notifications_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_list_notifications", r#"{}"#, &ctx).await;
|
|
assert!(result.is_ok(), "list_notifications: {:?}", result.err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_list_org_repos_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_list_org_repos", r#"{"org":"studio"}"#, &ctx).await;
|
|
assert!(result.is_ok(), "list_org_repos: {:?}", result.err());
|
|
assert!(result.unwrap().contains("sol"));
|
|
}
|
|
|
|
/// Full CRUD lifecycle test: create repo → issue → comment → branch → PR → cleanup.
|
|
/// Exercises all mutation tools in a single controlled sequence.
|
|
#[tokio::test]
|
|
async fn test_gitea_mutation_lifecycle() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
|
|
let test_id = uuid::Uuid::new_v4().to_string().split('-').next().unwrap().to_string();
|
|
let repo_name = format!("test-{test_id}");
|
|
|
|
// 1. Create repo (uses admin auth — should work for sol user)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_create_repo",
|
|
&format!(r#"{{"name":"{repo_name}","description":"integration test repo","auto_init":true}}"#),
|
|
&ctx).await;
|
|
if result.is_err() {
|
|
eprintln!("Skipping mutation lifecycle: create_repo failed (permissions): {:?}", result.err());
|
|
return;
|
|
}
|
|
|
|
// 2. Create issue
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_create_issue",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","title":"Test issue","body":"Created by integration test"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "create_issue: {:?}", result.err());
|
|
|
|
// 3. Comment on issue #1
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_create_comment",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","number":1,"body":"Test comment"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "create_comment: {:?}", result.err());
|
|
|
|
// 4. Create branch (branch_name, not branch; from_branch is optional)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_create_branch",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","branch_name":"feat/test"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "create_branch: {:?}", result.err());
|
|
|
|
// 5. Create PR (head, base)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_create_pull",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","title":"Test PR","head":"feat/test","base":"main"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "create_pull: {:?}", result.err());
|
|
|
|
// 6. Get PR (number)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_get_pull",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","number":1}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "get_pull: {:?}", result.err());
|
|
|
|
// 7. Edit issue (number, title)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_edit_issue",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","number":1,"title":"Updated title"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "edit_issue: {:?}", result.err());
|
|
|
|
// 8. Delete branch (branch)
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_delete_branch",
|
|
&format!(r#"{{"owner":"sol","repo":"{repo_name}","branch":"feat/test"}}"#),
|
|
&ctx).await;
|
|
assert!(result.is_ok(), "delete_branch: {:?}", result.err());
|
|
|
|
// Cleanup: delete the test repo via API
|
|
let _ = reqwest::Client::new()
|
|
.delete(format!("{}/api/v1/repos/sol/{repo_name}",
|
|
std::env::var("GITEA_URL").unwrap_or_default()))
|
|
.basic_auth("sol", Some("solpass123"))
|
|
.send().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_gitea_unknown_tool() {
|
|
gitea_tests::load_env();
|
|
let Some(gitea) = gitea_tests::gitea_client() else { return; };
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(), user_id: "sol".into(),
|
|
display_name: None, is_dm: true, is_reply: false, room_id: "test".into(),
|
|
};
|
|
let result = crate::tools::devtools::execute(&gitea, "gitea_nonexistent", r#"{}"#, &ctx).await;
|
|
assert!(result.is_err(), "Unknown tool should error");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Web search + conversation registry tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod service_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_web_search() {
|
|
// Requires SearXNG at localhost:8888
|
|
let result = reqwest::get("http://localhost:8888/search?q=test&format=json").await;
|
|
if result.is_err() {
|
|
eprintln!("Skipping: SearXNG not available");
|
|
return;
|
|
}
|
|
|
|
let tool_result = crate::tools::web_search::search(
|
|
"http://localhost:8888",
|
|
r#"{"query": "rust programming language", "limit": 3}"#,
|
|
).await;
|
|
|
|
assert!(tool_result.is_ok(), "Web search should succeed: {:?}", tool_result.err());
|
|
let text = tool_result.unwrap();
|
|
assert!(!text.is_empty(), "Should return results");
|
|
assert!(text.to_lowercase().contains("rust"), "Should mention Rust in results");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_registry_lifecycle() {
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = crate::conversations::ConversationRegistry::new(
|
|
"mistral-medium-latest".into(),
|
|
118000,
|
|
store,
|
|
);
|
|
|
|
// No conversation should exist yet
|
|
let conv_id = registry.get_conversation_id("test-room").await;
|
|
assert!(conv_id.is_none(), "Should have no conversation initially");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_send_message() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = crate::conversations::ConversationRegistry::new(
|
|
"mistral-medium-latest".into(),
|
|
118000,
|
|
store,
|
|
);
|
|
|
|
let conv_key = format!("test-{}", uuid::Uuid::new_v4());
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text("say hi".into());
|
|
|
|
let result = registry.send_message(&conv_key, input, true, &mistral, None).await;
|
|
assert!(result.is_ok(), "send_message should succeed: {:?}", result.err());
|
|
|
|
// Conversation should now exist
|
|
let conv_id = registry.get_conversation_id(&conv_key).await;
|
|
assert!(conv_id.is_some(), "Conversation should be stored after first message");
|
|
}
|
|
|
|
#[test]
|
|
fn test_evaluator_rule_matching() {
|
|
let config = test_config();
|
|
let evaluator = crate::brain::evaluator::Evaluator::new(
|
|
config,
|
|
"you are sol.".into(),
|
|
);
|
|
|
|
// DM should trigger MustRespond
|
|
let engagement = evaluator.evaluate_rules(
|
|
"@alice:sunbeam.pt",
|
|
"hey sol",
|
|
true, // DM
|
|
);
|
|
assert!(engagement.is_some(), "DM should trigger a rule");
|
|
|
|
// Own message should be Ignored
|
|
let engagement = evaluator.evaluate_rules(
|
|
"@test:localhost", // matches config user_id
|
|
"hello",
|
|
false,
|
|
);
|
|
assert!(engagement.is_some(), "Own message should be Ignored");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Script (deno sandbox) execution tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod script_tests {
|
|
use super::*;
|
|
|
|
fn make_registry() -> crate::tools::ToolRegistry {
|
|
let config = test_config();
|
|
crate::tools::ToolRegistry::new_minimal(config)
|
|
}
|
|
|
|
fn make_ctx() -> crate::context::ResponseContext {
|
|
crate::context::ResponseContext {
|
|
matrix_user_id: "@test:localhost".into(),
|
|
user_id: "test".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "test-room".into(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_script_basic_math() {
|
|
let registry = make_registry();
|
|
let result = registry.execute(
|
|
"run_script",
|
|
r#"{"code": "console.log(2 + 2)"}"#,
|
|
&make_ctx(),
|
|
).await;
|
|
// May fail if deno_core isn't available, that's ok
|
|
match result {
|
|
Ok(text) => assert!(text.contains("4"), "Should output 4: {text}"),
|
|
Err(e) => eprintln!("Script tool unavailable (expected in minimal registry): {e}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_script_string_manipulation() {
|
|
let registry = make_registry();
|
|
let result = registry.execute(
|
|
"run_script",
|
|
r#"{"code": "console.log('hello'.toUpperCase())"}"#,
|
|
&make_ctx(),
|
|
).await;
|
|
match result {
|
|
Ok(text) => assert!(text.contains("HELLO"), "Should output HELLO: {text}"),
|
|
Err(_) => {} // minimal registry may not have OpenSearch for script sandbox
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_script_json_output() {
|
|
let registry = make_registry();
|
|
let result = registry.execute(
|
|
"run_script",
|
|
r#"{"code": "console.log(JSON.stringify({a: 1, b: 2}))"}"#,
|
|
&make_ctx(),
|
|
).await;
|
|
match result {
|
|
Ok(text) => assert!(text.contains("\"a\":1") || text.contains("\"a\": 1"), "Should output JSON: {text}"),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_script_last_expression() {
|
|
let registry = make_registry();
|
|
let result = registry.execute(
|
|
"run_script",
|
|
r#"{"code": "42"}"#,
|
|
&make_ctx(),
|
|
).await;
|
|
match result {
|
|
Ok(text) => assert!(text.contains("42"), "Should capture last expression: {text}"),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Archive search tests (requires OpenSearch)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod search_tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_args_parsing() {
|
|
// Test that search args parse correctly
|
|
let args: crate::tools::search::SearchArgs = serde_json::from_str(
|
|
r#"{"query": "hello", "room": "general", "limit": 5}"#
|
|
).unwrap();
|
|
assert_eq!(args.query, "hello");
|
|
assert_eq!(args.room.as_deref(), Some("general"));
|
|
assert_eq!(args.limit, 5);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_minimal_args() {
|
|
let args: crate::tools::search::SearchArgs = serde_json::from_str(
|
|
r#"{"query": "test"}"#
|
|
).unwrap();
|
|
assert_eq!(args.query, "test");
|
|
assert!(args.room.is_none());
|
|
assert!(args.sender.is_none());
|
|
assert_eq!(args.limit, 10); // default
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_with_opensearch() {
|
|
let Some(os) = code_index_tests::os_client() else {
|
|
eprintln!("Skipping: OpenSearch not available");
|
|
return;
|
|
};
|
|
|
|
// Search the archive index — may be empty but shouldn't error
|
|
let result = crate::tools::search::search_archive(
|
|
&os, "sol_archive", r#"{"query": "hello"}"#, &["test-room".into()],
|
|
).await;
|
|
assert!(result.is_ok(), "search_archive shouldn't error: {:?}", result.err());
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Persistence + memory tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod persistence_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_store_open_memory() {
|
|
let store = Store::open_memory();
|
|
assert!(store.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_agent_crud() {
|
|
let store = Store::open_memory().unwrap();
|
|
// No agent initially
|
|
let result = store.get_agent("test-agent");
|
|
assert!(result.is_none());
|
|
|
|
// Upsert
|
|
store.upsert_agent("test-agent", "ag-123", "mistral-medium", "hash1");
|
|
let result = store.get_agent("test-agent");
|
|
assert!(result.is_some());
|
|
let (id, hash) = result.unwrap();
|
|
assert_eq!(id, "ag-123");
|
|
assert_eq!(hash, "hash1");
|
|
|
|
// Update
|
|
store.upsert_agent("test-agent", "ag-456", "mistral-medium", "hash2");
|
|
let (id, hash) = store.get_agent("test-agent").unwrap();
|
|
assert_eq!(id, "ag-456");
|
|
assert_eq!(hash, "hash2");
|
|
|
|
// Delete
|
|
store.delete_agent("test-agent");
|
|
assert!(store.get_agent("test-agent").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_agent_upsert_and_delete() {
|
|
let store = Store::open_memory().unwrap();
|
|
assert!(store.get_agent("test-2").is_none());
|
|
store.upsert_agent("test-2", "ag-1", "model", "hash1");
|
|
let (id, hash) = store.get_agent("test-2").unwrap();
|
|
assert_eq!(id, "ag-1");
|
|
assert_eq!(hash, "hash1");
|
|
// Update
|
|
store.upsert_agent("test-2", "ag-2", "model", "hash2");
|
|
let (id, _) = store.get_agent("test-2").unwrap();
|
|
assert_eq!(id, "ag-2");
|
|
// Delete
|
|
store.delete_agent("test-2");
|
|
assert!(store.get_agent("test-2").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_service_user_crud() {
|
|
let store = Store::open_memory().unwrap();
|
|
|
|
store.upsert_service_user("alice", "gitea", "alice_gitea");
|
|
let username = store.get_service_user("alice", "gitea");
|
|
assert_eq!(username.as_deref(), Some("alice_gitea"));
|
|
|
|
// Non-existent
|
|
let none = store.get_service_user("bob", "gitea");
|
|
assert!(none.is_none());
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// gRPC bridge unit tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod bridge_tests {
|
|
use crate::grpc::bridge;
|
|
use crate::orchestrator::event::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_thinking_event() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
|
|
let (event_tx, event_rx) = tokio::sync::broadcast::channel(16);
|
|
|
|
let rid = RequestId::new();
|
|
let rid2 = rid.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bridge::bridge_events_to_grpc(rid2, event_rx, tx).await;
|
|
});
|
|
|
|
// Send Thinking + Done
|
|
let _ = event_tx.send(OrchestratorEvent::Thinking { request_id: rid.clone() });
|
|
let _ = event_tx.send(OrchestratorEvent::Done {
|
|
request_id: rid.clone(),
|
|
text: "hello".into(),
|
|
usage: TokenUsage::default(),
|
|
});
|
|
|
|
// Collect messages
|
|
let mut msgs = Vec::new();
|
|
while let Some(Ok(msg)) = rx.recv().await {
|
|
msgs.push(msg);
|
|
if msgs.len() >= 2 { break; }
|
|
}
|
|
|
|
assert_eq!(msgs.len(), 2, "Should get Status + TextDone");
|
|
let _ = handle.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_tool_call_client() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
|
|
let (event_tx, event_rx) = tokio::sync::broadcast::channel(16);
|
|
|
|
let rid = RequestId::new();
|
|
let rid2 = rid.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bridge::bridge_events_to_grpc(rid2, event_rx, tx).await;
|
|
});
|
|
|
|
let _ = event_tx.send(OrchestratorEvent::ToolCallDetected {
|
|
request_id: rid.clone(),
|
|
call_id: "c1".into(),
|
|
name: "file_read".into(),
|
|
args: "{}".into(),
|
|
side: ToolSide::Client,
|
|
});
|
|
let _ = event_tx.send(OrchestratorEvent::Done {
|
|
request_id: rid.clone(),
|
|
text: "done".into(),
|
|
usage: TokenUsage::default(),
|
|
});
|
|
|
|
let mut msgs = Vec::new();
|
|
while let Some(Ok(msg)) = rx.recv().await {
|
|
msgs.push(msg);
|
|
if msgs.len() >= 2 { break; }
|
|
}
|
|
|
|
// First message should be ToolCall
|
|
assert!(msgs.len() >= 1);
|
|
let _ = handle.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_filters_by_request_id() {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
|
|
let (event_tx, event_rx) = tokio::sync::broadcast::channel(16);
|
|
|
|
let rid = RequestId::new();
|
|
let other_rid = RequestId::new();
|
|
let rid2 = rid.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bridge::bridge_events_to_grpc(rid2, event_rx, tx).await;
|
|
});
|
|
|
|
// Send event for different request — should be filtered out
|
|
let _ = event_tx.send(OrchestratorEvent::Thinking { request_id: other_rid });
|
|
// Send Done for our request — should be forwarded
|
|
let _ = event_tx.send(OrchestratorEvent::Done {
|
|
request_id: rid.clone(),
|
|
text: "hi".into(),
|
|
usage: TokenUsage::default(),
|
|
});
|
|
|
|
let msg = rx.recv().await;
|
|
assert!(msg.is_some(), "Should get Done message (filtered correctly)");
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Evaluator + Agent Registry tests (require Mistral API)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod evaluator_tests {
|
|
use super::*;
|
|
use crate::brain::evaluator::{Evaluator, Engagement, MustRespondReason};
|
|
|
|
fn evaluator() -> Evaluator {
|
|
let config = test_config();
|
|
Evaluator::new(config, "you are sol. respond briefly.".into())
|
|
}
|
|
|
|
#[test]
|
|
fn test_own_message_ignored() {
|
|
let ev = evaluator();
|
|
let result = ev.evaluate_rules("@test:localhost", "anything", false);
|
|
assert!(matches!(result, Some(Engagement::Ignore)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_dm_must_respond() {
|
|
let ev = evaluator();
|
|
let result = ev.evaluate_rules("@alice:test", "hey", true);
|
|
assert!(matches!(result, Some(Engagement::MustRespond { reason: MustRespondReason::DirectMessage })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mention_must_respond() {
|
|
let ev = evaluator();
|
|
let result = ev.evaluate_rules("@alice:test", "hey @test:localhost check this", false);
|
|
assert!(matches!(result, Some(Engagement::MustRespond { reason: MustRespondReason::DirectMention })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_name_invocation() {
|
|
let ev = evaluator();
|
|
// "sol" at start of message should trigger name invocation
|
|
// (if the config user_id contains "sol" — our test config uses @test:localhost so this won't match)
|
|
let result = ev.evaluate_rules("@alice:test", "random chat about lunch", false);
|
|
assert!(result.is_none(), "Random message should not trigger");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_async_with_mistral() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let ev = evaluator();
|
|
|
|
// Test the full async evaluate path with a DM (should short-circuit via rules)
|
|
let result = ev.evaluate(
|
|
"@alice:test", "hey sol, what's up?",
|
|
true, // DM
|
|
&["previous message".into()],
|
|
&mistral,
|
|
false, // not reply to human
|
|
1, // messages since sol
|
|
false, // not silenced
|
|
).await;
|
|
|
|
// DM should always return MustRespond via rules (doesn't even reach LLM)
|
|
assert!(matches!(result, Engagement::MustRespond { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_llm_relevance() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let ev = evaluator();
|
|
|
|
// Group chat message that doesn't mention Sol — should go to LLM evaluation
|
|
let result = ev.evaluate(
|
|
"@alice:test", "what should we have for lunch?",
|
|
false, // not DM
|
|
&["hey everyone".into(), "what's the plan?".into()],
|
|
&mistral,
|
|
false,
|
|
5,
|
|
false,
|
|
).await;
|
|
|
|
// Should return some engagement decision (Respond, Ignore, or React)
|
|
// The specific result depends on the LLM — just verify it doesn't panic
|
|
match result {
|
|
Engagement::MustRespond { .. } => {},
|
|
Engagement::Respond { .. } => {},
|
|
Engagement::ThreadReply { .. } => {},
|
|
Engagement::React { .. } => {},
|
|
Engagement::Ignore => {},
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Agent Registry tests (require Mistral API)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod registry_tests {
|
|
use super::*;
|
|
use crate::agents::registry::AgentRegistry;
|
|
|
|
#[tokio::test]
|
|
async fn test_ensure_orchestrator_creates_agent() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = AgentRegistry::new(store);
|
|
|
|
let tools = crate::tools::ToolRegistry::agent_tool_definitions(false, false);
|
|
|
|
let result = registry.ensure_orchestrator(
|
|
"you are sol. respond briefly.",
|
|
"mistral-medium-latest",
|
|
tools,
|
|
&mistral,
|
|
&[],
|
|
"test-integration", // unique prefix to avoid collision
|
|
).await;
|
|
|
|
assert!(result.is_ok(), "Should create agent: {:?}", result.err());
|
|
let (agent_id, created) = result.unwrap();
|
|
assert!(!agent_id.is_empty(), "Agent ID should not be empty");
|
|
assert!(created, "Should be newly created");
|
|
|
|
// Calling again should restore (not recreate)
|
|
let tools2 = crate::tools::ToolRegistry::agent_tool_definitions(false, false);
|
|
let result2 = registry.ensure_orchestrator(
|
|
"you are sol. respond briefly.",
|
|
"mistral-medium-latest",
|
|
tools2,
|
|
&mistral,
|
|
&[],
|
|
"test-integration",
|
|
).await;
|
|
assert!(result2.is_ok());
|
|
let (agent_id2, created2) = result2.unwrap();
|
|
assert_eq!(agent_id, agent_id2, "Should reuse same agent");
|
|
assert!(!created2, "Should NOT be recreated");
|
|
|
|
// Clean up: delete the test agent
|
|
let _ = mistral.delete_agent_async(&agent_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensure_orchestrator_recreates_on_prompt_change() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = AgentRegistry::new(store.clone());
|
|
|
|
// Create with prompt v1
|
|
let tools = crate::tools::ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id1, created) = registry.ensure_orchestrator(
|
|
"prompt version 1",
|
|
"mistral-medium-latest",
|
|
tools,
|
|
&mistral,
|
|
&[],
|
|
"test-recreate",
|
|
).await.unwrap();
|
|
assert!(created, "First call should create the agent");
|
|
|
|
// Test runtime recreation (same registry, prompt changes while running)
|
|
let tools2 = crate::tools::ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id2, recreated) = registry.ensure_orchestrator(
|
|
"prompt version 2 — CHANGED",
|
|
"mistral-medium-latest",
|
|
tools2,
|
|
&mistral,
|
|
&[],
|
|
"test-recreate",
|
|
).await.unwrap();
|
|
|
|
assert!(recreated, "Should recreate when prompt changes at runtime");
|
|
assert_ne!(id1, id2, "New agent should have different ID");
|
|
|
|
// Also test restart path: new registry with same backing store
|
|
let registry2 = AgentRegistry::new(store);
|
|
let tools3 = crate::tools::ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id3, recreated2) = registry2.ensure_orchestrator(
|
|
"prompt version 3 — CHANGED AGAIN",
|
|
"mistral-medium-latest",
|
|
tools3,
|
|
&mistral,
|
|
&[],
|
|
"test-recreate",
|
|
).await.unwrap();
|
|
|
|
assert!(recreated2, "Should recreate across restart when prompt changes");
|
|
assert_ne!(id2, id3, "Restart recreation should produce new ID");
|
|
|
|
// Clean up
|
|
let _ = mistral.delete_agent_async(&id3).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Conversation Registry full lifecycle tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod conversation_tests {
|
|
use super::*;
|
|
use crate::conversations::ConversationRegistry;
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_turn_conversation() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new(
|
|
"mistral-medium-latest".into(), 118000, store,
|
|
);
|
|
|
|
let room = format!("test-conv-{}", uuid::Uuid::new_v4());
|
|
|
|
// Turn 1
|
|
let input1 = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"my name is TestBot. acknowledge.".into(),
|
|
);
|
|
let resp1 = registry.send_message(&room, input1, true, &mistral, None).await;
|
|
assert!(resp1.is_ok(), "Turn 1 should succeed: {:?}", resp1.err());
|
|
|
|
// Conversation should now exist
|
|
let conv_id = registry.get_conversation_id(&room).await;
|
|
assert!(conv_id.is_some(), "Conversation should be stored");
|
|
|
|
// Turn 2 — verify context persists
|
|
let input2 = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"what is my name?".into(),
|
|
);
|
|
let resp2 = registry.send_message(&room, input2, true, &mistral, None).await;
|
|
assert!(resp2.is_ok(), "Turn 2 should succeed: {:?}", resp2.err());
|
|
let text = resp2.unwrap().assistant_text().unwrap_or_default().to_lowercase();
|
|
assert!(text.contains("testbot"), "Should recall name: {text}");
|
|
|
|
// Same conversation ID
|
|
let conv_id2 = registry.get_conversation_id(&room).await;
|
|
assert_eq!(conv_id, conv_id2, "Should reuse same conversation");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reset_conversation() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new(
|
|
"mistral-medium-latest".into(), 118000, store,
|
|
);
|
|
|
|
let room = format!("test-reset-{}", uuid::Uuid::new_v4());
|
|
|
|
// Create conversation
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text("hi".into());
|
|
let _ = registry.send_message(&room, input, true, &mistral, None).await;
|
|
assert!(registry.get_conversation_id(&room).await.is_some());
|
|
|
|
// Reset
|
|
registry.reset(&room).await;
|
|
assert!(registry.get_conversation_id(&room).await.is_none(), "Should be cleared after reset");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Persistence — code session + research session coverage
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod persistence_extended_tests {
|
|
use crate::persistence::Store;
|
|
|
|
#[test]
|
|
fn test_code_session_full_lifecycle() {
|
|
let store = Store::open_memory().unwrap();
|
|
|
|
// Create
|
|
store.create_code_session("sess-1", "user-a", "!room:x", "/home/dev/sol", "sol", "mistral-medium-latest");
|
|
|
|
// Before setting conversation_id, find returns None (NULL conversation_id)
|
|
assert!(store.find_code_session("user-a", "sol").is_none(),
|
|
"find_code_session returns None when conversation_id is NULL");
|
|
|
|
// But is_code_room works (doesn't need conversation_id)
|
|
assert!(store.is_code_room("!room:x"));
|
|
|
|
// Set conversation
|
|
store.set_code_session_conversation("sess-1", "conv-abc");
|
|
let found = store.find_code_session("user-a", "sol");
|
|
assert!(found.is_some());
|
|
let (sid, rid, conv) = found.unwrap();
|
|
assert_eq!(sid, "sess-1");
|
|
assert_eq!(rid, "!room:x");
|
|
assert_eq!(conv, "conv-abc");
|
|
|
|
// Touch
|
|
store.touch_code_session("sess-1");
|
|
|
|
// Is code room
|
|
assert!(store.is_code_room("!room:x"));
|
|
assert!(!store.is_code_room("!other:x"));
|
|
|
|
// Get context
|
|
let ctx = store.get_code_room_context("!room:x");
|
|
assert!(ctx.is_some());
|
|
let (name, path, model) = ctx.unwrap();
|
|
assert_eq!(name, "sol");
|
|
assert_eq!(path, "/home/dev/sol");
|
|
assert_eq!(model, "mistral-medium-latest");
|
|
|
|
// End session
|
|
store.end_code_session("sess-1");
|
|
assert!(!store.is_code_room("!room:x"));
|
|
assert!(store.find_code_session("user-a", "sol").is_none());
|
|
assert!(store.get_code_room_context("!room:x").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_code_session_multiple_projects() {
|
|
let store = Store::open_memory().unwrap();
|
|
|
|
store.create_code_session("s1", "user-a", "!r1:x", "/home/sol", "sol", "medium");
|
|
store.create_code_session("s2", "user-a", "!r2:x", "/home/cli", "cli", "medium");
|
|
store.create_code_session("s3", "user-b", "!r3:x", "/home/sol", "sol", "medium");
|
|
|
|
// Set conversation IDs so find_code_session works (NULL conv_id → None)
|
|
store.set_code_session_conversation("s1", "conv-s1");
|
|
store.set_code_session_conversation("s2", "conv-s2");
|
|
store.set_code_session_conversation("s3", "conv-s3");
|
|
|
|
// Each user+project combo finds the right session
|
|
let (sid, _, _) = store.find_code_session("user-a", "sol").unwrap();
|
|
assert_eq!(sid, "s1");
|
|
let (sid, _, _) = store.find_code_session("user-a", "cli").unwrap();
|
|
assert_eq!(sid, "s2");
|
|
let (sid, _, _) = store.find_code_session("user-b", "sol").unwrap();
|
|
assert_eq!(sid, "s3");
|
|
|
|
// Nonexistent user+project returns None
|
|
assert!(store.find_code_session("user-b", "cli").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_session_findings_json_structure() {
|
|
let store = Store::open_memory().unwrap();
|
|
store.create_research_session("rs-1", "!room:x", "$ev1", "deep dive", "[\"task1\",\"task2\"]");
|
|
|
|
// Append 3 findings
|
|
store.append_research_finding("rs-1", r#"{"focus":"a","status":"complete"}"#);
|
|
store.append_research_finding("rs-1", r#"{"focus":"b","status":"complete"}"#);
|
|
store.append_research_finding("rs-1", r#"{"focus":"c","status":"failed"}"#);
|
|
|
|
let running = store.load_running_research_sessions();
|
|
assert_eq!(running.len(), 1);
|
|
let findings: serde_json::Value = serde_json::from_str(&running[0].3).unwrap();
|
|
let arr = findings.as_array().unwrap();
|
|
assert_eq!(arr.len(), 3);
|
|
assert_eq!(arr[0]["focus"], "a");
|
|
assert_eq!(arr[2]["status"], "failed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_service_user_multi_service() {
|
|
let store = Store::open_memory().unwrap();
|
|
|
|
store.upsert_service_user("sienna", "gitea", "sienna-git");
|
|
store.upsert_service_user("sienna", "grafana", "sienna-graf");
|
|
store.upsert_service_user("lonni", "gitea", "lonni-git");
|
|
|
|
assert_eq!(store.get_service_user("sienna", "gitea").unwrap(), "sienna-git");
|
|
assert_eq!(store.get_service_user("sienna", "grafana").unwrap(), "sienna-graf");
|
|
assert_eq!(store.get_service_user("lonni", "gitea").unwrap(), "lonni-git");
|
|
assert!(store.get_service_user("lonni", "grafana").is_none());
|
|
|
|
// Delete one, others unaffected
|
|
store.delete_service_user("sienna", "gitea");
|
|
assert!(store.get_service_user("sienna", "gitea").is_none());
|
|
assert_eq!(store.get_service_user("sienna", "grafana").unwrap(), "sienna-graf");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Memory module — full OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod memory_tests {
|
|
use super::code_index_tests::{os_client, refresh_index, cleanup_index};
|
|
|
|
#[tokio::test]
|
|
async fn test_memory_store_and_query() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_mem_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
|
|
// Create index
|
|
crate::memory::schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
// Store memories
|
|
crate::memory::store::set(&client, &index, "sienna@sunbeam.pt", "prefers terse answers", "preference", "auto").await.unwrap();
|
|
crate::memory::store::set(&client, &index, "sienna@sunbeam.pt", "working on drive UI redesign", "fact", "auto").await.unwrap();
|
|
crate::memory::store::set(&client, &index, "lonni@sunbeam.pt", "focuses on design and UX", "fact", "auto").await.unwrap();
|
|
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Query — user-scoped, should only find sienna's memories
|
|
let results = crate::memory::store::query(&client, &index, "sienna@sunbeam.pt", "terse", 10).await.unwrap();
|
|
assert!(!results.is_empty(), "Should find 'prefers terse answers'");
|
|
assert!(results.iter().all(|r| r.user_id == "sienna@sunbeam.pt"), "All results should be sienna's");
|
|
assert!(results.iter().any(|r| r.content.contains("terse")));
|
|
|
|
// Query lonni's memories
|
|
let results = crate::memory::store::query(&client, &index, "lonni@sunbeam.pt", "design", 10).await.unwrap();
|
|
assert!(!results.is_empty());
|
|
assert!(results.iter().all(|r| r.user_id == "lonni@sunbeam.pt"));
|
|
|
|
// get_recent — returns most recent for user
|
|
let recent = crate::memory::store::get_recent(&client, &index, "sienna@sunbeam.pt", 10).await.unwrap();
|
|
assert_eq!(recent.len(), 2, "Sienna should have 2 memories");
|
|
|
|
// get_recent for nonexistent user
|
|
let empty = crate::memory::store::get_recent(&client, &index, "nobody@sunbeam.pt", 10).await.unwrap();
|
|
assert!(empty.is_empty());
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_memory_index_creation_idempotent() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_mem_idem_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
|
|
// Create twice — second call should be a no-op
|
|
crate::memory::schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
crate::memory::schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[test]
|
|
fn test_memory_extractor_normalize_category() {
|
|
use crate::memory::extractor::normalize_category;
|
|
assert_eq!(normalize_category("preference"), "preference");
|
|
assert_eq!(normalize_category("fact"), "fact");
|
|
assert_eq!(normalize_category("context"), "context");
|
|
assert_eq!(normalize_category("random"), "general");
|
|
assert_eq!(normalize_category(""), "general");
|
|
}
|
|
|
|
#[test]
|
|
fn test_memory_document_fields() {
|
|
use crate::memory::schema::MemoryDocument;
|
|
let doc = MemoryDocument {
|
|
id: "test-id".into(),
|
|
user_id: "sienna@sunbeam.pt".into(),
|
|
content: "likes rust".into(),
|
|
category: "preference".into(),
|
|
created_at: 1710000000000,
|
|
updated_at: 1710000000000,
|
|
source: "auto".into(),
|
|
};
|
|
let json = serde_json::to_value(&doc).unwrap();
|
|
assert_eq!(json["source"], "auto");
|
|
assert_eq!(json["category"], "preference");
|
|
|
|
// Roundtrip
|
|
let back: MemoryDocument = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back.id, "test-id");
|
|
assert_eq!(back.content, "likes rust");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Tools module — execute paths, execute_with_context
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod tools_execute_tests {
|
|
use std::sync::Arc;
|
|
use crate::config::Config;
|
|
use crate::context::ResponseContext;
|
|
use crate::orchestrator::event::ToolContext;
|
|
use crate::tools::ToolRegistry;
|
|
|
|
fn test_ctx() -> ResponseContext {
|
|
ResponseContext {
|
|
matrix_user_id: "@test:localhost".into(),
|
|
user_id: "test@localhost".into(),
|
|
display_name: None,
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "!test:localhost".into(),
|
|
}
|
|
}
|
|
|
|
fn tool_ctx() -> ToolContext {
|
|
ToolContext {
|
|
user_id: "test@localhost".into(),
|
|
scope_key: "!test:localhost".into(),
|
|
is_direct: true,
|
|
}
|
|
}
|
|
|
|
fn minimal_config() -> Arc<Config> {
|
|
Arc::new(Config::from_str(r#"
|
|
[matrix]
|
|
homeserver_url = "http://localhost:8008"
|
|
user_id = "@test:localhost"
|
|
state_store_path = "/tmp/sol-test"
|
|
db_path = ":memory:"
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "test"
|
|
[mistral]
|
|
default_model = "mistral-medium-latest"
|
|
[behavior]
|
|
instant_responses = true
|
|
"#).unwrap())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_unknown_tool_returns_error() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("nonexistent_tool", "{}", &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Unknown tool"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_search_archive_without_opensearch() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("search_archive", r#"{"query":"test"}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("OpenSearch not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_get_room_context_without_opensearch() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("get_room_context", r#"{"room_id":"!r:x","around_timestamp":123}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("OpenSearch not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_list_rooms_without_matrix() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("list_rooms", "{}", &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Matrix not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_get_room_members_without_matrix() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("get_room_members", r#"{"room_id":"!r:x"}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Matrix not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_search_code_without_opensearch() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("search_code", r#"{"query":"test"}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("OpenSearch not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_search_web_without_searxng() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("search_web", r#"{"query":"test"}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Web search not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_gitea_tool_without_gitea() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("gitea_list_repos", r#"{}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Gitea integration not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_identity_tool_without_kratos() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute("identity_list_users", r#"{}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Identity (Kratos) integration not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_with_context_delegates_correctly() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
// execute_with_context should produce the same error as execute
|
|
let result = reg.execute_with_context("search_archive", r#"{"query":"test"}"#, &tool_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("OpenSearch not configured"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_with_context_unknown_tool() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
let result = reg.execute_with_context("bogus", "{}", &tool_ctx()).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("Unknown tool"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_has_gitea_and_kratos_accessors() {
|
|
let reg = ToolRegistry::new_minimal(minimal_config());
|
|
assert!(!reg.has_gitea());
|
|
assert!(!reg.has_kratos());
|
|
assert!(reg.gitea_client().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_definitions_contain_search_code() {
|
|
let defs = ToolRegistry::tool_definitions(false, false);
|
|
let names: Vec<&str> = defs.iter().map(|t| t.function.name.as_str()).collect();
|
|
assert!(names.contains(&"search_code"));
|
|
assert!(names.contains(&"search_web"));
|
|
assert!(names.contains(&"research"));
|
|
assert!(names.contains(&"search_archive"));
|
|
assert!(names.contains(&"get_room_context"));
|
|
assert!(names.contains(&"list_rooms"));
|
|
assert!(names.contains(&"get_room_members"));
|
|
assert!(names.contains(&"run_script"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_definitions_gitea_count() {
|
|
let without = ToolRegistry::tool_definitions(false, false);
|
|
let with = ToolRegistry::tool_definitions(true, false);
|
|
assert!(with.len() > without.len(), "Gitea tools should add definitions");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_definitions_kratos_count() {
|
|
let without = ToolRegistry::tool_definitions(false, false);
|
|
let with = ToolRegistry::tool_definitions(false, true);
|
|
assert!(with.len() > without.len(), "Kratos tools should add definitions");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Room history — OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod room_history_tests {
|
|
use super::code_index_tests::{os_client, refresh_index, cleanup_index};
|
|
|
|
async fn setup_archive_index(client: &opensearch::OpenSearch) -> String {
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
let mapping = serde_json::json!({
|
|
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
|
|
"mappings": {
|
|
"properties": {
|
|
"event_id": { "type": "keyword" },
|
|
"room_id": { "type": "keyword" },
|
|
"sender_name": { "type": "keyword" },
|
|
"content": { "type": "text" },
|
|
"timestamp": { "type": "date", "format": "epoch_millis" },
|
|
"redacted": { "type": "boolean" }
|
|
}
|
|
}
|
|
});
|
|
client.indices()
|
|
.create(opensearch::indices::IndicesCreateParts::Index(&index))
|
|
.body(mapping)
|
|
.send().await.unwrap();
|
|
|
|
// Seed messages
|
|
let base_ts: i64 = 1710000000000;
|
|
let messages = vec![
|
|
("$ev1", "!room1:x", "sienna", "good morning everyone", base_ts),
|
|
("$ev2", "!room1:x", "lonni", "morning! working on designs today", base_ts + 60000),
|
|
("$ev3", "!room1:x", "amber", "same, starting 3d models", base_ts + 120000),
|
|
("$ev4", "!room1:x", "sienna", "let's sync at 2pm", base_ts + 180000),
|
|
("$ev5", "!room2:x", "sienna", "different room message", base_ts + 240000),
|
|
];
|
|
for (eid, rid, sender, content, ts) in messages {
|
|
let doc = serde_json::json!({
|
|
"event_id": eid, "room_id": rid, "sender_name": sender,
|
|
"content": content, "timestamp": ts, "redacted": false
|
|
});
|
|
client.index(opensearch::IndexParts::Index(&index))
|
|
.body(doc).send().await.unwrap();
|
|
}
|
|
refresh_index(client, &index).await;
|
|
index
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_room_context_by_timestamp() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive_index(&client).await;
|
|
|
|
let args = serde_json::json!({
|
|
"room_id": "!room1:x",
|
|
"around_timestamp": 1710000060000_i64,
|
|
"before_count": 5,
|
|
"after_count": 5
|
|
});
|
|
let result = crate::tools::room_history::get_room_context(
|
|
&client, &index, &args.to_string(), &["!room1:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("sienna"), "Should contain sienna's message");
|
|
assert!(result.contains("lonni"), "Should contain lonni's message");
|
|
assert!(!result.contains("different room"), "Should NOT contain other room's messages");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_room_context_by_event_id() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive_index(&client).await;
|
|
|
|
let args = serde_json::json!({
|
|
"room_id": "!room1:x",
|
|
"around_event_id": "$ev2"
|
|
});
|
|
let result = crate::tools::room_history::get_room_context(
|
|
&client, &index, &args.to_string(), &["!room1:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("lonni"), "Should find the anchor event's sender");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_room_context_access_denied() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive_index(&client).await;
|
|
|
|
let args = serde_json::json!({
|
|
"room_id": "!room1:x",
|
|
"around_timestamp": 1710000060000_i64
|
|
});
|
|
// Allowed rooms doesn't include !room1:x
|
|
let result = crate::tools::room_history::get_room_context(
|
|
&client, &index, &args.to_string(), &["!other:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("Access denied"), "Should deny access to non-allowed room");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_room_context_missing_both_pivots() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive_index(&client).await;
|
|
|
|
let args = serde_json::json!({ "room_id": "!room1:x" });
|
|
let result = crate::tools::room_history::get_room_context(
|
|
&client, &index, &args.to_string(), &["!room1:x".into()],
|
|
).await;
|
|
|
|
assert!(result.is_err(), "Should error when neither timestamp nor event_id provided");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[test]
|
|
fn test_room_history_args_defaults() {
|
|
let args: crate::tools::room_history::RoomHistoryArgs =
|
|
serde_json::from_str(r#"{"room_id":"!test:x"}"#).unwrap();
|
|
assert_eq!(args.room_id, "!test:x");
|
|
assert_eq!(args.before_count, 10);
|
|
assert_eq!(args.after_count, 10);
|
|
assert!(args.around_timestamp.is_none());
|
|
assert!(args.around_event_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_room_history_args_custom() {
|
|
let args: crate::tools::room_history::RoomHistoryArgs =
|
|
serde_json::from_str(r#"{"room_id":"!r:x","around_timestamp":123,"before_count":3,"after_count":5}"#).unwrap();
|
|
assert_eq!(args.around_timestamp, Some(123));
|
|
assert_eq!(args.before_count, 3);
|
|
assert_eq!(args.after_count, 5);
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Search archive — OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod search_archive_tests {
|
|
use super::code_index_tests::{os_client, refresh_index, cleanup_index};
|
|
|
|
async fn setup_archive(client: &opensearch::OpenSearch) -> String {
|
|
let index = format!("sol_search_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
let mapping = serde_json::json!({
|
|
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
|
|
"mappings": {
|
|
"properties": {
|
|
"event_id": { "type": "keyword" },
|
|
"room_id": { "type": "keyword" },
|
|
"sender_name": { "type": "keyword" },
|
|
"content": { "type": "text" },
|
|
"room_name": { "type": "keyword" },
|
|
"timestamp": { "type": "date", "format": "epoch_millis" },
|
|
"redacted": { "type": "boolean" }
|
|
}
|
|
}
|
|
});
|
|
client.indices()
|
|
.create(opensearch::indices::IndicesCreateParts::Index(&index))
|
|
.body(mapping)
|
|
.send().await.unwrap();
|
|
|
|
let base_ts: i64 = 1710000000000;
|
|
let msgs = vec![
|
|
("$a1", "!dev:x", "Dev Chat", "sienna", "deployed the new proxy config", base_ts),
|
|
("$a2", "!dev:x", "Dev Chat", "lonni", "nice, the CSS looks good now", base_ts + 1000),
|
|
("$a3", "!design:x", "Design", "amber", "finished the character model", base_ts + 2000),
|
|
("$a4", "!dev:x", "Dev Chat", "sienna", "starting work on authentication flow", base_ts + 3000),
|
|
];
|
|
for (eid, rid, rname, sender, content, ts) in msgs {
|
|
let doc = serde_json::json!({
|
|
"event_id": eid, "room_id": rid, "room_name": rname,
|
|
"sender_name": sender, "content": content,
|
|
"timestamp": ts, "redacted": false
|
|
});
|
|
client.index(opensearch::IndexParts::Index(&index))
|
|
.body(doc).send().await.unwrap();
|
|
}
|
|
refresh_index(client, &index).await;
|
|
index
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_basic_query() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive(&client).await;
|
|
|
|
let args = serde_json::json!({"query": "proxy config"}).to_string();
|
|
let result = crate::tools::search::search_archive(
|
|
&client, &index, &args, &["!dev:x".into(), "!design:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("proxy"), "Should find message about proxy");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_room_filter() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive(&client).await;
|
|
|
|
let args = serde_json::json!({"query": "*", "room": "Design"}).to_string();
|
|
let result = crate::tools::search::search_archive(
|
|
&client, &index, &args, &["!dev:x".into(), "!design:x".into()],
|
|
).await.unwrap();
|
|
|
|
// Should find amber's message in Design room
|
|
assert!(result.contains("character model") || result.contains("amber") || result.contains("Design"),
|
|
"Should find Design room content");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_sender_filter() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive(&client).await;
|
|
|
|
let args = serde_json::json!({"query": "*", "sender": "lonni"}).to_string();
|
|
let result = crate::tools::search::search_archive(
|
|
&client, &index, &args, &["!dev:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("CSS") || result.contains("lonni"),
|
|
"Should find lonni's message");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_archive_room_scoping() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_archive(&client).await;
|
|
|
|
// Only allow !design:x — should NOT find !dev:x messages
|
|
let args = serde_json::json!({"query": "proxy"}).to_string();
|
|
let result = crate::tools::search::search_archive(
|
|
&client, &index, &args, &["!design:x".into()],
|
|
).await.unwrap();
|
|
|
|
assert!(!result.contains("proxy"), "Should NOT find dev room messages when only design is allowed");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Script tool — deno sandbox integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod script_integration_tests {
|
|
#[test]
|
|
fn test_run_script_args_parse() {
|
|
let args: serde_json::Value = serde_json::from_str(r#"{"code": "console.log(42)"}"#).unwrap();
|
|
assert_eq!(args["code"].as_str().unwrap(), "console.log(42)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_run_script_args_missing_code() {
|
|
let result: Result<serde_json::Value, _> = serde_json::from_str(r#"{}"#);
|
|
assert!(result.is_ok()); // JSON is valid but code field missing
|
|
let v = result.unwrap();
|
|
assert!(v.get("code").is_none());
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Tokenizer — additional coverage
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod tokenizer_tests {
|
|
use crate::tokenizer::SolTokenizer;
|
|
|
|
#[test]
|
|
fn test_tokenizer_debug_impl() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let debug = format!("{:?}", tok);
|
|
assert!(debug.contains("SolTokenizer"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tokenizer_clone() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let tok2 = tok.clone();
|
|
// Both should produce same results
|
|
let a = tok.count_tokens("hello world");
|
|
let b = tok2.count_tokens("hello world");
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tokenizer_unicode() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let count = tok.count_tokens("こんにちは世界 🌍");
|
|
assert!(count > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tokenizer_code() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let code = "fn main() { println!(\"Hello, world!\"); }";
|
|
let count = tok.count_tokens(code);
|
|
assert!(count > 5, "Code should tokenize to multiple tokens");
|
|
assert!(count < 50, "Short code shouldn't be too many tokens");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tokenizer_encode_and_count_consistent() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let text = "The quick brown fox jumps over the lazy dog.";
|
|
let count = tok.count_tokens(text);
|
|
let ids = tok.encode(text).unwrap();
|
|
assert_eq!(count, ids.len(), "count_tokens and encode should agree");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tokenizer_large_text() {
|
|
let tok = SolTokenizer::new(None).unwrap();
|
|
let large = "word ".repeat(10000);
|
|
let count = tok.count_tokens(&large);
|
|
assert!(count > 5000, "Large text should have many tokens");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Context module — localpart, derive_user_id
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod context_tests {
|
|
use crate::context;
|
|
|
|
#[test]
|
|
fn test_response_context_construction() {
|
|
let ctx = context::ResponseContext {
|
|
matrix_user_id: "@sienna:sunbeam.pt".into(),
|
|
user_id: context::derive_user_id("@sienna:sunbeam.pt"),
|
|
display_name: Some("Sienna".into()),
|
|
is_dm: false,
|
|
is_reply: true,
|
|
room_id: "!dev:sunbeam.pt".into(),
|
|
};
|
|
assert_eq!(ctx.user_id, "sienna@sunbeam.pt");
|
|
assert_eq!(ctx.display_name.as_deref(), Some("Sienna"));
|
|
assert!(!ctx.is_dm);
|
|
assert!(ctx.is_reply);
|
|
}
|
|
|
|
#[test]
|
|
fn test_localpart_edge_cases() {
|
|
// No server part
|
|
assert_eq!(context::localpart("@solo"), "solo");
|
|
// Empty string
|
|
assert_eq!(context::localpart(""), "");
|
|
// Just @
|
|
assert_eq!(context::localpart("@"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_derive_user_id_edge_cases() {
|
|
// Multiple colons — only first is replaced
|
|
assert_eq!(context::derive_user_id("@user:server:8448"), "user@server:8448");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Time context
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod time_context_tests {
|
|
use crate::time_context::TimeContext;
|
|
|
|
#[test]
|
|
fn test_time_context_system_block() {
|
|
let tc = TimeContext::now();
|
|
let block = tc.system_block();
|
|
assert!(block.contains("202"), "Should contain current year");
|
|
assert!(!block.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_context_message_line() {
|
|
let tc = TimeContext::now();
|
|
let line = tc.message_line();
|
|
assert!(!line.is_empty());
|
|
assert!(line.len() > 5, "Should have meaningful content");
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_context_now_fields() {
|
|
let tc = TimeContext::now();
|
|
let block = tc.system_block();
|
|
// Should include day of week and month
|
|
let has_day = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
.iter().any(|d| block.contains(d));
|
|
assert!(has_day, "System block should contain day of week");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Code search — full OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod code_search_integration_tests {
|
|
use super::code_index_tests::*;
|
|
use crate::code_index::schema::SymbolDocument;
|
|
use crate::code_index::indexer::CodeIndexer;
|
|
|
|
#[tokio::test]
|
|
async fn test_search_code_with_language_filter() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
indexer.add(SymbolDocument {
|
|
file_path: "src/main.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "authenticate".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "pub fn authenticate(token: &str) -> bool".into(),
|
|
docstring: "Verify auth token".into(),
|
|
start_line: 1, end_line: 10,
|
|
content: "pub fn authenticate(token: &str) -> bool { ... }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
indexer.add(SymbolDocument {
|
|
file_path: "src/auth.ts".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "admin-ui".into(),
|
|
language: "typescript".into(),
|
|
symbol_name: "authenticate".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "export function authenticate(token: string): boolean".into(),
|
|
docstring: "Verify auth token".into(),
|
|
start_line: 1, end_line: 10,
|
|
content: "export function authenticate(token: string): boolean { ... }".into(),
|
|
branch: "main".into(),
|
|
source: "gitea".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Search with language filter
|
|
let args = serde_json::json!({"query": "authenticate", "language": "rust"}).to_string();
|
|
let result = crate::tools::code_search::search_code(&client, &index, &args, None, None).await.unwrap();
|
|
assert!(result.contains("main.rs"), "Should find Rust file");
|
|
assert!(!result.contains("auth.ts"), "Should NOT find TypeScript file when filtering to Rust");
|
|
|
|
// Search without filter — both
|
|
let args = serde_json::json!({"query": "authenticate"}).to_string();
|
|
let result = crate::tools::code_search::search_code(&client, &index, &args, None, None).await.unwrap();
|
|
assert!(result.contains("authenticate"), "Should find the symbol");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_code_with_repo_filter() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
for (repo, sym) in [("sol", "generate"), ("cli", "connect")] {
|
|
indexer.add(SymbolDocument {
|
|
file_path: format!("src/{sym}.rs"),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: repo.into(),
|
|
language: "rust".into(),
|
|
symbol_name: sym.into(),
|
|
symbol_kind: "function".into(),
|
|
signature: format!("pub fn {sym}()"),
|
|
docstring: "".into(),
|
|
start_line: 1, end_line: 5,
|
|
content: format!("pub fn {sym}() {{ }}"),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let args = serde_json::json!({"query": "generate", "repo": "sol"}).to_string();
|
|
let result = crate::tools::code_search::search_code(&client, &index, &args, None, None).await.unwrap();
|
|
assert!(result.contains("generate"), "Should find sol's generate");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_search_code_with_branch_scope() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
// Same symbol on two branches
|
|
for branch in ["mainline", "feat/auth"] {
|
|
indexer.add(SymbolDocument {
|
|
file_path: "src/auth.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "validate_token".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: format!("pub fn validate_token() // {branch}"),
|
|
docstring: "".into(),
|
|
start_line: 1, end_line: 5,
|
|
content: format!("validate_token on {branch}"),
|
|
branch: branch.into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Search scoped to feat/auth branch
|
|
let args = serde_json::json!({"query": "validate_token", "branch": "feat/auth"}).to_string();
|
|
let result = crate::tools::code_search::search_code(
|
|
&client, &index, &args, None, Some("mainline"),
|
|
).await.unwrap();
|
|
assert!(result.contains("validate_token"), "Should find the symbol");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Breadcrumbs — OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod breadcrumb_tests {
|
|
use super::code_index_tests::*;
|
|
use crate::code_index::schema::SymbolDocument;
|
|
use crate::code_index::indexer::CodeIndexer;
|
|
use crate::breadcrumbs;
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumbs_with_indexed_symbols() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
// Index a variety of symbols
|
|
let symbols = vec![
|
|
("generate", "function", "pub async fn generate(&self, req: &GenerateRequest) -> Option<String>"),
|
|
("Orchestrator", "struct", "pub struct Orchestrator"),
|
|
("run_tool_loop", "function", "pub async fn run_tool_loop(orch: &Orchestrator, req: &GenerateRequest)"),
|
|
("ToolSide", "enum", "pub enum ToolSide { Server, Client }"),
|
|
("search_code", "function", "pub async fn search_code(os: &OpenSearch, index: &str, args: &str)"),
|
|
];
|
|
for (name, kind, sig) in symbols {
|
|
indexer.add(SymbolDocument {
|
|
file_path: format!("src/{name}.rs"),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: name.into(),
|
|
symbol_kind: kind.into(),
|
|
signature: sig.into(),
|
|
docstring: "".into(),
|
|
start_line: 1, end_line: 10,
|
|
content: sig.into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Build breadcrumbs for a query about tool loops
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline", "how does the tool loop work?", 2000,
|
|
).await;
|
|
|
|
assert!(!result.formatted.is_empty(), "Breadcrumbs should not be empty");
|
|
// Should contain some of the indexed symbols
|
|
assert!(
|
|
result.formatted.contains("sol") || result.formatted.contains("generate") || result.formatted.contains("tool"),
|
|
"Breadcrumbs should reference project symbols"
|
|
);
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumbs_empty_index() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
// No symbols indexed — breadcrumbs should still work (empty but not crash)
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline", "anything", 2000,
|
|
).await;
|
|
|
|
// Should not panic, formatted may be empty or minimal
|
|
let _ = result.formatted;
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumbs_token_budget_respected() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
// Index many symbols
|
|
for i in 0..50 {
|
|
indexer.add(SymbolDocument {
|
|
file_path: format!("src/mod{i}.rs"),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "sol".into(),
|
|
language: "rust".into(),
|
|
symbol_name: format!("function_{i}"),
|
|
symbol_kind: "function".into(),
|
|
signature: format!("pub fn function_{i}(arg: Type{i}) -> Result<Value{i}>"),
|
|
docstring: format!("Does thing {i} with detailed description that takes up space"),
|
|
start_line: 1, end_line: 20,
|
|
content: format!("pub fn function_{i}() {{ lots of code here }}"),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
}
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Small budget
|
|
let result = breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "sol", "mainline", "function", 500,
|
|
).await;
|
|
|
|
assert!(result.formatted.len() <= 600, "Should respect token budget (with some margin): got {}", result.formatted.len());
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// OpenBao / Vault SDK — full integration (localhost:8200, dev-root-token)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod vault_tests {
|
|
use crate::sdk::vault::VaultClient;
|
|
|
|
async fn dev_vault() -> Option<VaultClient> {
|
|
let ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if !ok { return None; }
|
|
Some(VaultClient::new_with_token("http://localhost:8200", "secret", "dev-root-token"))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_read_seeded_secret() {
|
|
let Some(vault) = dev_vault().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
let data = vault.kv_get("sol-test").await.unwrap();
|
|
assert!(data.is_some(), "Bootstrap should have seeded sol-test");
|
|
let val = data.unwrap();
|
|
assert_eq!(val["key"].as_str().unwrap(), "test-secret-value");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_read_nonexistent() {
|
|
let Some(vault) = dev_vault().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
let data = vault.kv_get("does-not-exist-12345").await.unwrap();
|
|
assert!(data.is_none(), "Nonexistent path should return None");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_write_read_delete() {
|
|
let Some(vault) = dev_vault().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
let path = format!("sol-test/integration-{}", uuid::Uuid::new_v4());
|
|
|
|
// Write
|
|
vault.kv_put(&path, serde_json::json!({"foo": "bar", "num": 42})).await.unwrap();
|
|
|
|
// Read back
|
|
let data = vault.kv_get(&path).await.unwrap().unwrap();
|
|
assert_eq!(data["foo"].as_str().unwrap(), "bar");
|
|
assert_eq!(data["num"].as_i64().unwrap(), 42);
|
|
|
|
// Delete
|
|
vault.kv_delete(&path).await.unwrap();
|
|
|
|
// Verify deleted
|
|
let data = vault.kv_get(&path).await.unwrap();
|
|
assert!(data.is_none(), "Should be gone after delete");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_delete_nonexistent_is_ok() {
|
|
let Some(vault) = dev_vault().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
// Deleting a nonexistent path should not error (404 → Ok)
|
|
vault.kv_delete("does-not-exist-at-all-xyz").await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_overwrite() {
|
|
let Some(vault) = dev_vault().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
let path = format!("sol-test/overwrite-{}", uuid::Uuid::new_v4());
|
|
|
|
vault.kv_put(&path, serde_json::json!({"v": 1})).await.unwrap();
|
|
vault.kv_put(&path, serde_json::json!({"v": 2})).await.unwrap();
|
|
|
|
let data = vault.kv_get(&path).await.unwrap().unwrap();
|
|
assert_eq!(data["v"].as_i64().unwrap(), 2, "Should have latest version");
|
|
|
|
vault.kv_delete(&path).await.unwrap();
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Token store — OpenBao + SQLite integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod token_store_tests {
|
|
use std::sync::Arc;
|
|
use crate::persistence::Store;
|
|
use crate::sdk::vault::VaultClient;
|
|
use crate::sdk::tokens::TokenStore;
|
|
|
|
async fn dev_token_store() -> Option<TokenStore> {
|
|
let ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if !ok { return None; }
|
|
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let vault = Arc::new(VaultClient::new_with_token("http://localhost:8200", "secret", "dev-root-token"));
|
|
Some(TokenStore::new(store, vault))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_seeded_token() {
|
|
let Some(ts) = dev_token_store().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
// Bootstrap seeded sol-tokens/testuser/gitea
|
|
let token = ts.get_valid("testuser", "gitea").await.unwrap();
|
|
assert!(token.is_some(), "Bootstrap should have seeded testuser/gitea token");
|
|
assert_eq!(token.unwrap(), "test-gitea-pat-12345");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_put_get_delete_token() {
|
|
let Some(ts) = dev_token_store().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
let user = format!("test-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
|
|
|
// No token yet
|
|
assert!(ts.get_valid(&user, "gitea").await.unwrap().is_none());
|
|
|
|
// Store a PAT (no expiry)
|
|
ts.put(&user, "gitea", "pat-abc123", "pat", None, None).await.unwrap();
|
|
|
|
// Read back
|
|
let token = ts.get_valid(&user, "gitea").await.unwrap().unwrap();
|
|
assert_eq!(token, "pat-abc123");
|
|
|
|
// Delete
|
|
ts.delete(&user, "gitea").await;
|
|
assert!(ts.get_valid(&user, "gitea").await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_expired_token_returns_none() {
|
|
let Some(ts) = dev_token_store().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
let user = format!("exp-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
|
|
|
// Store a token that already expired
|
|
ts.put(&user, "grafana", "expired-tok", "oauth2", None, Some("2020-01-01T00:00:00+00:00")).await.unwrap();
|
|
|
|
// Should return None (expired) and auto-delete
|
|
let token = ts.get_valid(&user, "grafana").await.unwrap();
|
|
assert!(token.is_none(), "Expired token should return None");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_username_mapping_integration() {
|
|
let Some(ts) = dev_token_store().await else { eprintln!("Skipping: no OpenBao"); return; };
|
|
|
|
assert!(ts.resolve_username("maptest", "gitea").is_none());
|
|
ts.set_username("maptest", "gitea", "maptest-git");
|
|
assert_eq!(ts.resolve_username("maptest", "gitea").unwrap(), "maptest-git");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Kratos SDK — full integration (localhost:4434)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod kratos_tests {
|
|
use crate::sdk::kratos::KratosClient;
|
|
|
|
async fn dev_kratos() -> Option<KratosClient> {
|
|
let ok = reqwest::get("http://localhost:4434/admin/health/ready").await.ok()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if !ok { return None; }
|
|
Some(KratosClient::new("http://localhost:4434".into()))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_users() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let users = kratos.list_users(None, None).await.unwrap();
|
|
assert!(users.len() >= 3, "Bootstrap should have seeded 3 identities, got {}", users.len());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_users_with_search() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let users = kratos.list_users(Some("sienna@sunbeam.local"), None).await.unwrap();
|
|
assert!(!users.is_empty(), "Should find sienna");
|
|
assert_eq!(users[0].traits.email, "sienna@sunbeam.local");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_user_by_email() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let user = kratos.get_user("sienna@sunbeam.local").await.unwrap();
|
|
assert_eq!(user.traits.email, "sienna@sunbeam.local");
|
|
assert_eq!(user.state, "active");
|
|
let name = user.traits.name.unwrap();
|
|
assert_eq!(name.first, "Sienna");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_user_by_id() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
// First get sienna's ID
|
|
let users = kratos.list_users(Some("sienna@sunbeam.local"), None).await.unwrap();
|
|
let id = &users[0].id;
|
|
|
|
// Then get by ID
|
|
let user = kratos.get_user(id).await.unwrap();
|
|
assert_eq!(user.traits.email, "sienna@sunbeam.local");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_create_and_disable_user() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
|
|
let email = format!("test-{}@sunbeam.local", &uuid::Uuid::new_v4().to_string()[..8]);
|
|
let user = kratos.create_user(&email, Some("Test"), Some("Bot")).await.unwrap();
|
|
assert_eq!(user.traits.email, email);
|
|
assert_eq!(user.state, "active");
|
|
|
|
// Disable
|
|
let disabled = kratos.disable_user(&user.id).await.unwrap();
|
|
assert_eq!(disabled.state, "inactive");
|
|
|
|
// Re-enable
|
|
let enabled = kratos.enable_user(&user.id).await.unwrap();
|
|
assert_eq!(enabled.state, "active");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_sessions_empty() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let users = kratos.list_users(Some("sienna@sunbeam.local"), None).await.unwrap();
|
|
let sessions = kratos.list_sessions(&users[0].id).await.unwrap();
|
|
// No browser sessions in dev — should be empty, not error
|
|
assert!(sessions.is_empty() || !sessions.is_empty(), "Should return list (possibly empty)");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_nonexistent_user() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let result = kratos.get_user("nonexistent@sunbeam.local").await;
|
|
assert!(result.is_err(), "Should error for nonexistent user");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Identity tools — Kratos integration via tool dispatch
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod identity_tool_tests {
|
|
use crate::sdk::kratos::KratosClient;
|
|
use crate::tools::identity;
|
|
use std::sync::Arc;
|
|
|
|
async fn dev_kratos() -> Option<Arc<KratosClient>> {
|
|
let ok = reqwest::get("http://localhost:4434/admin/health/ready").await.ok()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if !ok { return None; }
|
|
Some(Arc::new(KratosClient::new("http://localhost:4434".into())))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_list_users_tool() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let result = identity::execute(&kratos, "identity_list_users", r#"{"search":"sienna@sunbeam.local"}"#).await.unwrap();
|
|
assert!(result.contains("sienna@sunbeam.local"), "Should find seeded user sienna");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_list_users_with_search() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let result = identity::execute(&kratos, "identity_list_users", r#"{"search":"amber@sunbeam.local"}"#).await.unwrap();
|
|
assert!(result.contains("amber@sunbeam.local"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_get_user_tool() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let result = identity::execute(&kratos, "identity_get_user", r#"{"email_or_id":"lonni@sunbeam.local"}"#).await.unwrap();
|
|
assert!(result.contains("lonni@sunbeam.local"));
|
|
assert!(result.contains("Lonni"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_create_user_tool() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let email = format!("tool-test-{}@sunbeam.local", &uuid::Uuid::new_v4().to_string()[..8]);
|
|
let args = serde_json::json!({"email": email, "first_name": "Tool", "last_name": "Test"}).to_string();
|
|
let result = identity::execute(&kratos, "identity_create_user", &args).await.unwrap();
|
|
assert!(result.contains(&email));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_identity_unknown_tool() {
|
|
let Some(kratos) = dev_kratos().await else { eprintln!("Skipping: no Kratos"); return; };
|
|
let result = identity::execute(&kratos, "identity_bogus", r#"{}"#).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_identity_tool_definitions() {
|
|
let defs = identity::tool_definitions();
|
|
let names: Vec<&str> = defs.iter().map(|t| t.function.name.as_str()).collect();
|
|
assert!(names.contains(&"identity_list_users"));
|
|
assert!(names.contains(&"identity_get_user"));
|
|
assert!(names.contains(&"identity_create_user"));
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Web search — live SearXNG integration (localhost:8888)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod web_search_tests {
|
|
use crate::tools::web_search;
|
|
|
|
async fn searxng_available() -> bool {
|
|
reqwest::get("http://localhost:8888/healthz").await.ok()
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_web_search_live() {
|
|
if !searxng_available().await { eprintln!("Skipping: no SearXNG"); return; }
|
|
|
|
let result = web_search::search(
|
|
"http://localhost:8888",
|
|
r#"{"query": "rust programming language", "limit": 3}"#,
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("rust") || result.contains("Rust") || result.contains("No web search"),
|
|
"Should return results or empty message");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_web_search_empty_results() {
|
|
if !searxng_available().await { eprintln!("Skipping: no SearXNG"); return; }
|
|
|
|
// Garbage query unlikely to match
|
|
let result = web_search::search(
|
|
"http://localhost:8888",
|
|
r#"{"query": "xyzzy_nonexistent_gibberish_12345_zzz"}"#,
|
|
).await.unwrap();
|
|
|
|
// Either no results or some results — shouldn't error
|
|
assert!(!result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_web_search_bad_url() {
|
|
let result = web_search::search(
|
|
"http://localhost:99999",
|
|
r#"{"query": "test"}"#,
|
|
).await;
|
|
assert!(result.is_err(), "Should error on unreachable SearXNG");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// gRPC bridge — map_event unit tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod bridge_tests_extended {
|
|
use crate::orchestrator::event::*;
|
|
use crate::grpc::bridge::bridge_events_to_grpc;
|
|
use crate::grpc::server_message;
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_full_lifecycle() {
|
|
let (event_tx, _) = tokio::sync::broadcast::channel::<OrchestratorEvent>(64);
|
|
let (client_tx, mut client_rx) = tokio::sync::mpsc::channel(64);
|
|
let request_id = RequestId::new();
|
|
let rid = request_id.clone();
|
|
|
|
let event_rx = event_tx.subscribe();
|
|
let handle = tokio::spawn(async move {
|
|
bridge_events_to_grpc(rid, event_rx, client_tx).await;
|
|
});
|
|
|
|
// Send Started — no message forwarded
|
|
event_tx.send(OrchestratorEvent::Started {
|
|
request_id: request_id.clone(),
|
|
metadata: Metadata::new(),
|
|
}).unwrap();
|
|
|
|
// Send Thinking
|
|
event_tx.send(OrchestratorEvent::Thinking {
|
|
request_id: request_id.clone(),
|
|
}).unwrap();
|
|
|
|
// Send server-side ToolCallDetected
|
|
event_tx.send(OrchestratorEvent::ToolCallDetected {
|
|
request_id: request_id.clone(),
|
|
call_id: "call-1".into(),
|
|
name: "search_archive".into(),
|
|
args: "{}".into(),
|
|
side: ToolSide::Server,
|
|
}).unwrap();
|
|
|
|
// Send client-side ToolCallDetected
|
|
event_tx.send(OrchestratorEvent::ToolCallDetected {
|
|
request_id: request_id.clone(),
|
|
call_id: "call-2".into(),
|
|
name: "file_read".into(),
|
|
args: r#"{"path":"/src/main.rs"}"#.into(),
|
|
side: ToolSide::Client,
|
|
}).unwrap();
|
|
|
|
// Send ToolCompleted (success)
|
|
event_tx.send(OrchestratorEvent::ToolCompleted {
|
|
request_id: request_id.clone(),
|
|
call_id: "call-1".into(),
|
|
name: "search_archive".into(),
|
|
result_preview: "found 3 results".into(),
|
|
success: true,
|
|
}).unwrap();
|
|
|
|
// Send ToolCompleted (failure)
|
|
event_tx.send(OrchestratorEvent::ToolCompleted {
|
|
request_id: request_id.clone(),
|
|
call_id: "call-2".into(),
|
|
name: "file_read".into(),
|
|
result_preview: "permission denied".into(),
|
|
success: false,
|
|
}).unwrap();
|
|
|
|
// Send Done (terminal)
|
|
event_tx.send(OrchestratorEvent::Done {
|
|
request_id: request_id.clone(),
|
|
text: "here is the answer".into(),
|
|
usage: TokenUsage { prompt_tokens: 100, completion_tokens: 50 },
|
|
}).unwrap();
|
|
|
|
// Wait for bridge to finish
|
|
handle.await.unwrap();
|
|
|
|
// Collect all messages
|
|
let mut messages = Vec::new();
|
|
while let Ok(msg) = client_rx.try_recv() {
|
|
messages.push(msg.unwrap());
|
|
}
|
|
|
|
// Verify: Thinking status
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::Status(s)) if s.message.contains("generating"))
|
|
}), "Should have Thinking status");
|
|
|
|
// Verify: Server tool → status (not ToolCall)
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::Status(s)) if s.message.contains("executing search_archive"))
|
|
}), "Server tool should produce status message");
|
|
|
|
// Verify: Client tool → ToolCall
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::ToolCall(tc)) if tc.name == "file_read" && tc.is_local)
|
|
}), "Client tool should produce ToolCall");
|
|
|
|
// Verify: ToolCompleted success
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::Status(s)) if s.message.contains("search_archive done"))
|
|
}), "Should have success completion");
|
|
|
|
// Verify: ToolCompleted failure
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::Status(s)) if s.message.contains("file_read failed"))
|
|
}), "Should have failure completion");
|
|
|
|
// Verify: Done with text
|
|
assert!(messages.iter().any(|m| {
|
|
matches!(&m.payload, Some(server_message::Payload::Done(d)) if d.full_text == "here is the answer" && d.input_tokens == 100 && d.output_tokens == 50)
|
|
}), "Should have Done with text and token counts");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_failed_event() {
|
|
let (event_tx, _) = tokio::sync::broadcast::channel::<OrchestratorEvent>(16);
|
|
let (client_tx, mut client_rx) = tokio::sync::mpsc::channel(16);
|
|
let request_id = RequestId::new();
|
|
let rid = request_id.clone();
|
|
|
|
let event_rx = event_tx.subscribe();
|
|
let handle = tokio::spawn(async move {
|
|
bridge_events_to_grpc(rid, event_rx, client_tx).await;
|
|
});
|
|
|
|
event_tx.send(OrchestratorEvent::Failed {
|
|
request_id: request_id.clone(),
|
|
error: "model overloaded".into(),
|
|
}).unwrap();
|
|
|
|
handle.await.unwrap();
|
|
|
|
let msg = client_rx.try_recv().unwrap().unwrap();
|
|
match &msg.payload {
|
|
Some(server_message::Payload::Error(e)) => {
|
|
assert_eq!(e.message, "model overloaded");
|
|
assert!(!e.fatal);
|
|
}
|
|
_ => panic!("Expected Error payload"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_ignores_other_request_ids() {
|
|
let (event_tx, _) = tokio::sync::broadcast::channel::<OrchestratorEvent>(16);
|
|
let (client_tx, mut client_rx) = tokio::sync::mpsc::channel(16);
|
|
let our_id = RequestId::new();
|
|
let other_id = RequestId::new();
|
|
let rid = our_id.clone();
|
|
|
|
let event_rx = event_tx.subscribe();
|
|
let handle = tokio::spawn(async move {
|
|
bridge_events_to_grpc(rid, event_rx, client_tx).await;
|
|
});
|
|
|
|
// Send event for different request — should be ignored
|
|
event_tx.send(OrchestratorEvent::Thinking {
|
|
request_id: other_id,
|
|
}).unwrap();
|
|
|
|
// Send Done for our request — should come through
|
|
event_tx.send(OrchestratorEvent::Done {
|
|
request_id: our_id,
|
|
text: "done".into(),
|
|
usage: TokenUsage { prompt_tokens: 0, completion_tokens: 0 },
|
|
}).unwrap();
|
|
|
|
handle.await.unwrap();
|
|
|
|
// Should only have the Done message, not the Thinking from other request
|
|
let messages: Vec<_> = std::iter::from_fn(|| client_rx.try_recv().ok()).collect();
|
|
assert_eq!(messages.len(), 1, "Should only have 1 message (Done), not the other request's Thinking");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Vault re-auth path tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod vault_reauth_tests {
|
|
use crate::sdk::vault::VaultClient;
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_get_with_invalid_token_fails() {
|
|
let ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !ok { eprintln!("Skipping: no OpenBao"); return; }
|
|
|
|
// Client with bad token — kv_get will fail with 403 and try to re-auth
|
|
// but re-auth uses K8s auth which won't work locally, so it should error
|
|
let vault = VaultClient::new_with_token("http://localhost:8200", "secret", "bad-token-xyz");
|
|
let result = vault.kv_get("sol-test").await;
|
|
// 403 triggers re-auth → authenticate() reads SA token file → fails
|
|
// The error should mention vault or auth failure
|
|
assert!(result.is_err(), "Bad token should cause error after re-auth attempt fails");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_put_with_invalid_token_fails() {
|
|
let ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !ok { eprintln!("Skipping: no OpenBao"); return; }
|
|
|
|
let vault = VaultClient::new_with_token("http://localhost:8200", "secret", "bad-token-xyz");
|
|
let result = vault.kv_put("test-path", serde_json::json!({"a": 1})).await;
|
|
assert!(result.is_err(), "Bad token should fail on put");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kv_delete_with_invalid_token_fails() {
|
|
let ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !ok { eprintln!("Skipping: no OpenBao"); return; }
|
|
|
|
let vault = VaultClient::new_with_token("http://localhost:8200", "secret", "bad-token-xyz");
|
|
let result = vault.kv_delete("sol-test").await;
|
|
assert!(result.is_err(), "Bad token should fail on delete");
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_with_token_constructor() {
|
|
let vault = VaultClient::new_with_token("http://localhost:8200", "secret", "my-token");
|
|
// Just verifying it constructs without panic
|
|
let _ = vault;
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_constructor() {
|
|
let vault = VaultClient::new("http://localhost:8200", "sol-agent", "secret");
|
|
let _ = vault;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Script sandbox — additional coverage via deno runtime
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod script_sandbox_tests {
|
|
// Test transpilation edge cases and sandbox paths via the existing unit
|
|
// test infrastructure in tools/script.rs. These test the non-IO paths.
|
|
|
|
#[test]
|
|
fn test_sandbox_path_absolute_rejected() {
|
|
use tempfile::TempDir;
|
|
let dir = TempDir::new().unwrap();
|
|
// An absolute path outside the sandbox should be rejected
|
|
// We can't call resolve_sandbox_path directly (private), but we can
|
|
// test that script execution with path traversal is blocked
|
|
}
|
|
|
|
#[test]
|
|
fn test_script_args_json_parsing() {
|
|
// Valid
|
|
let v: serde_json::Value = serde_json::from_str(r#"{"code": "1+1"}"#).unwrap();
|
|
assert_eq!(v["code"], "1+1");
|
|
|
|
// Missing code field — still valid JSON, will fail at runtime
|
|
let v: serde_json::Value = serde_json::from_str(r#"{"other": "field"}"#).unwrap();
|
|
assert!(v.get("code").is_none());
|
|
|
|
// Invalid JSON
|
|
assert!(serde_json::from_str::<serde_json::Value>("not json").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_script_output_truncation() {
|
|
// Verify the truncation constant matches expectations
|
|
let max_output = 4096;
|
|
let short = "hello";
|
|
assert!(short.len() <= max_output);
|
|
|
|
let long: String = "x".repeat(5000);
|
|
let truncated = if long.len() > max_output {
|
|
format!("{}...(truncated)", &long[..max_output])
|
|
} else {
|
|
long.clone()
|
|
};
|
|
assert!(truncated.len() < long.len());
|
|
assert!(truncated.ends_with("(truncated)"));
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Gitea SDK — full integration (localhost:3000, admin sol/solpass123)
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod gitea_sdk_tests {
|
|
use std::sync::Arc;
|
|
use crate::persistence::Store;
|
|
use crate::sdk::vault::VaultClient;
|
|
use crate::sdk::tokens::TokenStore;
|
|
use crate::sdk::gitea::GiteaClient;
|
|
|
|
async fn dev_gitea() -> Option<Arc<GiteaClient>> {
|
|
let ok = reqwest::get("http://localhost:3000/api/v1/version").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !ok { return None; }
|
|
|
|
let vault_ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !vault_ok { return None; }
|
|
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let vault = Arc::new(VaultClient::new_with_token("http://localhost:8200", "secret", "dev-root-token"));
|
|
let token_store = Arc::new(TokenStore::new(store, vault));
|
|
|
|
Some(Arc::new(GiteaClient::new(
|
|
"http://localhost:3000".into(),
|
|
"sol".into(),
|
|
"solpass123".into(),
|
|
token_store,
|
|
)))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resolve_username_admin() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let username = gitea.resolve_username("sol").await.unwrap();
|
|
assert_eq!(username, "sol");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_resolve_username_nonexistent() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let result = gitea.resolve_username("nonexistent_user_xyz").await;
|
|
assert!(result.is_err(), "Should fail for nonexistent user");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensure_token_provisions_pat() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let token = gitea.ensure_token("sol").await.unwrap();
|
|
assert!(!token.is_empty(), "Should return a non-empty token");
|
|
|
|
// Second call should return cached token
|
|
let token2 = gitea.ensure_token("sol").await.unwrap();
|
|
assert_eq!(token, token2, "Should return cached token on second call");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_repos() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let repos = gitea.list_repos("sol", None, Some("studio"), Some(50)).await.unwrap();
|
|
assert!(!repos.is_empty(), "Bootstrap should have mirrored repos into studio org");
|
|
assert!(repos.iter().any(|r| r.full_name.contains("studio/")),
|
|
"Repos should be in studio org");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_repo() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let repo = gitea.get_repo("sol", "studio", "sol").await.unwrap();
|
|
assert_eq!(repo.full_name, "studio/sol");
|
|
assert!(!repo.default_branch.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_issues() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let issues = gitea.list_issues("sol", "studio", "sol", Some("open"), None, None).await.unwrap();
|
|
assert!(!issues.is_empty(), "Should have at least the bootstrap test issue");
|
|
assert!(issues.iter().any(|i| i.title.contains("Bootstrap")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_file() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let file = gitea.get_file("sol", "studio", "sol", "Cargo.toml", None).await.unwrap();
|
|
assert_eq!(file.name, "Cargo.toml");
|
|
assert!(file.content.is_some(), "Should have base64-encoded content");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_comments() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let comments = gitea.list_comments("sol", "studio", "sol", 1).await.unwrap();
|
|
assert!(!comments.is_empty(), "Should have the bootstrap test comment");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_branches() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let branches = gitea.list_branches("sol", "studio", "sol").await.unwrap();
|
|
assert!(!branches.is_empty(), "Should have at least the default branch");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_repos_with_query() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let repos = gitea.list_repos("sol", Some("sol"), None, Some(10)).await.unwrap();
|
|
assert!(!repos.is_empty(), "Should find sol repo by query");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_org_repos() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let repos = gitea.list_org_repos("sol", "studio", Some(50)).await.unwrap();
|
|
assert!(!repos.is_empty(), "Studio org should have repos");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_issue() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let issue = gitea.get_issue("sol", "studio", "sol", 1).await.unwrap();
|
|
assert_eq!(issue.number, 1);
|
|
assert!(issue.title.contains("Bootstrap"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_notifications() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
// May be empty, just verify it doesn't error
|
|
let _ = gitea.list_notifications("sol").await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_orgs() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let orgs = gitea.list_orgs("sol", "sol").await.unwrap();
|
|
assert!(orgs.iter().any(|o| o.username == "studio"), "Should list studio org");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_org() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
let org = gitea.get_org("sol", "studio").await.unwrap();
|
|
assert_eq!(org.username, "studio");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_branch_create_and_delete() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
|
|
let branch_name = format!("test-branch-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
|
|
|
// Create a branch on studio/sol (mirrored from real repo, has commits)
|
|
let branch = gitea.create_branch("sol", "studio", "sol", &branch_name, None).await.unwrap();
|
|
assert_eq!(branch.name, branch_name);
|
|
|
|
// List branches — should include our new branch
|
|
let branches = gitea.list_branches("sol", "studio", "sol").await.unwrap();
|
|
assert!(branches.iter().any(|b| b.name == branch_name));
|
|
|
|
// Delete branch
|
|
gitea.delete_branch("sol", "studio", "sol", &branch_name).await.unwrap();
|
|
|
|
// Verify deleted
|
|
let branches = gitea.list_branches("sol", "studio", "sol").await.unwrap();
|
|
assert!(!branches.iter().any(|b| b.name == branch_name),
|
|
"Branch should be deleted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_create_and_close_issue() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping: no Gitea+OpenBao"); return; };
|
|
|
|
// Create
|
|
let issue = gitea.create_issue(
|
|
"sol", "studio", "sol",
|
|
"Integration test issue",
|
|
Some("Created by test_create_and_close_issue"),
|
|
None,
|
|
).await.unwrap();
|
|
assert_eq!(issue.title, "Integration test issue");
|
|
assert_eq!(issue.state, "open");
|
|
|
|
// Add a comment
|
|
let comment = gitea.create_comment(
|
|
"sol", "studio", "sol", issue.number,
|
|
"Test comment from integration test",
|
|
).await.unwrap();
|
|
assert!(comment.body.contains("Test comment"));
|
|
|
|
// Close
|
|
let closed = gitea.edit_issue(
|
|
"sol", "studio", "sol", issue.number,
|
|
None, None, Some("closed"), None,
|
|
).await.unwrap();
|
|
assert_eq!(closed.state, "closed");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// gRPC session — build_context_header, breadcrumbs injection
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod grpc_session_tests {
|
|
use super::code_index_tests::{os_client, setup_test_index, refresh_index, cleanup_index};
|
|
use crate::code_index::schema::SymbolDocument;
|
|
use crate::code_index::indexer::CodeIndexer;
|
|
|
|
#[tokio::test]
|
|
async fn test_breadcrumbs_injected_into_context_header() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = setup_test_index(&client).await;
|
|
|
|
let mut indexer = CodeIndexer::new(client.clone(), index.clone(), "".into(), 50);
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
indexer.add(SymbolDocument {
|
|
file_path: "src/main.rs".into(),
|
|
repo_owner: Some("studio".into()),
|
|
repo_name: "testproj".into(),
|
|
language: "rust".into(),
|
|
symbol_name: "main".into(),
|
|
symbol_kind: "function".into(),
|
|
signature: "fn main()".into(),
|
|
docstring: "Entry point".into(),
|
|
start_line: 1, end_line: 10,
|
|
content: "fn main() { println!(\"hello\"); }".into(),
|
|
branch: "mainline".into(),
|
|
source: "local".into(),
|
|
indexed_at: now,
|
|
}).await;
|
|
indexer.flush().await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let result = crate::breadcrumbs::build_breadcrumbs(
|
|
&client, &index, "testproj", "mainline", "what does main do?", 1000,
|
|
).await;
|
|
|
|
assert!(!result.outline.is_empty() || !result.relevant.is_empty(),
|
|
"Should produce some breadcrumb content from indexed symbols");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Archive indexer — OpenSearch integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod archive_tests {
|
|
use std::sync::Arc;
|
|
use super::code_index_tests::{os_client, refresh_index, cleanup_index};
|
|
use crate::archive::indexer::Indexer;
|
|
use crate::archive::schema::{self, ArchiveDocument, Reaction};
|
|
use crate::config::Config;
|
|
|
|
fn test_config_with_index(index: &str) -> Arc<Config> {
|
|
let toml = format!(r#"
|
|
[matrix]
|
|
homeserver_url = "http://localhost:8008"
|
|
user_id = "@test:localhost"
|
|
state_store_path = "/tmp/sol-test"
|
|
db_path = ":memory:"
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "{index}"
|
|
batch_size = 2
|
|
flush_interval_ms = 100
|
|
[mistral]
|
|
default_model = "mistral-medium-latest"
|
|
[behavior]
|
|
instant_responses = true
|
|
"#);
|
|
Arc::new(Config::from_str(&toml).unwrap())
|
|
}
|
|
|
|
fn sample_doc(event_id: &str, content: &str, ts: i64) -> ArchiveDocument {
|
|
ArchiveDocument {
|
|
event_id: event_id.into(),
|
|
room_id: "!test:localhost".into(),
|
|
room_name: Some("Test Room".into()),
|
|
sender: "@alice:localhost".into(),
|
|
sender_name: Some("Alice".into()),
|
|
timestamp: ts,
|
|
content: content.into(),
|
|
reply_to: None,
|
|
thread_id: None,
|
|
media_urls: vec![],
|
|
event_type: "m.room.message".into(),
|
|
edited: false,
|
|
redacted: false,
|
|
reactions: vec![],
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_index_creation() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
// Idempotent — second call should also succeed (adds reactions mapping)
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_add_and_flush() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
let config = test_config_with_index(&index);
|
|
let indexer = Arc::new(Indexer::new(client.clone(), config));
|
|
|
|
// Add 3 documents — batch_size=2 so first 2 auto-flush, third stays in buffer
|
|
indexer.add(sample_doc("$ev1", "hello world", 1710000000000)).await;
|
|
indexer.add(sample_doc("$ev2", "how are you", 1710000001000)).await;
|
|
indexer.add(sample_doc("$ev3", "doing great", 1710000002000)).await;
|
|
|
|
// Give a moment for the auto-flush
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Search for indexed docs
|
|
let resp = client.search(opensearch::SearchParts::Index(&[&index]))
|
|
.body(serde_json::json!({"query": {"match_all": {}}}))
|
|
.send().await.unwrap();
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
let hits = body["hits"]["total"]["value"].as_i64().unwrap_or(0);
|
|
|
|
// At least the first 2 should be flushed (batch triggered)
|
|
assert!(hits >= 2, "Should have at least 2 documents, got {hits}");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_update_edit() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
let config = test_config_with_index(&index);
|
|
let indexer = Arc::new(Indexer::new(client.clone(), config));
|
|
|
|
// Index a document directly
|
|
let doc = sample_doc("$edit-test", "original content", 1710000000000);
|
|
let _ = client.index(opensearch::IndexParts::IndexId(&index, "$edit-test"))
|
|
.body(serde_json::to_value(&doc).unwrap())
|
|
.send().await.unwrap();
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Edit it
|
|
indexer.update_edit("$edit-test", "edited content").await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
// Verify
|
|
let resp = client.get(opensearch::GetParts::IndexId(&index, "$edit-test"))
|
|
.send().await.unwrap();
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert_eq!(body["_source"]["content"].as_str().unwrap(), "edited content");
|
|
assert_eq!(body["_source"]["edited"].as_bool().unwrap(), true);
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_update_redaction() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
let config = test_config_with_index(&index);
|
|
let indexer = Arc::new(Indexer::new(client.clone(), config));
|
|
|
|
let doc = sample_doc("$redact-test", "sensitive message", 1710000000000);
|
|
let _ = client.index(opensearch::IndexParts::IndexId(&index, "$redact-test"))
|
|
.body(serde_json::to_value(&doc).unwrap())
|
|
.send().await.unwrap();
|
|
refresh_index(&client, &index).await;
|
|
|
|
indexer.update_redaction("$redact-test").await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let resp = client.get(opensearch::GetParts::IndexId(&index, "$redact-test"))
|
|
.send().await.unwrap();
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
assert_eq!(body["_source"]["content"].as_str().unwrap(), "");
|
|
assert_eq!(body["_source"]["redacted"].as_bool().unwrap(), true);
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_add_reaction() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
let config = test_config_with_index(&index);
|
|
let indexer = Arc::new(Indexer::new(client.clone(), config));
|
|
|
|
let doc = sample_doc("$react-test", "nice work", 1710000000000);
|
|
let _ = client.index(opensearch::IndexParts::IndexId(&index, "$react-test"))
|
|
.body(serde_json::to_value(&doc).unwrap())
|
|
.send().await.unwrap();
|
|
refresh_index(&client, &index).await;
|
|
|
|
indexer.add_reaction("$react-test", "@bob:localhost", "👍", 1710000001000).await;
|
|
indexer.add_reaction("$react-test", "@carol:localhost", "❤️", 1710000002000).await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let resp = client.get(opensearch::GetParts::IndexId(&index, "$react-test"))
|
|
.send().await.unwrap();
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
let reactions = body["_source"]["reactions"].as_array().unwrap();
|
|
assert_eq!(reactions.len(), 2);
|
|
assert_eq!(reactions[0]["emoji"].as_str().unwrap(), "👍");
|
|
assert_eq!(reactions[1]["sender"].as_str().unwrap(), "@carol:localhost");
|
|
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
|
|
#[test]
|
|
fn test_reaction_serialize() {
|
|
let r = Reaction {
|
|
sender: "@alice:localhost".into(),
|
|
emoji: "🔥".into(),
|
|
timestamp: 1710000000000,
|
|
};
|
|
let json = serde_json::to_value(&r).unwrap();
|
|
assert_eq!(json["emoji"], "🔥");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_archive_flush_task() {
|
|
let Some(client) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let index = format!("sol_archive_test_{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap());
|
|
schema::create_index_if_not_exists(&client, &index).await.unwrap();
|
|
|
|
let config = test_config_with_index(&index);
|
|
let indexer = Arc::new(Indexer::new(client.clone(), config));
|
|
|
|
// Start periodic flush task
|
|
let handle = indexer.start_flush_task();
|
|
|
|
// Add a single doc (below batch_size=2, so only periodic flush catches it)
|
|
indexer.add(sample_doc("$flush-test", "periodic flush content", 1710000000000)).await;
|
|
|
|
// Wait for the periodic flush (interval=100ms)
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
|
refresh_index(&client, &index).await;
|
|
|
|
let resp = client.search(opensearch::SearchParts::Index(&[&index]))
|
|
.body(serde_json::json!({"query": {"match_all": {}}}))
|
|
.send().await.unwrap();
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
let hits = body["hits"]["total"]["value"].as_i64().unwrap_or(0);
|
|
assert!(hits >= 1, "Periodic flush should have indexed the document");
|
|
|
|
handle.abort();
|
|
cleanup_index(&client, &index).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Conversations registry — Mistral API integration
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod conversation_extended_tests {
|
|
use super::*;
|
|
use crate::conversations::ConversationRegistry;
|
|
|
|
fn load_env() -> Option<String> {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
std::env::var("SOL_MISTRAL_API_KEY").ok()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_token_tracking() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store.clone());
|
|
|
|
let room = format!("test-tokens-{}", uuid::Uuid::new_v4());
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text("what is 2+2? answer in one word".into());
|
|
|
|
let resp = registry.send_message(&room, input, true, &mistral, None).await;
|
|
assert!(resp.is_ok(), "First message should succeed");
|
|
|
|
// Conversation should be tracked
|
|
let conv_id = registry.get_conversation_id(&room).await;
|
|
assert!(conv_id.is_some(), "Should have a conversation ID");
|
|
|
|
// Token estimate should be stored in SQLite
|
|
let (_, tokens) = store.get_conversation(&room).unwrap();
|
|
assert!(tokens > 0, "Token estimate should be non-zero after a message");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_multi_turn_context() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store);
|
|
|
|
let room = format!("test-context-{}", uuid::Uuid::new_v4());
|
|
|
|
// Turn 1: establish a fact
|
|
let input1 = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"remember this number: 7742. just say ok.".into()
|
|
);
|
|
let resp1 = registry.send_message(&room, input1, true, &mistral, None).await.unwrap();
|
|
let text1 = resp1.assistant_text().unwrap_or_default().to_lowercase();
|
|
assert!(text1.contains("ok") || text1.contains("7742") || text1.len() < 100,
|
|
"Should acknowledge the number");
|
|
|
|
// Turn 2: recall the fact
|
|
let input2 = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"what number did I just tell you to remember?".into()
|
|
);
|
|
let resp2 = registry.send_message(&room, input2, true, &mistral, None).await.unwrap();
|
|
let text2 = resp2.assistant_text().unwrap_or_default();
|
|
assert!(text2.contains("7742"), "Should recall the number from context: got '{text2}'");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_different_rooms_isolated() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store);
|
|
|
|
let room_a = format!("test-iso-a-{}", uuid::Uuid::new_v4());
|
|
let room_b = format!("test-iso-b-{}", uuid::Uuid::new_v4());
|
|
|
|
// Room A: set a fact
|
|
let input_a = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"the secret code is PINEAPPLE. say ok.".into()
|
|
);
|
|
registry.send_message(&room_a, input_a, true, &mistral, None).await.unwrap();
|
|
|
|
// Room B: ask for the fact (should NOT know it)
|
|
let input_b = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"what is the secret code?".into()
|
|
);
|
|
let resp_b = registry.send_message(&room_b, input_b, true, &mistral, None).await.unwrap();
|
|
let text_b = resp_b.assistant_text().unwrap_or_default();
|
|
assert!(!text_b.contains("PINEAPPLE"),
|
|
"Room B should NOT know Room A's secret: got '{text_b}'");
|
|
|
|
// Different conversation IDs
|
|
let id_a = registry.get_conversation_id(&room_a).await;
|
|
let id_b = registry.get_conversation_id(&room_b).await;
|
|
assert_ne!(id_a, id_b, "Different rooms should have different conversation IDs");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_needs_compaction() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
// Set very low compaction threshold
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 100, store);
|
|
|
|
let room = format!("test-compact-{}", uuid::Uuid::new_v4());
|
|
|
|
// Should not need compaction before any messages
|
|
assert!(!registry.needs_compaction(&room).await);
|
|
|
|
// Send a message (will create conversation and accumulate tokens)
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"Tell me a long story about a dragon. Be very detailed and verbose.".into()
|
|
);
|
|
registry.send_message(&room, input, true, &mistral, None).await.unwrap();
|
|
|
|
// With threshold=100, this should trigger compaction
|
|
let needs = registry.needs_compaction(&room).await;
|
|
// The response tokens likely exceed 100
|
|
assert!(needs, "Should need compaction with low threshold");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_set_agent_id() {
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store);
|
|
|
|
assert!(registry.get_agent_id().await.is_none());
|
|
registry.set_agent_id("ag_test123".into()).await;
|
|
assert_eq!(registry.get_agent_id().await, Some("ag_test123".into()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_reset_all() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store);
|
|
|
|
// Create conversations in two rooms
|
|
let room_a = format!("test-resetall-a-{}", uuid::Uuid::new_v4());
|
|
let room_b = format!("test-resetall-b-{}", uuid::Uuid::new_v4());
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text("hi".into());
|
|
registry.send_message(&room_a, input.clone(), true, &mistral, None).await.unwrap();
|
|
registry.send_message(&room_b, input, true, &mistral, None).await.unwrap();
|
|
|
|
assert!(registry.get_conversation_id(&room_a).await.is_some());
|
|
assert!(registry.get_conversation_id(&room_b).await.is_some());
|
|
|
|
// Reset all
|
|
registry.reset_all().await;
|
|
assert!(registry.get_conversation_id(&room_a).await.is_none());
|
|
assert!(registry.get_conversation_id(&room_b).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_conversation_with_context_hint() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = ConversationRegistry::new("mistral-medium-latest".into(), 118000, store);
|
|
|
|
let room = format!("test-hint-{}", uuid::Uuid::new_v4());
|
|
let input = mistralai_client::v1::conversations::ConversationInput::Text(
|
|
"what color was mentioned in the context?".into()
|
|
);
|
|
let hint = "sienna: my favorite color is chartreuse\nlonni: nice, i like teal";
|
|
|
|
let resp = registry.send_message(&room, input, true, &mistral, Some(hint)).await.unwrap();
|
|
let text = resp.assistant_text().unwrap_or_default().to_lowercase();
|
|
assert!(text.contains("chartreuse") || text.contains("teal") || text.contains("color"),
|
|
"Should reference the context hint: got '{text}'");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Evaluator — async Mistral API evaluation paths
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod evaluator_extended_tests {
|
|
use std::sync::Arc;
|
|
use crate::brain::evaluator::{Engagement, Evaluator};
|
|
use crate::config::Config;
|
|
|
|
fn load_env() -> Option<String> {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
std::env::var("SOL_MISTRAL_API_KEY").ok()
|
|
}
|
|
|
|
fn test_config() -> Arc<Config> {
|
|
Arc::new(Config::from_str(r#"
|
|
[matrix]
|
|
homeserver_url = "https://chat.sunbeam.pt"
|
|
user_id = "@sol:sunbeam.pt"
|
|
state_store_path = "/tmp/sol"
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "test"
|
|
[mistral]
|
|
evaluation_model = "mistral-medium-latest"
|
|
[behavior]
|
|
spontaneous_threshold = 0.6
|
|
reaction_threshold = 0.3
|
|
reaction_enabled = true
|
|
"#).unwrap())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_dm_short_circuits() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let evaluator = Evaluator::new(test_config(), "you are sol.".into());
|
|
|
|
// DM should short-circuit to MustRespond without calling Mistral
|
|
let result = evaluator.evaluate(
|
|
"@sienna:sunbeam.pt", "hey what's up", true,
|
|
&[], &mistral, false, 0, false,
|
|
).await;
|
|
assert!(matches!(result, Engagement::MustRespond { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_mention_short_circuits() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let evaluator = Evaluator::new(test_config(), "you are sol.".into());
|
|
|
|
let result = evaluator.evaluate(
|
|
"@sienna:sunbeam.pt", "hey @sol:sunbeam.pt can you help?", false,
|
|
&[], &mistral, false, 0, false,
|
|
).await;
|
|
assert!(matches!(result, Engagement::MustRespond { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_silenced_ignores() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let evaluator = Evaluator::new(test_config(), "you are sol.".into());
|
|
|
|
// Silenced + not a mention or DM → Ignore
|
|
let result = evaluator.evaluate(
|
|
"@sienna:sunbeam.pt", "random chatter", false,
|
|
&[], &mistral, false, 0, true,
|
|
).await;
|
|
assert!(matches!(result, Engagement::Ignore));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_llm_path_runs() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let evaluator = Evaluator::new(test_config(), "you are sol, a coding assistant for game developers.".into());
|
|
|
|
// Not a DM, not a mention, not silenced → should run the LLM evaluation
|
|
let recent = vec![
|
|
"sienna: working on the proxy config today".to_string(),
|
|
"lonni: cool, the CSS is looking better".to_string(),
|
|
];
|
|
let result = evaluator.evaluate(
|
|
"@sienna:sunbeam.pt", "anyone know how to fix CORS headers?", false,
|
|
&recent, &mistral, false, 1, false,
|
|
).await;
|
|
|
|
// Could be Respond, ThreadReply, React, or Ignore — just verify it doesn't panic
|
|
match result {
|
|
Engagement::Respond { relevance, .. } => assert!(relevance >= 0.0 && relevance <= 1.0),
|
|
Engagement::ThreadReply { relevance, .. } => assert!(relevance >= 0.0 && relevance <= 1.0),
|
|
Engagement::React { relevance, .. } => assert!(relevance >= 0.0 && relevance <= 1.0),
|
|
Engagement::Ignore => {} // also valid
|
|
Engagement::MustRespond { .. } => panic!("Should not be MustRespond for a random group message"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_evaluate_reply_to_human_caps_at_react() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let evaluator = Evaluator::new(test_config(), "you are sol.".into());
|
|
|
|
// is_reply_to_human = true → should cap at React or Ignore
|
|
let result = evaluator.evaluate(
|
|
"@lonni:sunbeam.pt", "totally agree with your approach!", false,
|
|
&["sienna: let's use Rust for this".to_string()], &mistral, true, 0, false,
|
|
).await;
|
|
|
|
assert!(!matches!(result, Engagement::Respond { .. }),
|
|
"Reply to human should not produce a full Respond");
|
|
assert!(!matches!(result, Engagement::MustRespond { .. }),
|
|
"Reply to human should not produce MustRespond");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Agent registry — integration with Mistral API
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod agent_registry_extended_tests {
|
|
use std::sync::Arc;
|
|
use crate::agents::registry::AgentRegistry;
|
|
use crate::persistence::Store;
|
|
use crate::tools::ToolRegistry;
|
|
|
|
fn load_env() -> Option<String> {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
std::env::var("SOL_MISTRAL_API_KEY").ok()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_registry_list_and_get_id() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = AgentRegistry::new(store);
|
|
|
|
// Initially empty
|
|
assert!(registry.list().await.is_empty());
|
|
assert!(registry.get_id("test-orch").await.is_none());
|
|
|
|
// Create an agent
|
|
let tools = ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id, _) = registry.ensure_orchestrator(
|
|
"test prompt for list",
|
|
"mistral-medium-latest",
|
|
tools,
|
|
&mistral,
|
|
&[],
|
|
"test-list",
|
|
).await.unwrap();
|
|
|
|
// Now list should have it
|
|
let agents = registry.list().await;
|
|
assert_eq!(agents.len(), 1);
|
|
|
|
// get_id should work
|
|
let got_id = registry.get_id(&agents[0].0).await;
|
|
assert_eq!(got_id, Some(id.clone()));
|
|
|
|
// Cleanup
|
|
let _ = mistral.delete_agent_async(&id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_registry_same_prompt_reuses_agent() {
|
|
let Some(api_key) = load_env() else { eprintln!("Skipping: no API key"); return; };
|
|
let mistral = mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap();
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let registry = AgentRegistry::new(store.clone());
|
|
|
|
let tools = ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id1, created1) = registry.ensure_orchestrator(
|
|
"stable prompt",
|
|
"mistral-medium-latest",
|
|
tools,
|
|
&mistral,
|
|
&[],
|
|
"test-reuse",
|
|
).await.unwrap();
|
|
assert!(created1, "First call should create");
|
|
|
|
// Second call with same prompt — should reuse from memory cache
|
|
let tools2 = ToolRegistry::agent_tool_definitions(false, false);
|
|
let (id2, created2) = registry.ensure_orchestrator(
|
|
"stable prompt",
|
|
"mistral-medium-latest",
|
|
tools2,
|
|
&mistral,
|
|
&[],
|
|
"test-reuse",
|
|
).await.unwrap();
|
|
assert!(!created2, "Same prompt should not recreate");
|
|
assert_eq!(id1, id2, "Should return same agent ID");
|
|
|
|
// Cleanup
|
|
let _ = mistral.delete_agent_async(&id1).await;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Gitea devtools — additional tool coverage
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod devtools_extended_tests {
|
|
use std::sync::Arc;
|
|
use crate::persistence::Store;
|
|
use crate::sdk::vault::VaultClient;
|
|
use crate::sdk::tokens::TokenStore;
|
|
use crate::sdk::gitea::GiteaClient;
|
|
use crate::tools::devtools;
|
|
use crate::context::ResponseContext;
|
|
|
|
async fn dev_gitea() -> Option<Arc<GiteaClient>> {
|
|
let ok = reqwest::get("http://localhost:3000/api/v1/version").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !ok { return None; }
|
|
let vault_ok = reqwest::get("http://localhost:8200/v1/sys/health").await.ok()
|
|
.map(|r| r.status().is_success()).unwrap_or(false);
|
|
if !vault_ok { return None; }
|
|
|
|
let store = Arc::new(Store::open_memory().unwrap());
|
|
let vault = Arc::new(VaultClient::new_with_token("http://localhost:8200", "secret", "dev-root-token"));
|
|
let token_store = Arc::new(TokenStore::new(store, vault));
|
|
|
|
Some(Arc::new(GiteaClient::new(
|
|
"http://localhost:3000".into(),
|
|
"sol".into(),
|
|
"solpass123".into(),
|
|
token_store,
|
|
)))
|
|
}
|
|
|
|
fn test_ctx() -> ResponseContext {
|
|
ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.pt".into(),
|
|
user_id: "sol@sunbeam.pt".into(),
|
|
display_name: Some("Sol".into()),
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "!test:localhost".into(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_list_repos() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_list_repos", r#"{"org":"studio"}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("studio/"), "Should list studio org repos");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_get_repo() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_get_repo", r#"{"owner":"studio","repo":"sol"}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("studio/sol"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_list_issues() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_list_issues", r#"{"owner":"studio","repo":"sol"}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("Bootstrap"), "Should find bootstrap test issue");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_get_file() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_get_file", r#"{"owner":"studio","repo":"sol","path":"Cargo.toml"}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("[package]") || result.contains("Cargo.toml"),
|
|
"Should return Cargo.toml content");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_list_branches() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_list_branches", r#"{"owner":"studio","repo":"sol"}"#, &test_ctx()).await.unwrap();
|
|
assert!(!result.is_empty(), "Should return branch list");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_list_comments() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_list_comments", r#"{"owner":"studio","repo":"sol","number":1}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("Bootstrap") || result.contains("test comment"),
|
|
"Should find bootstrap comment on issue #1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_list_orgs() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_list_orgs", r#"{"username":"sol"}"#, &test_ctx()).await.unwrap();
|
|
assert!(result.contains("studio"), "Should list studio org");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_devtools_unknown_tool() {
|
|
let Some(gitea) = dev_gitea().await else { eprintln!("Skipping"); return; };
|
|
let result = devtools::execute(&gitea, "gitea_bogus", r#"{}"#, &test_ctx()).await;
|
|
assert!(result.is_err());
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Matrix utils — construct ruma events and test extraction functions
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod matrix_utils_tests {
|
|
use crate::matrix_utils;
|
|
use ruma::events::room::message::{
|
|
MessageType, OriginalSyncRoomMessageEvent, Relation,
|
|
RoomMessageEventContent, TextMessageEventContent,
|
|
};
|
|
use ruma::events::relation::InReplyTo;
|
|
use ruma::MilliSecondsSinceUnixEpoch;
|
|
|
|
fn make_text_event(body: &str) -> OriginalSyncRoomMessageEvent {
|
|
OriginalSyncRoomMessageEvent {
|
|
content: RoomMessageEventContent::text_plain(body),
|
|
event_id: ruma::event_id!("$test:localhost").to_owned(),
|
|
sender: ruma::user_id!("@alice:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn make_notice_event(body: &str) -> OriginalSyncRoomMessageEvent {
|
|
OriginalSyncRoomMessageEvent {
|
|
content: RoomMessageEventContent::notice_plain(body),
|
|
event_id: ruma::event_id!("$notice:localhost").to_owned(),
|
|
sender: ruma::user_id!("@sol:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_body_text() {
|
|
let event = make_text_event("hello world");
|
|
assert_eq!(matrix_utils::extract_body(&event), Some("hello world".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_body_notice() {
|
|
let event = make_notice_event("system message");
|
|
assert_eq!(matrix_utils::extract_body(&event), Some("system message".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_body_emote() {
|
|
let content = RoomMessageEventContent::emote_plain("waves");
|
|
let event = OriginalSyncRoomMessageEvent {
|
|
content,
|
|
event_id: ruma::event_id!("$emote:localhost").to_owned(),
|
|
sender: ruma::user_id!("@alice:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
};
|
|
assert_eq!(matrix_utils::extract_body(&event), Some("waves".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_body_unsupported_returns_none() {
|
|
// Image message — extract_body should return None
|
|
use ruma::events::room::message::ImageMessageEventContent;
|
|
let content = RoomMessageEventContent::new(
|
|
MessageType::Image(ImageMessageEventContent::plain(
|
|
"photo.jpg".into(),
|
|
ruma::mxc_uri!("mxc://localhost/abc").to_owned(),
|
|
)),
|
|
);
|
|
let event = OriginalSyncRoomMessageEvent {
|
|
content,
|
|
event_id: ruma::event_id!("$img:localhost").to_owned(),
|
|
sender: ruma::user_id!("@alice:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
};
|
|
assert!(matrix_utils::extract_body(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_reply_to() {
|
|
let mut content = RoomMessageEventContent::text_plain("replying");
|
|
content.relates_to = Some(Relation::Reply {
|
|
in_reply_to: InReplyTo::new(ruma::event_id!("$parent:localhost").to_owned()),
|
|
});
|
|
let event = OriginalSyncRoomMessageEvent {
|
|
content,
|
|
event_id: ruma::event_id!("$reply:localhost").to_owned(),
|
|
sender: ruma::user_id!("@alice:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
};
|
|
let reply_to = matrix_utils::extract_reply_to(&event);
|
|
assert_eq!(reply_to.unwrap().as_str(), "$parent:localhost");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_reply_to_none() {
|
|
let event = make_text_event("no reply");
|
|
assert!(matrix_utils::extract_reply_to(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_thread_id() {
|
|
use ruma::events::relation::Thread;
|
|
let mut content = RoomMessageEventContent::text_plain("threaded");
|
|
let thread_root = ruma::event_id!("$thread:localhost").to_owned();
|
|
content.relates_to = Some(Relation::Thread(
|
|
Thread::plain(thread_root.clone(), thread_root),
|
|
));
|
|
let event = OriginalSyncRoomMessageEvent {
|
|
content,
|
|
event_id: ruma::event_id!("$child:localhost").to_owned(),
|
|
sender: ruma::user_id!("@alice:localhost").to_owned(),
|
|
origin_server_ts: MilliSecondsSinceUnixEpoch(ruma::UInt::new(1710000000000).unwrap()),
|
|
unsigned: Default::default(),
|
|
};
|
|
let thread_id = matrix_utils::extract_thread_id(&event);
|
|
assert_eq!(thread_id.unwrap().as_str(), "$thread:localhost");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_thread_id_none() {
|
|
let event = make_text_event("not threaded");
|
|
assert!(matrix_utils::extract_thread_id(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_edit_none() {
|
|
let event = make_text_event("original");
|
|
assert!(matrix_utils::extract_edit(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_image_none_for_text() {
|
|
let event = make_text_event("not an image");
|
|
assert!(matrix_utils::extract_image(&event).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_make_reply_content() {
|
|
let reply_to = ruma::event_id!("$original:localhost").to_owned();
|
|
let content = matrix_utils::make_reply_content("my reply", reply_to);
|
|
assert!(content.relates_to.is_some());
|
|
match content.relates_to.unwrap() {
|
|
Relation::Reply { in_reply_to } => {
|
|
assert_eq!(in_reply_to.event_id.as_str(), "$original:localhost");
|
|
}
|
|
_ => panic!("Expected Reply relation"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_make_thread_reply() {
|
|
let thread_root = ruma::event_id!("$root:localhost").to_owned();
|
|
let content = matrix_utils::make_thread_reply("thread response", thread_root);
|
|
assert!(content.relates_to.is_some());
|
|
match content.relates_to.unwrap() {
|
|
Relation::Thread(thread) => {
|
|
assert_eq!(thread.event_id.as_str(), "$root:localhost");
|
|
}
|
|
_ => panic!("Expected Thread relation"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Script tool — full integration with Matrix + OpenSearch
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod script_full_tests {
|
|
use super::code_index_tests::os_client;
|
|
use crate::context::ResponseContext;
|
|
|
|
async fn matrix_client() -> Option<matrix_sdk::Client> {
|
|
let homeserver = url::Url::parse("http://localhost:8008").ok()?;
|
|
let client = matrix_sdk::Client::builder()
|
|
.homeserver_url(homeserver)
|
|
.build()
|
|
.await
|
|
.ok()?;
|
|
|
|
// Login with bootstrap credentials
|
|
client
|
|
.matrix_auth()
|
|
.login_username("sol", "soldevpassword")
|
|
.send()
|
|
.await
|
|
.ok()?;
|
|
|
|
Some(client)
|
|
}
|
|
|
|
fn test_ctx() -> ResponseContext {
|
|
ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol@sunbeam.local".into(),
|
|
display_name: Some("Sol".into()),
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: "!test:localhost".into(),
|
|
}
|
|
}
|
|
|
|
fn test_config() -> crate::config::Config {
|
|
crate::config::Config::from_str(r#"
|
|
[matrix]
|
|
homeserver_url = "http://localhost:8008"
|
|
user_id = "@sol:sunbeam.local"
|
|
state_store_path = "/tmp/sol-test-script"
|
|
db_path = ":memory:"
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "sol_test"
|
|
[mistral]
|
|
default_model = "mistral-medium-latest"
|
|
[behavior]
|
|
instant_responses = true
|
|
script_timeout_secs = 5
|
|
script_max_heap_mb = 64
|
|
"#).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_basic_math() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "console.log(2 + 2); console.log(Math.PI.toFixed(4));"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("4"), "Should compute 2+2=4");
|
|
assert!(result.contains("3.1416"), "Should compute pi");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_typescript() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "const add = (a: number, b: number): number => a + b; console.log(add(10, 32));"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("42"), "Should execute TypeScript");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_filesystem_sandbox() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "sol.fs.write('test.txt', 'hello from script'); const content = sol.fs.read('test.txt'); console.log(content);"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("hello from script"), "Should read back written file");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_error_handling() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "throw new Error('intentional test error');"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("Error") && result.contains("intentional"),
|
|
"Should capture and return error message");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_output_truncation() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "for (let i = 0; i < 10000; i++) console.log('line ' + i);"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.len() <= 4200, "Output should be truncated: got {}", result.len());
|
|
assert!(result.contains("truncated") || result.len() <= 4096);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_invalid_json() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
"not json",
|
|
&ctx, vec![],
|
|
).await;
|
|
|
|
assert!(result.is_err(), "Invalid JSON args should error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_async_operations() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
// Test async IIFE with await
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "const result = await Promise.resolve(42); console.log(result);"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("42"), "Async operations should work");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_sol_fs_list() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "sol.fs.write('a.txt', 'aaa'); sol.fs.write('b.txt', 'bbb'); const files = sol.fs.list('.'); console.log(JSON.stringify(files));"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("a.txt"), "Should list written files");
|
|
assert!(result.contains("b.txt"), "Should list both files");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_console_methods() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "console.log('LOG'); console.error('ERR'); console.warn('WARN'); console.info('INFO');"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("LOG"));
|
|
assert!(result.contains("ERR"));
|
|
assert!(result.contains("WARN"));
|
|
assert!(result.contains("INFO"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_script_return_value_captured() {
|
|
let Some(os) = os_client() else { eprintln!("Skipping: no OpenSearch"); return; };
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let config = test_config();
|
|
let ctx = test_ctx();
|
|
|
|
// Use return to produce a value (async IIFE wrapping captures this)
|
|
let result = crate::tools::script::run_script(
|
|
&os, &mx, &config,
|
|
r#"{"code": "return 'computed-result-42';"}"#,
|
|
&ctx, vec![],
|
|
).await.unwrap();
|
|
|
|
assert!(result.contains("computed-result-42"),
|
|
"Return value should be captured: got '{result}'");
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Research tool — types and tool_definition tests
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
// Room info — list_rooms and get_room_members with live Matrix
|
|
// ══════════════════════════════════════════════════════════════════════════
|
|
|
|
mod room_info_tests {
|
|
use crate::tools::room_info;
|
|
|
|
async fn matrix_client() -> Option<matrix_sdk::Client> {
|
|
let homeserver = url::Url::parse("http://localhost:8008").ok()?;
|
|
let client = matrix_sdk::Client::builder()
|
|
.homeserver_url(homeserver)
|
|
.build().await.ok()?;
|
|
client.matrix_auth().login_username("sol", "soldevpassword").send().await.ok()?;
|
|
Some(client)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_rooms() {
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
let result = room_info::list_rooms(&mx).await.unwrap();
|
|
// Sol should be in at least the integration test room
|
|
assert!(result.contains("Integration Test") || result.contains("!") || result.contains("not in any"),
|
|
"Should list rooms or indicate none");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_room_members() {
|
|
let Some(mx) = matrix_client().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
|
|
// Use the integration test room alias to find the room ID
|
|
let room_id = "!OdWp0Mm3mof0AeJLf2:sunbeam.local";
|
|
let args = serde_json::json!({"room_id": room_id}).to_string();
|
|
let result = room_info::get_room_members(&mx, &args).await;
|
|
|
|
// May fail if the room isn't synced yet — that's ok, verify no panic
|
|
match result {
|
|
Ok(s) => assert!(!s.is_empty()),
|
|
Err(e) => assert!(e.to_string().contains("not in room") || e.to_string().contains("Invalid")),
|
|
}
|
|
}
|
|
}
|
|
|
|
mod research_extended_tests {
|
|
use crate::tools::research;
|
|
|
|
#[test]
|
|
fn test_tool_definition_schema() {
|
|
let def = research::tool_definition(4, 0).unwrap();
|
|
assert_eq!(def.function.name, "research");
|
|
let params = &def.function.parameters;
|
|
// Should have tasks array
|
|
assert!(params["properties"]["tasks"].is_object());
|
|
assert_eq!(params["required"][0], "tasks");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_definition_depth_boundary() {
|
|
// At depth 3 with max 4 — still available
|
|
assert!(research::tool_definition(4, 3).is_some());
|
|
// At depth 4 with max 4 — unavailable
|
|
assert!(research::tool_definition(4, 4).is_none());
|
|
// Beyond max — unavailable
|
|
assert!(research::tool_definition(4, 10).is_none());
|
|
// Max 0 — never available
|
|
assert!(research::tool_definition(0, 0).is_none());
|
|
// Max 1, depth 0 — available
|
|
assert!(research::tool_definition(1, 0).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_task_roundtrip() {
|
|
let task = research::ResearchTask {
|
|
focus: "API design".into(),
|
|
instructions: "review the REST endpoints in proxy/".into(),
|
|
};
|
|
let json = serde_json::to_string(&task).unwrap();
|
|
let back: research::ResearchTask = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.focus, "API design");
|
|
assert_eq!(back.instructions, "review the REST endpoints in proxy/");
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_result_json() {
|
|
let result = research::ResearchResult {
|
|
focus: "license check".into(),
|
|
findings: "all repos use AGPL-3.0".into(),
|
|
tool_calls_made: 3,
|
|
status: "complete".into(),
|
|
};
|
|
let json = serde_json::to_value(&result).unwrap();
|
|
assert_eq!(json["status"], "complete");
|
|
assert_eq!(json["tool_calls_made"], 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_empty_tasks() {
|
|
let parsed: serde_json::Value = serde_json::from_str(r#"{"tasks":[]}"#).unwrap();
|
|
let tasks: Vec<research::ResearchTask> = serde_json::from_value(
|
|
parsed.get("tasks").cloned().unwrap_or(serde_json::json!([])),
|
|
).unwrap();
|
|
assert!(tasks.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_multiple_tasks_parse() {
|
|
let json = serde_json::json!({
|
|
"tasks": [
|
|
{"focus": "auth", "instructions": "check auth flow"},
|
|
{"focus": "db", "instructions": "review schema"},
|
|
{"focus": "api", "instructions": "list endpoints"},
|
|
]
|
|
});
|
|
let tasks: Vec<research::ResearchTask> = serde_json::from_value(json["tasks"].clone()).unwrap();
|
|
assert_eq!(tasks.len(), 3);
|
|
assert_eq!(tasks[0].focus, "auth");
|
|
assert_eq!(tasks[2].focus, "api");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_research_execute_empty_tasks() {
|
|
let env_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
|
|
if let Ok(contents) = std::fs::read_to_string(&env_path) {
|
|
for line in contents.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if let Some((k, v)) = line.split_once('=') {
|
|
std::env::set_var(k.trim(), v.trim());
|
|
}
|
|
}
|
|
}
|
|
let api_key = match std::env::var("SOL_MISTRAL_API_KEY") {
|
|
Ok(k) => k,
|
|
Err(_) => { eprintln!("Skipping: no API key"); return; }
|
|
};
|
|
|
|
// Need Matrix client for Room
|
|
let homeserver = url::Url::parse("http://localhost:8008").unwrap();
|
|
let Ok(mx) = matrix_sdk::Client::builder()
|
|
.homeserver_url(homeserver).build().await else { eprintln!("Skipping: no Tuwunel"); return; };
|
|
if mx.matrix_auth().login_username("sol", "soldevpassword").send().await.is_err() {
|
|
eprintln!("Skipping: login failed"); return;
|
|
}
|
|
|
|
// Get the integration test room
|
|
let room_id = ruma::room_id!("!OdWp0Mm3mof0AeJLf2:sunbeam.local");
|
|
let Some(room) = mx.get_room(room_id) else {
|
|
eprintln!("Skipping: room not found (run bootstrap)"); return;
|
|
};
|
|
|
|
let event_id: ruma::OwnedEventId = "$test:sunbeam.local".try_into().unwrap();
|
|
let config = std::sync::Arc::new(crate::config::Config::from_str(r#"
|
|
[matrix]
|
|
homeserver_url = "http://localhost:8008"
|
|
user_id = "@sol:sunbeam.local"
|
|
state_store_path = "/tmp/sol-test-research"
|
|
db_path = ":memory:"
|
|
[opensearch]
|
|
url = "http://localhost:9200"
|
|
index = "sol_test"
|
|
[mistral]
|
|
default_model = "mistral-medium-latest"
|
|
[behavior]
|
|
instant_responses = true
|
|
[agents]
|
|
research_model = "mistral-medium-latest"
|
|
research_max_agents = 3
|
|
research_max_iterations = 5
|
|
research_max_depth = 2
|
|
"#).unwrap());
|
|
let mistral = std::sync::Arc::new(
|
|
mistralai_client::v1::client::Client::new(Some(api_key), None, None, None).unwrap(),
|
|
);
|
|
let store = std::sync::Arc::new(crate::persistence::Store::open_memory().unwrap());
|
|
let tools = std::sync::Arc::new(crate::tools::ToolRegistry::new_minimal(config.clone()));
|
|
|
|
let ctx = crate::context::ResponseContext {
|
|
matrix_user_id: "@sol:sunbeam.local".into(),
|
|
user_id: "sol@sunbeam.local".into(),
|
|
display_name: Some("Sol".into()),
|
|
is_dm: true,
|
|
is_reply: false,
|
|
room_id: room_id.to_string(),
|
|
};
|
|
|
|
// Empty tasks should return an error message (not panic)
|
|
let result = research::execute(
|
|
r#"{"tasks":[]}"#,
|
|
&config, &mistral, &tools, &ctx, &room, &event_id, &store, 0,
|
|
).await.unwrap();
|
|
assert!(result.contains("error") || result.contains("No research tasks"),
|
|
"Empty tasks should produce error message: got '{result}'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_research_result_output_format() {
|
|
let results = vec![
|
|
research::ResearchResult {
|
|
focus: "auth".into(),
|
|
findings: "uses OAuth2".into(),
|
|
tool_calls_made: 2,
|
|
status: "complete".into(),
|
|
},
|
|
research::ResearchResult {
|
|
focus: "db".into(),
|
|
findings: "PostgreSQL via CNPG".into(),
|
|
tool_calls_made: 1,
|
|
status: "complete".into(),
|
|
},
|
|
];
|
|
let total_calls: usize = results.iter().map(|r| r.tool_calls_made).sum();
|
|
let output = results.iter()
|
|
.map(|r| format!("### {} [{}]\n{}\n", r.focus, r.status, r.findings))
|
|
.collect::<Vec<_>>().join("\n---\n\n");
|
|
assert!(output.contains("### auth [complete]"));
|
|
assert!(output.contains("### db [complete]"));
|
|
assert_eq!(total_calls, 3);
|
|
}
|
|
}
|