mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
475 lines
20 KiB
Rust
475 lines
20 KiB
Rust
//! Multi-party conversation e2e: **human ↔ subconscious orchestrator ↔ sub-agent**.
|
|
//!
|
|
//! This drives the *real* [`TriggerOrchestrator`] (ingest → dedupe/rate →
|
|
//! gate → priority queue → serial run loop) with two injected seams — a
|
|
//! scripted [`Gate`] and a scripted [`SessionExecutor`] — so we can simulate
|
|
//! many back-and-forth comms patterns deterministically, without an LLM:
|
|
//!
|
|
//! - **human → subconscious**: a `ChannelInboundMessage` is normalized,
|
|
//! gated, promoted, and run.
|
|
//! - **subconscious → sub-agent**: the scripted session "spawns" a sub-agent
|
|
//! by emitting a follow-on `SubagentCompleted`/`SubagentFailed` event that
|
|
//! re-enters the real pipeline.
|
|
//! - **sub-agent → subconscious**: that conclusion is normalized, gated, and
|
|
//! merged by the session.
|
|
//! - **subconscious → human**: the session calls the real `notify_user`,
|
|
//! which we capture off the event bus.
|
|
//!
|
|
//! Everything between the two seams is the production code path: event
|
|
//! normalization, the dedupe window, per-source rate limiting, the promotion
|
|
//! gate budget, the priority queue, the serial loop, and the anti-self-trigger
|
|
//! guard. The harness re-injects emitted follow-on events through the same
|
|
//! `ingest` entry point the bus subscriber uses, so cascades are exercised
|
|
//! exactly as in production.
|
|
//!
|
|
//! Run with a visible transcript:
|
|
//! `cargo test --test subconscious_conversation_e2e -- --nocapture`
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use openhuman_core::core::event_bus::{global, init_global, DomainEvent};
|
|
use openhuman_core::openhuman::subconscious_triggers::types::{
|
|
GateDecision, Trigger, TriggerPriority, TriggerSource,
|
|
};
|
|
use openhuman_core::openhuman::subconscious_triggers::{
|
|
Gate, OrchestratorConfig, SessionExecutor, TriggerOrchestrator,
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared conversation transcript — the record of "what happened".
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone)]
|
|
struct Transcript(Arc<StdMutex<Vec<String>>>);
|
|
|
|
impl Transcript {
|
|
fn new() -> Self {
|
|
Self(Arc::new(StdMutex::new(Vec::new())))
|
|
}
|
|
fn push(&self, line: impl Into<String>) {
|
|
self.0.lock().unwrap().push(line.into());
|
|
}
|
|
fn lines(&self) -> Vec<String> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
fn count(&self, needle: &str) -> usize {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|l| l.contains(needle))
|
|
.count()
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scripted gate — promote/drop driven by trigger source + content rules.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
struct ScriptedGate {
|
|
transcript: Transcript,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Gate for ScriptedGate {
|
|
async fn evaluate(&self, trigger: &Trigger, _now: f64) -> GateDecision {
|
|
let summary = &trigger.payload.gate_summary;
|
|
// Routine cron noise is dropped.
|
|
let drop =
|
|
matches!(trigger.source, TriggerSource::Cron { .. }) || summary.contains("[ignore]");
|
|
if drop {
|
|
self.transcript
|
|
.push(format!("GATE drop {}", trigger.display_label));
|
|
return GateDecision::Drop {
|
|
acknowledge: false,
|
|
reason: "routine/no-op".into(),
|
|
};
|
|
}
|
|
// Everything else promotes. Urgent if the human flagged it.
|
|
let priority = if summary.to_lowercase().contains("urgent") {
|
|
TriggerPriority::Urgent
|
|
} else {
|
|
trigger.priority
|
|
};
|
|
self.transcript.push(format!(
|
|
"GATE promote {} (prio={})",
|
|
trigger.display_label,
|
|
priority.as_str()
|
|
));
|
|
GateDecision::Promote {
|
|
// Keep the original summary so the session can branch on it.
|
|
synthesized_summary: summary.clone(),
|
|
priority,
|
|
reason: "actionable".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scripted session — models the subconscious agent's behaviour, including
|
|
// spawning sub-agents (via emitted follow-on events) and notifying the human.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// A follow-on event the scripted session wants to feed back into the
|
|
/// orchestrator (simulating the bus fan-in for sub-agent conclusions).
|
|
type Emitter = Arc<StdMutex<VecDeque<DomainEvent>>>;
|
|
|
|
struct ScriptedSession {
|
|
transcript: Transcript,
|
|
workspace: std::path::PathBuf,
|
|
emit: Emitter,
|
|
/// Monotonic id for spawned sub-agent tasks.
|
|
next_task: Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
impl ScriptedSession {
|
|
fn emit(&self, event: DomainEvent) {
|
|
self.emit.lock().unwrap().push_back(event);
|
|
}
|
|
fn spawn_subagent(&self, agent_id: &str, ok: bool) -> String {
|
|
let n = self
|
|
.next_task
|
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
let task_id = format!("task-{n}");
|
|
self.transcript
|
|
.push(format!("SESSION spawn sub-agent '{agent_id}' ({task_id})"));
|
|
let event = if ok {
|
|
DomainEvent::SubagentCompleted {
|
|
parent_session: "subconscious:orchestrator".into(),
|
|
task_id: task_id.clone(),
|
|
agent_id: agent_id.into(),
|
|
elapsed_ms: 5,
|
|
output_chars: 120,
|
|
iterations: 2,
|
|
}
|
|
} else {
|
|
DomainEvent::SubagentFailed {
|
|
parent_session: "subconscious:orchestrator".into(),
|
|
task_id: task_id.clone(),
|
|
agent_id: agent_id.into(),
|
|
error: "tool timeout".into(),
|
|
}
|
|
};
|
|
self.emit(event);
|
|
task_id
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SessionExecutor for ScriptedSession {
|
|
async fn execute(&self, summary: &str, _external: bool) -> Result<String, String> {
|
|
let s = summary.to_lowercase();
|
|
|
|
// Branch on what the promoted turn is about — modelling the
|
|
// orchestrator agent's decisions. ORDER MATTERS: sub-agent conclusion
|
|
// summaries mention the agent id ("researcher" ⊃ "research"), so the
|
|
// conclusion branches must be checked before the delegate branch.
|
|
if s.contains("failed") {
|
|
// A sub-agent failed → recover, escalate a retry sub-agent.
|
|
self.transcript
|
|
.push("SESSION handle sub-agent FAILURE → retry");
|
|
self.spawn_subagent("researcher", true);
|
|
Ok("Retrying after failure.".into())
|
|
} else if s.contains("completed in") || s.contains("chars of output") {
|
|
// A sub-agent conclusion came back → merge + tell the human.
|
|
self.transcript
|
|
.push("SESSION merge sub-agent conclusion → notifying human");
|
|
openhuman_core::openhuman::subconscious::notify_user(
|
|
self.workspace.clone(),
|
|
"Your research is ready — here's the summary.",
|
|
Some("research done"),
|
|
);
|
|
self.transcript.push("SESSION notify human (done)");
|
|
Ok("Merged conclusion; user notified.".into())
|
|
} else if s.contains("research") || s.contains("prep") || s.contains("deck") {
|
|
// Human asked for deep work → delegate to a sub-agent.
|
|
self.transcript
|
|
.push(format!("SESSION run (delegating) :: {summary}"));
|
|
self.spawn_subagent("researcher", true);
|
|
Ok("Delegated to researcher.".into())
|
|
} else if s.contains("status") || s.contains("what") {
|
|
// Simple Q → answer the human directly, no sub-agent.
|
|
self.transcript
|
|
.push(format!("SESSION reply directly :: {summary}"));
|
|
openhuman_core::openhuman::subconscious::notify_user(
|
|
self.workspace.clone(),
|
|
"Here's your status.",
|
|
None,
|
|
);
|
|
Ok("Answered directly.".into())
|
|
} else {
|
|
self.transcript.push(format!("SESSION note :: {summary}"));
|
|
Ok("Noted.".into())
|
|
}
|
|
}
|
|
|
|
fn thread_id(&self) -> &str {
|
|
"subconscious:orchestrator"
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Harness.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Serializes tests that touch the process-global event bus.
|
|
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())
|
|
}
|
|
|
|
struct Harness {
|
|
orch: Arc<TriggerOrchestrator>,
|
|
transcript: Transcript,
|
|
emit: Emitter,
|
|
notifications: Arc<StdMutex<Vec<String>>>,
|
|
_loop: tokio::task::JoinHandle<()>,
|
|
_sub: openhuman_core::core::event_bus::SubscriptionHandle,
|
|
_tmp: tempfile::TempDir,
|
|
}
|
|
|
|
impl Harness {
|
|
fn new(config: OrchestratorConfig) -> Self {
|
|
init_global(128);
|
|
let transcript = Transcript::new();
|
|
let emit: Emitter = Arc::new(StdMutex::new(VecDeque::new()));
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
|
|
let session = Arc::new(ScriptedSession {
|
|
transcript: transcript.clone(),
|
|
workspace: tmp.path().to_path_buf(),
|
|
emit: Arc::clone(&emit),
|
|
next_task: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
|
});
|
|
let gate = Arc::new(ScriptedGate {
|
|
transcript: transcript.clone(),
|
|
});
|
|
let orch = Arc::new(TriggerOrchestrator::with_components(session, gate, config));
|
|
|
|
// Capture proactive (subconscious → human) deliveries off the bus.
|
|
let notifications = Arc::new(StdMutex::new(Vec::<String>::new()));
|
|
let sink = Arc::clone(¬ifications);
|
|
let sub = global().expect("bus").on("conv-e2e-notify", move |event| {
|
|
let sink = Arc::clone(&sink);
|
|
let event = event.clone();
|
|
Box::pin(async move {
|
|
if let DomainEvent::ProactiveMessageRequested {
|
|
source, message, ..
|
|
} = &event
|
|
{
|
|
if source == "subconscious" {
|
|
sink.lock().unwrap().push(message.clone());
|
|
}
|
|
}
|
|
})
|
|
});
|
|
|
|
let loop_handle = Arc::clone(&orch);
|
|
let task = tokio::spawn(async move { loop_handle.run_loop().await });
|
|
|
|
Self {
|
|
orch,
|
|
transcript,
|
|
emit,
|
|
notifications,
|
|
_loop: task,
|
|
_sub: sub,
|
|
_tmp: tmp,
|
|
}
|
|
}
|
|
|
|
/// Feed an inbound event into the orchestrator (as the bus subscriber would).
|
|
fn ingest(&self, event: DomainEvent) {
|
|
self.orch.ingest(&event);
|
|
}
|
|
|
|
/// Pump the cascade until quiescent: repeatedly drain follow-on events the
|
|
/// scripted session emitted back into `ingest`, waiting for the queue +
|
|
/// gate tasks to settle between rounds. Bounded so a runaway loop fails
|
|
/// fast instead of hanging.
|
|
async fn settle(&self) {
|
|
for _ in 0..200 {
|
|
// Let in-flight gate tasks + the run loop make progress.
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
let pending: Vec<DomainEvent> = {
|
|
let mut q = self.emit.lock().unwrap();
|
|
q.drain(..).collect()
|
|
};
|
|
for ev in &pending {
|
|
self.orch.ingest(ev);
|
|
}
|
|
// Quiescent when nothing is queued and nothing new was emitted.
|
|
if pending.is_empty() && self.orch.queue_depth() == 0 {
|
|
// One more grace round to catch a just-finished session run
|
|
// that emitted a trailing event.
|
|
tokio::time::sleep(Duration::from_millis(15)).await;
|
|
if self.emit.lock().unwrap().is_empty() && self.orch.queue_depth() == 0 {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
panic!("conversation did not settle — possible runaway cascade");
|
|
}
|
|
|
|
fn notifications(&self) -> Vec<String> {
|
|
self.notifications.lock().unwrap().clone()
|
|
}
|
|
|
|
fn print(&self, title: &str) {
|
|
println!("\n=== {title} ===");
|
|
for line in self.transcript.lines() {
|
|
println!(" {line}");
|
|
}
|
|
for n in self.notifications() {
|
|
println!(" >> to human: {n}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn human_msg(channel: &str, sender: &str, message: &str) -> DomainEvent {
|
|
DomainEvent::ChannelInboundMessage {
|
|
event_name: "msg".into(),
|
|
channel: channel.into(),
|
|
message: message.into(),
|
|
sender: Some(sender.into()),
|
|
reply_target: Some("dm".into()),
|
|
thread_ts: Some(format!("t-{}", message.len())),
|
|
raw_data: serde_json::Value::Null,
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scenario 1 — full round trip: human → session → sub-agent → session → human.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn conversation_human_delegates_then_subagent_reports_back() {
|
|
let _g = bus_lock();
|
|
let h = Harness::new(OrchestratorConfig::default());
|
|
|
|
// Human asks for deep work.
|
|
h.ingest(human_msg(
|
|
"slack",
|
|
"U1",
|
|
"can you prep the Q3 research deck?",
|
|
));
|
|
h.settle().await;
|
|
h.print("scenario 1: delegate → sub-agent → report back");
|
|
|
|
// The session delegated (spawned a sub-agent) …
|
|
assert_eq!(
|
|
h.transcript.count("spawn sub-agent"),
|
|
1,
|
|
"one sub-agent spawned"
|
|
);
|
|
// … the conclusion came back through the real pipeline and was merged …
|
|
assert_eq!(h.transcript.count("merge sub-agent conclusion"), 1);
|
|
// … and the human was notified exactly once.
|
|
let notes = h.notifications();
|
|
assert_eq!(notes.len(), 1, "exactly one human notification");
|
|
assert!(notes[0].contains("research is ready"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scenario 2 — sub-agent failure → recovery → retry → success → human.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn conversation_subagent_failure_recovers_with_retry() {
|
|
let _g = bus_lock();
|
|
let h = Harness::new(OrchestratorConfig::default());
|
|
|
|
// Inject a sub-agent FAILURE conclusion directly (as if a prior spawn failed).
|
|
h.ingest(DomainEvent::SubagentFailed {
|
|
parent_session: "subconscious:orchestrator".into(),
|
|
task_id: "task-0".into(),
|
|
agent_id: "researcher".into(),
|
|
error: "tool timeout".into(),
|
|
});
|
|
h.settle().await;
|
|
h.print("scenario 2: failure → retry → success → human");
|
|
|
|
// Failure handled, a retry sub-agent spawned, its success merged, human told.
|
|
assert_eq!(h.transcript.count("handle sub-agent FAILURE"), 1);
|
|
assert_eq!(h.transcript.count("spawn sub-agent"), 1, "one retry spawn");
|
|
assert_eq!(h.transcript.count("merge sub-agent conclusion"), 1);
|
|
assert_eq!(h.notifications().len(), 1);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scenario 3 — interleaved traffic: cron noise dropped, two humans, dedupe.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn conversation_interleaved_traffic_is_handled() {
|
|
let _g = bus_lock();
|
|
let h = Harness::new(OrchestratorConfig::default());
|
|
|
|
// Routine cron tick — gate should drop it (no session run).
|
|
h.ingest(DomainEvent::CronJobTriggered {
|
|
job_id: "nightly".into(),
|
|
job_name: "nightly recap".into(),
|
|
job_type: "agent".into(),
|
|
});
|
|
// Human A asks a simple question → direct reply, no sub-agent.
|
|
h.ingest(human_msg("slack", "U1", "what's my status today?"));
|
|
// Human B duplicate-sends the exact same message twice (transport retry).
|
|
let dup = human_msg("discord", "U2", "please prep the deck");
|
|
h.ingest(dup.clone());
|
|
h.ingest(dup);
|
|
h.settle().await;
|
|
h.print("scenario 3: interleaved cron + two humans + dedupe");
|
|
|
|
// Cron was dropped (no session run for it).
|
|
assert_eq!(h.transcript.count("GATE drop"), 1);
|
|
// Human A answered directly (no sub-agent for a status question).
|
|
assert_eq!(h.transcript.count("reply directly"), 1);
|
|
// Human B's duplicate collapsed → only ONE deck delegation despite two sends.
|
|
assert_eq!(
|
|
h.transcript.count("(delegating)"),
|
|
1,
|
|
"duplicate human msg collapsed"
|
|
);
|
|
// Two human-facing notifications: status answer + research-ready.
|
|
assert_eq!(h.notifications().len(), 2);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scenario 4 — back-pressure: promotion budget caps a burst of human asks.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn conversation_promotion_budget_caps_a_burst() {
|
|
let _g = bus_lock();
|
|
// The scripted gate intentionally has no promotion budget (that lives in
|
|
// the real GatePass), so this scenario isolates the *rate limiter*: a
|
|
// flood of distinct user messages from one source is capped by the token
|
|
// bucket (capacity 3, no refill).
|
|
let config = OrchestratorConfig {
|
|
rate_capacity: 3.0,
|
|
rate_refill_per_sec: 0.0,
|
|
..OrchestratorConfig::default()
|
|
};
|
|
let h = Harness::new(config);
|
|
|
|
for i in 0..6 {
|
|
h.ingest(human_msg("slack", "U1", &format!("status check #{i}")));
|
|
}
|
|
h.settle().await;
|
|
h.print("scenario 4: rate-limited burst of human messages");
|
|
|
|
// Only 3 of the 6 distinct messages passed the rate limiter → 3 replies.
|
|
assert_eq!(
|
|
h.transcript.count("reply directly"),
|
|
3,
|
|
"rate limiter capped the burst"
|
|
);
|
|
assert_eq!(h.notifications().len(), 3);
|
|
}
|