Files
openhuman/tests/subconscious_triggers_e2e.rs
T

593 lines
24 KiB
Rust

//! End-to-end scenario simulation for the subconscious **trigger pipeline**.
//!
//! This exercises the real public pipeline functions wired together —
//! `normalize` → `TriggerRegistry::admit` (dedupe + rate) → gate mapping
//! (`map_triage_to_gate` + `apply_budget`) → `OrchestratorQueue` — across
//! every trigger source and gate outcome, plus the real `notify_user`
//! handoff (event bus + reserved-thread persistence) and the reserved-thread
//! cold-boot contract the long-lived session depends on.
//!
//! It is hermetic (no network, no model) and deterministic: the LLM gate is
//! simulated by feeding the real `map_triage_to_gate` a `TriageDecision` for
//! each `TriageAction`, so we test *our* promote/drop/dedupe/budget/queue
//! logic exhaustively. The actual triage model call and the full agent turn
//! run through the native bus + global provider and are covered by the
//! `agent::triage` and agent-harness test suites.
//!
//! Run with a visible trace:
//! `cargo test --test subconscious_triggers_e2e -- --nocapture`
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use openhuman_core::core::event_bus::{global, init_global, DomainEvent};
use openhuman_core::openhuman::agent::triage::{TriageAction, TriageDecision};
use openhuman_core::openhuman::subconscious::{
notify_user, ORCHESTRATOR_THREAD_ID, USER_THREAD_ID,
};
use openhuman_core::openhuman::subconscious_triggers::gate::{apply_budget, map_triage_to_gate};
use openhuman_core::openhuman::subconscious_triggers::{
normalize, AdmitOutcome, DedupeWindow, EnqueueOutcome, GateDecision, OrchestratorQueue,
PromotionBudget, RateLimiter, Trigger, TriggerPriority, TriggerRegistry, TriggerSource,
};
// ─────────────────────────────────────────────────────────────────────────────
// Event constructors for the four v1 trigger sources.
// ─────────────────────────────────────────────────────────────────────────────
fn cron_event(job_id: &str, name: &str) -> DomainEvent {
DomainEvent::CronJobTriggered {
job_id: job_id.into(),
job_name: name.into(),
job_type: "agent".into(),
}
}
fn user_event(channel: &str, sender: Option<&str>, message: &str) -> DomainEvent {
DomainEvent::ChannelInboundMessage {
event_name: "msg".into(),
channel: channel.into(),
message: message.into(),
sender: sender.map(str::to_string),
reply_target: Some("dm".into()),
thread_ts: Some("t1".into()),
raw_data: serde_json::Value::Null,
}
}
fn composio_event(metadata_id: &str, subject: &str) -> DomainEvent {
DomainEvent::ComposioTriggerReceived {
toolkit: "gmail".into(),
trigger: "GMAIL_NEW_GMAIL_MESSAGE".into(),
metadata_id: metadata_id.into(),
metadata_uuid: format!("{metadata_id}-uuid"),
payload: serde_json::json!({ "subject": subject, "body": "PRIVATE BODY" }),
}
}
fn subagent_done_event(task_id: &str, agent_id: &str) -> DomainEvent {
DomainEvent::SubagentCompleted {
parent_session: "subconscious:orchestrator".into(),
task_id: task_id.into(),
agent_id: agent_id.into(),
elapsed_ms: 10,
output_chars: 100,
iterations: 2,
}
}
fn triage(action: TriageAction, prompt: Option<&str>) -> TriageDecision {
TriageDecision {
action,
target_agent: prompt.map(|_| "orchestrator".into()),
prompt: prompt.map(str::to_string),
reason: "simulated gate verdict".into(),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 1 — normalization: every source maps with the right shape.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn scenario_normalization_covers_all_sources() {
let now = 100.0;
let cron = normalize(&cron_event("j1", "morning brief"), now).expect("cron normalizes");
assert_eq!(cron.priority, TriggerPriority::Low);
assert!(!cron.external_content);
assert!(matches!(cron.source, TriggerSource::Cron { .. }));
let user = normalize(&user_event("slack", Some("U1"), "what's on my plate?"), now)
.expect("user normalizes");
assert_eq!(user.priority, TriggerPriority::High);
assert!(
user.external_content,
"inbound channel messages are untrusted"
);
assert!(user.payload.gate_summary.contains("what's on my plate"));
let composio =
normalize(&composio_event("evt-1", "Invoice #42"), now).expect("composio normalizes");
assert_eq!(composio.priority, TriggerPriority::Normal);
assert!(composio.external_content, "third-party content is tainted");
// Redaction: the private body never reaches the gate summary.
assert!(!composio.payload.gate_summary.contains("PRIVATE BODY"));
// …but is retained in raw for promotion synthesis.
assert_eq!(composio.payload.raw["payload"]["body"], "PRIVATE BODY");
let subagent =
normalize(&subagent_done_event("task-1", "researcher"), now).expect("subagent normalizes");
assert!(matches!(
subagent.source,
TriggerSource::SubagentConclusion { ok: true, .. }
));
// A self-authored proactive message must NOT become a trigger (anti-loop).
assert!(
normalize(&user_event("slack", Some("subconscious"), "proactive"), now).is_none(),
"orchestrator's own output must not re-trigger it"
);
// Unrelated events are ignored.
assert!(normalize(
&DomainEvent::ChannelConnected {
channel: "slack".into()
},
now
)
.is_none());
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 2 — admission: dedupe collapses storms, rate limits floods.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn scenario_dedupe_collapses_webhook_storm() {
let reg = TriggerRegistry::with_defaults();
// 50 identical Gmail webhooks (same metadata_id) within the TTL window.
let mut admitted = 0;
let mut duplicates = 0;
for i in 0..50 {
let ev = composio_event("same-evt", "dup");
let t = normalize(&ev, 1000.0 + i as f64 * 0.01).expect("normalize");
match reg.admit(&t, 1000.0 + i as f64 * 0.01) {
AdmitOutcome::Admitted => admitted += 1,
AdmitOutcome::Duplicate => duplicates += 1,
AdmitOutcome::RateLimited => {}
}
}
assert_eq!(admitted, 1, "only the first of the storm is admitted");
assert_eq!(duplicates, 49);
}
#[test]
fn scenario_rate_limit_caps_distinct_events_per_source() {
// Tiny bucket: capacity 2, no refill, so the 3rd distinct event of the
// same family is rate-limited even though it passes dedupe.
let reg = TriggerRegistry::new(DedupeWindow::new(600.0), RateLimiter::new(2.0, 0.0));
let outcomes: Vec<AdmitOutcome> = (0..3)
.map(|i| {
let ev = composio_event(&format!("evt-{i}"), "x");
let t = normalize(&ev, 5.0).expect("normalize");
reg.admit(&t, 5.0)
})
.collect();
assert_eq!(outcomes[0], AdmitOutcome::Admitted);
assert_eq!(outcomes[1], AdmitOutcome::Admitted);
assert_eq!(outcomes[2], AdmitOutcome::RateLimited);
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 3 — gate decisions: every triage action maps correctly.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn scenario_gate_maps_every_triage_action() {
let now = 0.0;
let budget = PromotionBudget::new(100);
let base = normalize(&composio_event("g1", "subj"), now).expect("normalize");
// Drop → dropped, not acknowledged.
let d = apply_budget(
map_triage_to_gate(&triage(TriageAction::Drop, None), &base),
&budget,
now,
);
assert!(matches!(
d,
GateDecision::Drop {
acknowledge: false,
..
}
));
// Acknowledge → dropped but acknowledged.
let a = apply_budget(
map_triage_to_gate(&triage(TriageAction::Acknowledge, None), &base),
&budget,
now,
);
assert!(matches!(
a,
GateDecision::Drop {
acknowledge: true,
..
}
));
// React → promote, keeping the trigger's own priority.
let r = map_triage_to_gate(&triage(TriageAction::React, Some("ack it")), &base);
match r {
GateDecision::Promote {
priority,
synthesized_summary,
..
} => {
assert_eq!(priority, base.priority);
assert!(synthesized_summary.contains("ack it"));
}
other => panic!("expected promote, got {other:?}"),
}
// Escalate → promote at >= High.
let e = map_triage_to_gate(&triage(TriageAction::Escalate, Some("draft reply")), &base);
match e {
GateDecision::Promote { priority, .. } => assert!(priority >= TriggerPriority::High),
other => panic!("expected promote, got {other:?}"),
}
}
#[test]
fn scenario_promotion_budget_exhaustion_downgrades_to_ack_drop() {
let now = 0.0;
let budget = PromotionBudget::new(1); // one promotion per hour
let base = normalize(&user_event("slack", Some("U1"), "urgent!"), now).expect("normalize");
let escalate = || map_triage_to_gate(&triage(TriageAction::Escalate, Some("do it")), &base);
// First escalation promotes.
assert!(apply_budget(escalate(), &budget, now).is_promote());
// Second within the hour is downgraded to an acknowledged drop — noted,
// but no reasoning-tier session run is spent.
match apply_budget(escalate(), &budget, now) {
GateDecision::Drop {
acknowledge,
reason,
} => {
assert!(acknowledge);
assert!(reason.contains("budget exhausted"));
}
other => panic!("expected budget downgrade, got {other:?}"),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 4 — queue: priority ordering + overflow eviction.
// ─────────────────────────────────────────────────────────────────────────────
fn promoted(label: &str, priority: TriggerPriority) -> Trigger {
let mut t = normalize(&cron_event("j", label), 0.0).expect("normalize");
t.display_label = label.into();
t.priority = priority;
t
}
#[test]
fn scenario_queue_serves_highest_priority_first() {
let q = OrchestratorQueue::new(16);
q.push(promoted("cron-low", TriggerPriority::Low));
q.push(promoted("user-high", TriggerPriority::High));
q.push(promoted("webhook-normal", TriggerPriority::Normal));
q.push(promoted("interrupt-urgent", TriggerPriority::Urgent));
let drained: Vec<String> = std::iter::from_fn(|| q.pop())
.map(|t| t.display_label)
.collect();
assert_eq!(
drained,
vec![
"interrupt-urgent",
"user-high",
"webhook-normal",
"cron-low"
]
);
}
#[test]
fn scenario_queue_overflow_sheds_lowest_priority() {
let q = OrchestratorQueue::new(2);
assert_eq!(
q.push(promoted("low", TriggerPriority::Low)),
EnqueueOutcome::Accepted
);
assert_eq!(
q.push(promoted("normal", TriggerPriority::Normal)),
EnqueueOutcome::Accepted
);
// Full; an Urgent arrival evicts the lowest-priority held item.
match q.push(promoted("urgent", TriggerPriority::Urgent)) {
EnqueueOutcome::EvictedLowest { evicted } => assert_eq!(evicted.display_label, "low"),
other => panic!("expected eviction, got {other:?}"),
}
// Full again; a Low arrival is dropped rather than evicting a better item.
assert_eq!(
q.push(promoted("late-low", TriggerPriority::Low)),
EnqueueOutcome::DroppedIncoming
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 5 — full pipeline simulation across scenarios, with a printed trace.
// ─────────────────────────────────────────────────────────────────────────────
/// One scenario: a raw event + the gate verdict the (simulated) LLM returns.
struct Scenario {
name: &'static str,
event: DomainEvent,
action: TriageAction,
prompt: Option<&'static str>,
}
/// Outcome of running a scenario through the whole pipeline.
#[derive(Debug)]
enum Outcome {
Ignored,
Duplicate,
RateLimited,
Dropped,
Promoted { priority: TriggerPriority },
}
/// Drive one event through normalize → admit → gate → enqueue, returning what
/// happened and (on promotion) pushing onto `queue`.
fn run_scenario(
s: &Scenario,
reg: &TriggerRegistry,
budget: &PromotionBudget,
queue: &OrchestratorQueue,
now: f64,
) -> Outcome {
let Some(trigger) = normalize(&s.event, now) else {
return Outcome::Ignored;
};
match reg.admit(&trigger, now) {
AdmitOutcome::Duplicate => return Outcome::Duplicate,
AdmitOutcome::RateLimited => return Outcome::RateLimited,
AdmitOutcome::Admitted => {}
}
let decision = apply_budget(
map_triage_to_gate(&triage(s.action, s.prompt), &trigger),
budget,
now,
);
match decision {
GateDecision::Drop { .. } => Outcome::Dropped,
GateDecision::Promote {
priority,
synthesized_summary,
..
} => {
let mut item = trigger;
item.priority = priority;
item.payload.gate_summary = synthesized_summary;
queue.push(item);
Outcome::Promoted { priority }
}
}
}
#[test]
fn scenario_full_pipeline_simulation_with_trace() {
let reg = TriggerRegistry::with_defaults();
let budget = PromotionBudget::new(2); // tight budget to exercise exhaustion
let queue = OrchestratorQueue::new(64);
let scenarios = vec![
Scenario {
name: "cron tick — gate drops routine noise",
event: cron_event("nightly", "nightly recap"),
action: TriageAction::Drop,
prompt: None,
},
Scenario {
name: "user message — gate escalates (promote #1)",
event: user_event("slack", Some("U1"), "can you prep the Q3 deck?"),
action: TriageAction::Escalate,
prompt: Some("prepare the Q3 deck"),
},
Scenario {
name: "composio gmail — gate reacts (promote #2)",
event: composio_event("inv-1", "Invoice overdue"),
action: TriageAction::React,
prompt: Some("flag the overdue invoice"),
},
Scenario {
name: "composio gmail DUPLICATE — collapsed by dedupe",
event: composio_event("inv-1", "Invoice overdue"),
action: TriageAction::React,
prompt: Some("flag the overdue invoice"),
},
Scenario {
name: "subagent conclusion — would promote but budget exhausted",
event: subagent_done_event("task-9", "researcher"),
action: TriageAction::Escalate,
prompt: Some("merge the research findings"),
},
Scenario {
name: "self-authored proactive echo — ignored (anti-loop)",
event: user_event("slack", Some("subconscious"), "FYI: deck is ready"),
action: TriageAction::Escalate,
prompt: Some("should never run"),
},
];
println!("\n=== subconscious trigger pipeline — scenario trace ===");
let mut promotions = 0;
let mut trace = Vec::new();
for (i, s) in scenarios.iter().enumerate() {
let outcome = run_scenario(s, &reg, &budget, &queue, 1000.0 + i as f64);
if matches!(outcome, Outcome::Promoted { .. }) {
promotions += 1;
}
println!(" [{i}] {:<55} -> {outcome:?}", s.name);
trace.push(outcome);
}
println!(" queue depth after gating: {}", queue.len());
println!("=== drain (highest priority first) ===");
while let Some(item) = queue.pop() {
println!(
" run -> {:<24} priority={}",
item.display_label,
item.priority.as_str()
);
}
// Assertions on what the pipeline decided.
assert!(matches!(trace[0], Outcome::Dropped), "cron noise dropped");
assert!(
matches!(trace[1], Outcome::Promoted { priority } if priority >= TriggerPriority::High),
"user escalation promoted at >= High"
);
assert!(
matches!(trace[2], Outcome::Promoted { .. }),
"gmail react promoted"
);
assert!(
matches!(trace[3], Outcome::Duplicate),
"duplicate gmail collapsed"
);
assert!(
matches!(trace[4], Outcome::Dropped),
"budget (2) exhausted → 3rd promotion downgraded to drop"
);
assert!(matches!(trace[5], Outcome::Ignored), "self-echo ignored");
assert_eq!(promotions, 2, "exactly two promotions within the budget");
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 6 — notify_user: real event bus + reserved user-thread persistence.
// ─────────────────────────────────────────────────────────────────────────────
/// Serializes tests that touch the process-global event bus so concurrent
/// captures don't cross-talk.
fn bus_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
#[tokio::test]
async fn scenario_notify_user_delivers_and_persists() {
let _guard = bus_lock();
init_global(64);
let captured: Arc<StdMutex<Vec<DomainEvent>>> = Arc::new(StdMutex::new(Vec::new()));
let sink = Arc::clone(&captured);
let _sub = global()
.expect("bus initialized")
.on("e2e-notify-capture", move |event| {
let sink = Arc::clone(&sink);
let event = event.clone();
Box::pin(async move {
if let DomainEvent::ProactiveMessageRequested { .. } = &event {
sink.lock().unwrap().push(event);
}
})
});
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().to_path_buf();
let unique = "E2E-NOTIFY-MARKER-7321";
notify_user(workspace.clone(), unique, Some("heads-up"));
// Give the async broadcast a moment to deliver.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// 1) The proactive delivery event fired, tagged as subconscious-sourced.
let events = captured.lock().unwrap();
let found = events.iter().any(|e| match e {
DomainEvent::ProactiveMessageRequested {
source, message, ..
} => source == "subconscious" && message == unique,
_ => false,
});
assert!(
found,
"notify_user must publish a ProactiveMessageRequested event"
);
// 2) The message landed in the reserved user-facing thread.
let persisted =
openhuman_core::openhuman::memory_conversations::get_messages(workspace, USER_THREAD_ID)
.expect("read user thread");
assert!(
persisted
.iter()
.any(|m| m.content == unique && m.sender == "agent"),
"notify_user must persist to the user-facing thread"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 7 — reserved-thread cold-boot contract (what the session resumes from).
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn scenario_reserved_threads_are_distinct_and_persist() {
use openhuman_core::openhuman::memory_conversations::{
append_message, ensure_thread, get_messages, ConversationMessage, CreateConversationThread,
};
assert_ne!(
ORCHESTRATOR_THREAD_ID, USER_THREAD_ID,
"orchestrator and user threads must be distinct"
);
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().to_path_buf();
// The reserved thread must exist before appending (the production code
// ensures this lazily; mirror it here).
ensure_thread(
workspace.clone(),
CreateConversationThread {
id: ORCHESTRATOR_THREAD_ID.into(),
title: "Subconscious Orchestrator".into(),
created_at: "2026-06-12T00:00:00Z".into(),
parent_thread_id: None,
labels: None,
personality_id: None,
},
)
.expect("ensure thread");
// Simulate prior orchestrator history that a long-lived session would
// cold-boot resume from via seed_resume_from_messages.
for (sender, content) in [
("user", "[composio/gmail] new invoice"),
("agent", "Noted; I'll watch for the follow-up."),
] {
append_message(
workspace.clone(),
ORCHESTRATOR_THREAD_ID,
ConversationMessage {
id: format!("m-{sender}"),
content: content.into(),
message_type: "text".into(),
extra_metadata: serde_json::Value::Null,
sender: sender.into(),
created_at: "2026-06-12T00:00:00Z".into(),
},
)
.expect("append");
}
let history = get_messages(workspace, ORCHESTRATOR_THREAD_ID).expect("read");
assert_eq!(history.len(), 2);
assert_eq!(history[0].sender, "user");
assert_eq!(history[1].sender, "agent");
}