mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
593 lines
24 KiB
Rust
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, ®, &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");
|
|
}
|