diff --git a/tests/gateway_workflow_integration.rs b/tests/gateway_workflow_integration.rs new file mode 100644 index 00000000..edf7de91 --- /dev/null +++ b/tests/gateway_workflow_integration.rs @@ -0,0 +1,130 @@ +//! Live-ish gateway workflow integration using an in-process mock OpenAI server. +//! This exercises the same path as manual validation: +//! - chat send through gateway +//! - routine creation via tool call +//! - system-event emission via tool call +//! - webhook ingestion via gateway +//! - status/runs checks via routines API + +#[cfg(feature = "libsql")] +mod support; + +#[cfg(feature = "libsql")] +mod tests { + use std::time::Duration; + + use crate::support::gateway_workflow_harness::GatewayWorkflowHarness; + use crate::support::mock_openai_server::{ + MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall, + }; + + #[tokio::test] + async fn gateway_workflow_harness_chat_and_webhook() { + let mock = MockOpenAiServerBuilder::new() + .with_rule(MockOpenAiRule::on_user_contains( + "create workflow routine", + MockOpenAiResponse::ToolCalls(vec![MockToolCall::new( + "call_create_1", + "routine_create", + serde_json::json!({ + "name": "wf-ci-webhook-demo", + "description": "CI webhook workflow demo", + "trigger_type": "system_event", + "trigger": { + "source": "github", + "event_type": "issue.opened", + "filters": {"repository": "nearai/ironclaw"} + }, + "action_type": "lightweight", + "action": { + "prompt": "Summarize webhook and report issue number" + } + }), + )]), + )) + .with_rule(MockOpenAiRule::on_user_contains( + "emit webhook event", + MockOpenAiResponse::ToolCalls(vec![MockToolCall::new( + "call_emit_1", + "event_emit", + serde_json::json!({ + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue": {"number": 777, "title": "Infra test"} + } + }), + )]), + )) + .with_default_response(MockOpenAiResponse::Text("ack".to_string())) + .start() + .await; + + let harness = + GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model") + .await; + + let thread_id = harness.create_thread().await; + harness + .send_chat(&thread_id, "create workflow routine") + .await; + harness.send_chat(&thread_id, "emit webhook event").await; + + let history = harness + .wait_for_turns(&thread_id, 2, Duration::from_secs(10)) + .await; + let turns = history["turns"].as_array().expect("turns array missing"); + assert!(turns.len() >= 2, "expected at least 2 turns"); + + let routine = harness + .routine_by_name("wf-ci-webhook-demo") + .await + .expect("routine not created"); + let routine_id = routine["id"].as_str().expect("routine id missing"); + + let runs_before = harness.routine_runs(routine_id).await; + let before_count = runs_before["runs"] + .as_array() + .map(|a| a.len()) + .unwrap_or_default(); + + let hook = harness + .github_webhook( + "issues", + serde_json::json!({ + "action": "opened", + "repository": {"full_name": "nearai/ironclaw"}, + "issue": {"number": 778, "title": "Webhook endpoint test"} + }), + ) + .await; + + assert_eq!(hook["status"], "accepted"); + assert_eq!(hook["event_type"], "issue.opened"); + assert!( + hook["fired_routines"].as_u64().unwrap_or(0) >= 1, + "expected webhook to fire at least one routine" + ); + + tokio::time::sleep(Duration::from_millis(500)).await; + let runs_after = harness.routine_runs(routine_id).await; + let after_count = runs_after["runs"] + .as_array() + .map(|a| a.len()) + .unwrap_or_default(); + assert!( + after_count > before_count, + "expected routine runs to increase after webhook; before={before_count}, after={after_count}" + ); + + let requests = mock.requests().await; + assert!( + requests.len() >= 2, + "expected mock LLM server to receive requests" + ); + + harness.shutdown().await; + mock.shutdown().await; + } +} diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs new file mode 100644 index 00000000..77d53ac1 --- /dev/null +++ b/tests/support/gateway_workflow_harness.rs @@ -0,0 +1,467 @@ +#![allow(dead_code)] + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use secrecy::SecretString; +use tokio::sync::mpsc; + +use ironclaw::agent::routine_engine::RoutineEngine; +use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager}; +use ironclaw::app::{AppBuilder, AppBuilderFlags}; +use ironclaw::channels::web::log_layer::LogBroadcaster; +use ironclaw::channels::web::server::{GatewayState, RateLimiter, start_server}; +use ironclaw::channels::web::sse::SseManager; +use ironclaw::channels::web::ws::WsConnectionTracker; +use ironclaw::channels::{Channel, IncomingMessage, MessageStream}; +use ironclaw::config::llm::RegistryProviderConfig; +use ironclaw::config::{Config, RoutineConfig}; +use ironclaw::context::ContextManager; +use ironclaw::db::Database; +use ironclaw::db::libsql::LibSqlBackend; +use ironclaw::error::ChannelError; +use ironclaw::llm::registry::ProviderProtocol; +use ironclaw::llm::{ + SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider, +}; + +use crate::support::test_channel::TestChannel; + +struct TestChannelHandle { + inner: Arc, +} + +impl TestChannelHandle { + fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[async_trait] +impl Channel for TestChannelHandle { + fn name(&self) -> &str { + "gateway" + } + + async fn start(&self) -> Result { + self.inner.start().await + } + + async fn respond( + &self, + msg: &IncomingMessage, + response: ironclaw::channels::OutgoingResponse, + ) -> Result<(), ChannelError> { + self.inner.respond(msg, response).await + } + + async fn send_status( + &self, + status: ironclaw::channels::StatusUpdate, + metadata: &serde_json::Value, + ) -> Result<(), ChannelError> { + self.inner.send_status(status, metadata).await + } + + async fn broadcast( + &self, + user_id: &str, + response: ironclaw::channels::OutgoingResponse, + ) -> Result<(), ChannelError> { + self.inner.broadcast(user_id, response).await + } + + async fn health_check(&self) -> Result<(), ChannelError> { + self.inner.health_check().await + } + + fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap { + self.inner.conversation_context(metadata) + } + + async fn shutdown(&self) -> Result<(), ChannelError> { + self.inner.shutdown().await + } +} + +pub struct GatewayWorkflowHarness { + pub addr: SocketAddr, + pub auth_token: String, + pub client: reqwest::Client, + pub user_id: String, + pub test_channel: Arc, + pub db: Arc, + gateway_state: Arc, + agent_handle: Option>, + bridge_handle: Option>, + _temp_dir: tempfile::TempDir, +} + +impl GatewayWorkflowHarness { + pub async fn start_openai_compatible(base_url: &str, model: &str) -> Self { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + let db_path = temp_dir.path().join("gateway_workflow_harness.db"); + let backend = LibSqlBackend::new_local(&db_path) + .await + .expect("failed to create test db"); + backend + .run_migrations() + .await + .expect("failed to run migrations"); + let db: Arc = Arc::new(backend); + + let skills_dir = temp_dir.path().join("skills"); + let installed_skills_dir = temp_dir.path().join("installed_skills"); + let _ = std::fs::create_dir_all(&skills_dir); + let _ = std::fs::create_dir_all(&installed_skills_dir); + let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); + config.agent.auto_approve_tools = true; + config.routines.enabled = true; + config.routines.max_concurrent_routines = 4; + config.llm.backend = "openai_compatible".to_string(); + config.llm.provider = Some(RegistryProviderConfig { + protocol: ProviderProtocol::OpenAiCompletions, + provider_id: "openai_compatible".to_string(), + api_key: Some(SecretString::from("dummy".to_string())), + base_url: base_url.to_string(), + model: model.to_string(), + extra_headers: Vec::new(), + oauth_token: None, + }); + + let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default())); + let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session)) + .expect("failed to create openai-compatible provider"); + + let log_broadcaster = Arc::new(LogBroadcaster::new()); + let mut app_builder = AppBuilder::new( + config, + AppBuilderFlags::default(), + None, + Arc::clone(&llm_session), + log_broadcaster, + ); + app_builder.with_database(Arc::clone(&db)); + app_builder.with_llm(llm); + + let mut components = app_builder + .build_all() + .await + .expect("failed to build app components"); + + let ctx_mgr = Arc::new(ContextManager::new( + components.config.agent.max_parallel_jobs, + )); + components.tools.register_job_tools( + ctx_mgr, + None, + None, + components.db.clone(), + None, + None, + None, + None, + ); + + let routine_slot: Arc>>> = + Arc::new(tokio::sync::RwLock::new(None)); + + if let (Some(db_arc), Some(ws)) = (&components.db, &components.workspace) { + let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); + let engine = Arc::new(RoutineEngine::new( + RoutineConfig::default(), + Arc::clone(db_arc), + components.llm.clone(), + Arc::clone(ws), + notify_tx, + None, + )); + components + .tools + .register_routine_tools(Arc::clone(db_arc), engine); + } + + let test_channel = Arc::new(TestChannel::new()); + let handle = TestChannelHandle::new(Arc::clone(&test_channel)); + let channel_manager = ironclaw::channels::ChannelManager::new(); + channel_manager.add(Box::new(handle)).await; + let channels = Arc::new(channel_manager); + + let user_id = "gateway-test-user".to_string(); + let (gw_tx, mut gw_rx) = mpsc::channel::(256); + let forward_channel = Arc::clone(&test_channel); + let bridge_handle = tokio::spawn(async move { + while let Some(msg) = gw_rx.recv().await { + forward_channel.send_incoming(msg).await; + } + }); + + let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot = + Arc::new(tokio::sync::RwLock::new(None)); + let agent_session_manager = Arc::new(AgentSessionManager::new()); + + let gateway_state = Arc::new(GatewayState { + msg_tx: tokio::sync::RwLock::new(Some(gw_tx)), + sse: SseManager::new(), + workspace: components.workspace.clone(), + session_manager: Some(Arc::clone(&agent_session_manager)), + log_broadcaster: None, + log_level_handle: None, + extension_manager: components.extension_manager.clone(), + tool_registry: Some(Arc::clone(&components.tools)), + store: components.db.clone(), + job_manager: None, + prompt_queue: None, + scheduler: Some(scheduler_slot.clone()), + user_id: user_id.clone(), + shutdown_tx: tokio::sync::RwLock::new(None), + ws_tracker: Some(Arc::new(WsConnectionTracker::new())), + llm_provider: Some(Arc::clone(&components.llm)), + skill_registry: components.skill_registry.clone(), + skill_catalog: components.skill_catalog.clone(), + chat_rate_limiter: RateLimiter::new(120, 60), + registry_entries: Vec::new(), + cost_guard: Some(Arc::clone(&components.cost_guard)), + routine_engine: Arc::clone(&routine_slot), + startup_time: Instant::now(), + }); + + let mut agent = Agent::new( + components.config.agent.clone(), + AgentDeps { + store: components.db, + llm: components.llm, + cheap_llm: components.cheap_llm, + safety: components.safety, + tools: components.tools, + workspace: components.workspace, + extension_manager: components.extension_manager, + skill_registry: components.skill_registry, + skill_catalog: components.skill_catalog, + skills_config: components.config.skills.clone(), + hooks: components.hooks, + cost_guard: components.cost_guard, + sse_tx: Some(gateway_state.sse.sender()), + http_interceptor: None, + transcription: None, + document_extraction: None, + }, + channels, + None, + None, + Some(RoutineConfig { + enabled: true, + cron_check_interval_secs: 60, + max_concurrent_routines: 4, + default_cooldown_secs: 300, + max_lightweight_tokens: 4096, + }), + Some(Arc::clone(&components.context_manager)), + Some(Arc::clone(&agent_session_manager)), + ); + agent.set_routine_engine_slot(Arc::clone(&routine_slot)); + *scheduler_slot.write().await = Some(agent.scheduler()); + + let agent_handle = tokio::spawn(async move { + let _ = agent.run().await; + }); + + if let Some(rx) = test_channel.take_ready_rx().await { + let _ = tokio::time::timeout(Duration::from_secs(5), rx).await; + } + + let auth_token = "gateway-test-token".to_string(); + let addr = start_server( + "127.0.0.1:0".parse().expect("valid localhost addr"), + Arc::clone(&gateway_state), + auth_token.clone(), + ) + .await + .expect("failed to start gateway server"); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build reqwest client"); + + Self { + addr, + auth_token, + client, + user_id, + test_channel, + db, + gateway_state, + agent_handle: Some(agent_handle), + bridge_handle: Some(bridge_handle), + _temp_dir: temp_dir, + } + } + + pub fn base_url(&self) -> String { + format!("http://{}", self.addr) + } + + pub async fn create_thread(&self) -> String { + let resp = self + .client + .post(format!("{}/api/chat/thread/new", self.base_url())) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("create thread request failed") + .error_for_status() + .expect("create thread non-2xx") + .json::() + .await + .expect("invalid thread response"); + resp.get("id") + .and_then(|v| v.as_str()) + .expect("thread id missing") + .to_string() + } + + pub async fn send_chat(&self, thread_id: &str, content: &str) { + let _ = self + .client + .post(format!("{}/api/chat/send", self.base_url())) + .bearer_auth(&self.auth_token) + .json(&serde_json::json!({"thread_id": thread_id, "content": content})) + .send() + .await + .expect("chat send failed") + .error_for_status() + .expect("chat send non-2xx"); + } + + pub async fn history(&self, thread_id: &str) -> serde_json::Value { + self.client + .get(format!( + "{}/api/chat/history?thread_id={thread_id}", + self.base_url() + )) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("history request failed") + .error_for_status() + .expect("history non-2xx") + .json::() + .await + .expect("invalid history response") + } + + pub async fn wait_for_turns( + &self, + thread_id: &str, + min_turns: usize, + timeout: Duration, + ) -> serde_json::Value { + let deadline = Instant::now() + timeout; + loop { + let history = self.history(thread_id).await; + let turns = history + .get("turns") + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or_default(); + if turns >= min_turns { + return history; + } + assert!(Instant::now() < deadline, "timed out waiting for turns"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + pub async fn list_routines(&self) -> serde_json::Value { + self.client + .get(format!("{}/api/routines", self.base_url())) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("routines request failed") + .error_for_status() + .expect("routines non-2xx") + .json::() + .await + .expect("invalid routines response") + } + + pub async fn routine_by_name(&self, name: &str) -> Option { + let routines = self.list_routines().await; + routines + .get("routines") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|r| r.get("name").and_then(|v| v.as_str()) == Some(name)) + .cloned() + }) + } + + pub async fn routine_runs(&self, routine_id: &str) -> serde_json::Value { + self.client + .get(format!( + "{}/api/routines/{routine_id}/runs", + self.base_url() + )) + .bearer_auth(&self.auth_token) + .send() + .await + .expect("routine runs request failed") + .error_for_status() + .expect("routine runs non-2xx") + .json::() + .await + .expect("invalid routine runs response") + } + + pub async fn github_webhook( + &self, + event: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + self.client + .post(format!("{}/api/webhooks/github", self.base_url())) + .header("x-github-event", event) + .json(&payload) + .send() + .await + .expect("webhook request failed") + .error_for_status() + .expect("webhook non-2xx") + .json::() + .await + .expect("invalid webhook response") + } + + pub async fn shutdown(mut self) { + self.test_channel.signal_shutdown(); + + if let Some(tx) = self.gateway_state.shutdown_tx.write().await.take() { + let _ = tx.send(()); + } + + if let Some(handle) = self.bridge_handle.take() { + handle.abort(); + } + if let Some(handle) = self.agent_handle.take() { + handle.abort(); + } + } +} + +impl Drop for GatewayWorkflowHarness { + fn drop(&mut self) { + self.test_channel.signal_shutdown(); + if let Some(handle) = self.bridge_handle.take() { + handle.abort(); + } + if let Some(handle) = self.agent_handle.take() { + handle.abort(); + } + } +} diff --git a/tests/support/mock_openai_server.rs b/tests/support/mock_openai_server.rs new file mode 100644 index 00000000..4d9e0117 --- /dev/null +++ b/tests/support/mock_openai_server.rs @@ -0,0 +1,277 @@ +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tokio::sync::{Mutex, oneshot}; + +#[derive(Clone)] +pub struct MockOpenAiRule { + contains: String, + response: MockOpenAiResponse, +} + +impl MockOpenAiRule { + pub fn on_user_contains(contains: impl Into, response: MockOpenAiResponse) -> Self { + Self { + contains: contains.into(), + response, + } + } +} + +#[derive(Clone)] +pub enum MockOpenAiResponse { + Text(String), + ToolCalls(Vec), + Raw(Value), +} + +#[derive(Clone)] +pub struct MockToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +impl MockToolCall { + pub fn new(id: impl Into, name: impl Into, arguments: Value) -> Self { + Self { + id: id.into(), + name: name.into(), + arguments, + } + } +} + +#[derive(Default)] +pub struct MockOpenAiServerBuilder { + models: Vec, + rules: Vec, + default_response: Option, +} + +impl MockOpenAiServerBuilder { + pub fn new() -> Self { + Self { + models: vec!["mock-model".to_string()], + ..Self::default() + } + } + + pub fn with_models(mut self, models: Vec) -> Self { + self.models = models; + self + } + + pub fn with_rule(mut self, rule: MockOpenAiRule) -> Self { + self.rules.push(rule); + self + } + + pub fn with_default_response(mut self, response: MockOpenAiResponse) -> Self { + self.default_response = Some(response); + self + } + + pub async fn start(self) -> MockOpenAiServer { + let state = Arc::new(MockOpenAiState { + models: self.models, + rules: self.rules, + default_response: self + .default_response + .unwrap_or_else(|| MockOpenAiResponse::Text("OK".to_string())), + requests: Mutex::new(Vec::new()), + response_counter: AtomicU64::new(1), + }); + + let app = Router::new() + .route("/v1/models", get(models_handler)) + .route("/v1/chat/completions", post(chat_completions_handler)) + .with_state(Arc::clone(&state)); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind mock openai server"); + let addr = listener.local_addr().expect("failed to read bound addr"); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + + MockOpenAiServer { + addr, + state, + shutdown_tx: Some(shutdown_tx), + server_task: Some(handle), + } + } +} + +pub struct MockOpenAiServer { + addr: SocketAddr, + state: Arc, + shutdown_tx: Option>, + server_task: Option>, +} + +impl MockOpenAiServer { + pub fn base_url(&self) -> String { + format!("http://{}", self.addr) + } + + pub fn openai_base_url(&self) -> String { + format!("{}/v1", self.base_url()) + } + + pub async fn requests(&self) -> Vec { + self.state.requests.lock().await.clone() + } + + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.server_task.take() { + let _ = handle.await; + } + } +} + +impl Drop for MockOpenAiServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.server_task.take() { + handle.abort(); + } + } +} + +struct MockOpenAiState { + models: Vec, + rules: Vec, + default_response: MockOpenAiResponse, + requests: Mutex>, + response_counter: AtomicU64, +} + +async fn models_handler(State(state): State>) -> Json { + Json(json!({ + "object": "list", + "data": state + .models + .iter() + .map(|id| json!({"id": id, "object": "model"})) + .collect::>() + })) +} + +async fn chat_completions_handler( + State(state): State>, + Json(body): Json, +) -> Result, (StatusCode, String)> { + state.requests.lock().await.push(body.clone()); + + let model = body + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("mock-model"); + let last_role = body + .pointer("/messages") + .and_then(|m| m.as_array()) + .and_then(|arr| arr.last()) + .and_then(|v| v.get("role")) + .and_then(|r| r.as_str()) + .unwrap_or_default(); + + let latest_user = body + .pointer("/messages") + .and_then(|m| m.as_array()) + .and_then(|arr| { + arr.iter().rev().find_map(|msg| { + if msg.get("role").and_then(|r| r.as_str()) == Some("user") { + msg.get("content") + .and_then(|c| c.as_str()) + .map(ToOwned::to_owned) + } else { + None + } + }) + }) + .unwrap_or_default(); + + let selected = if last_role == "user" { + state + .rules + .iter() + .find(|r| latest_user.contains(&r.contains)) + .map(|r| r.response.clone()) + .unwrap_or_else(|| state.default_response.clone()) + } else { + state.default_response.clone() + }; + + let n = state.response_counter.fetch_add(1, Ordering::Relaxed); + let response = match selected { + MockOpenAiResponse::Text(content) => json!({ + "id": format!("chatcmpl-mock-{n}"), + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }), + MockOpenAiResponse::ToolCalls(tool_calls) => { + let calls = tool_calls + .iter() + .map(|tc| { + json!({ + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": tc.arguments.to_string() + } + }) + }) + .collect::>(); + json!({ + "id": format!("chatcmpl-mock-{n}"), + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": serde_json::Value::Null, + "tool_calls": calls + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }) + } + MockOpenAiResponse::Raw(v) => v, + }; + + Ok(Json(response)) +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index e1ce4866..a5130532 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,7 +1,9 @@ pub mod assertions; pub mod cleanup; +pub mod gateway_workflow_harness; pub mod instrumented_llm; pub mod metrics; +pub mod mock_openai_server; pub mod test_channel; pub mod test_rig; pub mod trace_llm;