//! 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, event_rx: tokio::sync::broadcast::Receiver, } 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 { 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 { 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) { 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, tonic::Streaming, SessionReady, ) { let mut client = CodeAgentClient::connect(endpoint.to_string()).await.unwrap(); let (tx, client_rx) = mpsc::channel::(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 { 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 { 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".into(), docstring: "Generate a response using the ConversationRegistry.".into(), start_line: 80, end_line: 120, content: "pub async fn generate(&self, req: &GenerateRequest) -> Option { ... }".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".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> { 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 { 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::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"), ("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"), 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 { 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 { 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 { 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> { 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::(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::(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::(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::("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> { 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 { 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 { 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 { 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 { 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 { 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> { 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 { 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 { 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 = 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 = 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::>().join("\n---\n\n"); assert!(output.contains("### auth [complete]")); assert!(output.contains("### db [complete]")); assert_eq!(total_calls, 3); } }