//! Fully hermetic FULL-STACK e2e: the REAL gate + REAL long-lived session //! run against a **mocked LLM model** — no network, no Ollama, no real model //! anywhere (cloud and local model funnels are both overridden). //! //! Unlike `subconscious_conversation_e2e.rs` (which injects scripted Gate / //! SessionExecutor *above* the model), this test mocks at the crate model //! layer, so the production code paths run for real: //! - `GatePass::evaluate` → `agent::triage::run_triage` → real model //! funnel → mock → real triage parse → real promote/drop mapping. //! - `LongLivedSession::process_promoted` → `Agent::from_config` (the real //! orchestrator agent + tool loop) → mock → real reserved-thread persistence. //! //! The mock is installed via the factory's `test_provider_override` seam, //! which both model funnels consult first. //! //! Gated on the off-by-default `e2e-test-support` feature (the seam is only //! compiled then). Run with: //! `RUST_MIN_STACK=16777216 cargo test --features e2e-test-support --test subconscious_fullstack_e2e -- --nocapture` #![cfg(feature = "e2e-test-support")] use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use async_trait::async_trait; use openhuman_core::core::event_bus::{init_global, DomainEvent}; use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry; use openhuman_core::openhuman::config::schema::SubconsciousMode; use openhuman_core::openhuman::inference::provider::factory::test_provider_override; use openhuman_core::openhuman::subconscious::LongLivedSession; use openhuman_core::openhuman::subconscious_triggers::{normalize, GatePass}; use tinyagents::harness::message::AssistantMessage; use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse}; use tinyagents::harness::tool::ToolCall; // ───────────────────────────────────────────────────────────────────────────── // Mock LLM model — deterministic, content-routed, no network. // ───────────────────────────────────────────────────────────────────────────── /// Records every prompt the mock saw, and answers deterministically: /// - triage turns (recognised by the triage envelope markers) → a JSON /// decision (drop for cron, escalate otherwise); /// - any other turn (the session/orchestrator agent) → a plain final reply. struct MockLlm { seen: StdMutex>, /// When set, the orchestrator turn emits a real `spawn_subagent` tool /// call so the harness runs an actual sub-agent (native tool mode). spawn: bool, profile: ModelProfile, } const SUBAGENT_MARKER: &str = "SUBAGENT_TASK"; const RESEARCHER_FINDINGS: &str = "Researcher findings: Q3 numbers look healthy."; impl MockLlm { fn new() -> Arc { let mut profile = ModelProfile::default(); profile.tool_calling = false; Arc::new(Self { seen: StdMutex::new(Vec::new()), spawn: false, profile, }) } fn with_spawning() -> Arc { let mut profile = ModelProfile::default(); profile.tool_calling = true; profile.parallel_tool_calls = true; Arc::new(Self { seen: StdMutex::new(Vec::new()), spawn: true, profile, }) } fn triage_decision(&self, joined: &str) -> String { let drop = joined.contains("\"source\": \"cron\"") || joined.contains("\"source\":\"cron\"") || joined.to_lowercase().contains("ignore me"); if drop { "{\"action\":\"drop\",\"reason\":\"mock: routine noise\"}".to_string() } else { "{\"action\":\"escalate\",\"target_agent\":\"orchestrator\",\ \"prompt\":\"handle this\",\"reason\":\"mock: actionable\"}" .to_string() } } fn respond(&self, joined: &str) -> ModelResponse { self.seen.lock().unwrap().push(joined.to_string()); let is_triage = joined.contains("DISPLAY_LABEL:") && joined.contains("PAYLOAD:"); let text = if is_triage { self.triage_decision(joined) } else if joined.contains(RESEARCHER_FINDINGS) { // Orchestrator's follow-up turn after the sub-agent returned → // merge + finish. Checked BEFORE the marker because turn-2's // history echoes the tool-call arguments (which carry the marker). "Mock orchestrator merged the sub-agent findings.".to_string() } else if joined.contains(SUBAGENT_MARKER) { // The spawned researcher sub-agent's own turn → return findings, // no further tool calls (prevents recursive spawning). RESEARCHER_FINDINGS.to_string() } else if self.spawn { // Orchestrator's first turn → delegate via a real spawn_subagent // tool call. return ModelResponse { message: AssistantMessage { id: None, content: Vec::new(), tool_calls: vec![ToolCall::new( "call-1", "spawn_subagent", serde_json::json!({ "agent_id": "researcher", "prompt": format!("{SUBAGENT_MARKER}: investigate the Q3 numbers"), }), )], usage: None, }, usage: None, finish_reason: Some("tool_calls".to_string()), raw: None, resolved_model: None, continue_turn: None, }; } else { "Mock orchestrator handled the promoted trigger.".to_string() }; ModelResponse::assistant(text) } fn triage_turns(&self) -> usize { self.seen .lock() .unwrap() .iter() .filter(|p| p.contains("DISPLAY_LABEL:") && p.contains("PAYLOAD:")) .count() } fn saw(&self, needle: &str) -> bool { self.seen.lock().unwrap().iter().any(|p| p.contains(needle)) } } #[async_trait] impl ChatModel<()> for MockLlm { fn profile(&self) -> Option<&ModelProfile> { Some(&self.profile) } async fn invoke( &self, _state: &(), request: ModelRequest, ) -> tinyagents::Result { let joined = request .messages .iter() .map(|message| message.text()) .collect::>() .join("\n"); Ok(self.respond(&joined)) } } // ───────────────────────────────────────────────────────────────────────────── // Hermetic harness: temp HOME + config + globals + installed mock model. // ───────────────────────────────────────────────────────────────────────────── /// Serializes these tests: they mutate process-global env + install a /// process-global model override. fn serial() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| StdMutex::new(())) .lock() .unwrap_or_else(|p| p.into_inner()) } struct EnvGuard { key: &'static str, old: Option, } impl EnvGuard { fn set(key: &'static str, val: &str) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, val); Self { key, old } } fn unset(key: &'static str) -> Self { let old = std::env::var(key).ok(); std::env::remove_var(key); Self { key, old } } } impl Drop for EnvGuard { fn drop(&mut self) { match &self.old { Some(v) => std::env::set_var(self.key, v), None => std::env::remove_var(self.key), } } } struct Harness { workspace: std::path::PathBuf, mock: Arc, _install: test_provider_override::InstallGuard, _home: EnvGuard, _ws: EnvGuard, _keyring: EnvGuard, _tmp: tempfile::TempDir, } fn write_config(openhuman_dir: &std::path::Path) { // api_url is never dialed — the model override intercepts creation // before any URL is used — but config must parse and disable local AI so // nothing reaches Ollama either. let cfg = r#"api_url = "http://127.0.0.1:9" default_model = "mock-model" default_temperature = 0.2 chat_onboarding_completed = true [secrets] encrypt = false [local_ai] enabled = false runtime_enabled = false "#; let write = |dir: &std::path::Path| { std::fs::create_dir_all(dir).expect("mkdir"); std::fs::write(dir.join("config.toml"), cfg).expect("write config"); }; write(openhuman_dir); write(&openhuman_dir.join("users").join("local")); // Sanity: the config must match the schema. let _: openhuman_core::openhuman::config::Config = toml::from_str(cfg).expect("config schema"); } fn harness() -> Harness { harness_with(MockLlm::new()) } fn harness_spawning() -> Harness { harness_with(MockLlm::with_spawning()) } fn harness_with(mock: Arc) -> Harness { let tmp = tempfile::tempdir().expect("tempdir"); let home = tmp.path().to_path_buf(); let openhuman_dir = home.join(".openhuman"); write_config(&openhuman_dir); let workspace = home.join("workspace"); std::fs::create_dir_all(&workspace).expect("mkdir ws"); let home_guard = EnvGuard::set("HOME", home.to_str().unwrap()); let ws_guard = EnvGuard::set("OPENHUMAN_WORKSPACE", workspace.to_str().unwrap()); let keyring_guard = EnvGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"); // Globals the real pipeline needs. init_global(64); openhuman_core::openhuman::agent::bus::register_agent_handlers(); let _ = AgentDefinitionRegistry::init_global_builtins(); // Install the mock LLM — both model funnels consult this first. let install = test_provider_override::install_model(mock.clone()); Harness { workspace, mock, _install: install, _home: home_guard, _ws: ws_guard, _keyring: keyring_guard, _tmp: tmp, } } fn human_event(message: &str) -> DomainEvent { DomainEvent::ChannelInboundMessage { event_name: "msg".into(), channel: "slack".into(), message: message.into(), sender: Some("U1".into()), reply_target: Some("dm".into()), thread_ts: Some("t1".into()), raw_data: serde_json::Value::Null, } } fn cron_event() -> DomainEvent { DomainEvent::CronJobTriggered { job_id: "nightly".into(), job_name: "nightly recap".into(), job_type: "agent".into(), } } // ───────────────────────────────────────────────────────────────────────────── // Gate (real triage pipeline) over the mock — promote vs drop. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] async fn fullstack_gate_promotes_human_message_via_real_triage() { let _s = serial(); let h = harness(); let gate = GatePass::new(100); let trigger = normalize(&human_event("can you help with the Q3 plan?"), 1.0).unwrap(); let decision = gate.evaluate(&trigger, 1.0).await; println!("\n[fullstack] human trigger gate decision: {decision:?}"); assert!( decision.is_promote(), "real triage (mock-backed) should promote an actionable human message; got {decision:?}" ); assert!(h.mock.triage_turns() >= 1, "the real triage LLM path ran"); } #[tokio::test] async fn fullstack_gate_drops_cron_noise_via_real_triage() { let _s = serial(); let h = harness(); let gate = GatePass::new(100); let trigger = normalize(&cron_event(), 1.0).unwrap(); let decision = gate.evaluate(&trigger, 1.0).await; println!("[fullstack] cron trigger gate decision: {decision:?}"); assert!( !decision.is_promote(), "routine cron noise should be dropped" ); assert!(h.mock.triage_turns() >= 1); } // ───────────────────────────────────────────────────────────────────────────── // Session (real orchestrator agent + tool loop) over the mock. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] async fn fullstack_session_runs_real_agent_and_persists() { let _s = serial(); let h = harness(); let session = LongLivedSession::with_thread( h.workspace.clone(), SubconsciousMode::Aggressive, "subconscious:orchestrator".into(), ); let outcome = session .process_promoted("[user/slack] Please look into the Q3 numbers.", false) .await; println!("[fullstack] session outcome: {outcome:?}"); let outcome = outcome.expect("real agent turn (mock-backed) should succeed"); assert!( outcome.response.contains("Mock orchestrator"), "session returned the mock agent's reply: {}", outcome.response ); // Real reserved-thread persistence: the user turn + agent reply landed. let msgs = openhuman_core::openhuman::memory_conversations::get_messages( h.workspace.clone(), "subconscious:orchestrator", ) .expect("read reserved thread"); let senders: Vec<&str> = msgs.iter().map(|m| m.sender.as_str()).collect(); assert!( senders.contains(&"user"), "user turn persisted: {senders:?}" ); assert!( senders.contains(&"agent"), "agent reply persisted: {senders:?}" ); } // ───────────────────────────────────────────────────────────────────────────── // Full chain: human → subconscious session → REAL sub-agent → back → human. // The mock makes the orchestrator emit a real `spawn_subagent` tool call, so // the harness runs an actual researcher sub-agent (inheriting the mock // model), whose output is merged by the orchestrator's follow-up turn. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] async fn fullstack_session_spawns_real_subagent_and_merges() { let _s = serial(); let h = harness_spawning(); let session = LongLivedSession::with_thread( h.workspace.clone(), SubconsciousMode::Aggressive, "subconscious:orchestrator".into(), ); let outcome = session .process_promoted("[user/slack] Please research the Q3 numbers.", false) .await; println!("[fullstack] spawn outcome: {outcome:?}"); let outcome = outcome.expect("orchestrator turn with a real sub-agent should succeed"); // The orchestrator delegated and a REAL researcher sub-agent ran (its turn // carried our SUBAGENT_TASK marker), and its findings flowed back into the // session result — the full human → subconscious → sub-agent → back chain, // all production code, mock-backed model. assert!( h.mock.saw(SUBAGENT_MARKER), "the real harness ran the spawned researcher sub-agent" ); assert!( outcome.response.contains("Researcher findings") || outcome.response.contains("merged the sub-agent findings"), "the sub-agent's output flowed back to the session: {}", outcome.response ); }