use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use chrono::Utc;
use tokio::sync::{RwLock, broadcast, mpsc};
use crate::control::actor::{
ActorCommand, CRITICAL_OBSERVER_CAPACITY, DEFAULT_BROADCAST_CAPACITY, SessionActorConfig,
SessionActorHandle, run_actor, spawn_actor,
};
use crate::control::backend::{SessionBackend, SessionInput};
use crate::control::event::{BackendKind, SessionEvent};
use crate::control::id::ControlSessionId;
use crate::control::state::{SessionMetadata, SessionState};
struct ChanBackend {
rx: mpsc::Receiver<Option<anyhow::Result<Vec<SessionEvent>>>>,
stop_flag: Option<Arc<std::sync::atomic::AtomicBool>>,
}
#[async_trait::async_trait]
impl SessionBackend for ChanBackend {
async fn send(&mut self, _: SessionInput) -> anyhow::Result<()> {
Ok(())
}
async fn recv(&mut self) -> Option<anyhow::Result<Vec<SessionEvent>>> {
self.rx.recv().await.unwrap_or_default()
}
async fn stop(self) -> anyhow::Result<()> {
if let Some(flag) = self.stop_flag {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
}
Ok(())
}
}
struct StubBackend {
events: std::collections::VecDeque<Option<anyhow::Result<Vec<SessionEvent>>>>,
block_when_exhausted: bool,
}
impl StubBackend {
fn new_clean_exit() -> Self {
Self {
events: std::collections::VecDeque::from([None]),
block_when_exhausted: false,
}
}
fn new_never_exits() -> Self {
Self {
events: std::collections::VecDeque::new(),
block_when_exhausted: true,
}
}
fn new_with_output(session_id: &ControlSessionId) -> Self {
let out = SessionEvent::Output {
session_id: session_id.clone(),
raw: "hello".into(),
structured: None,
ts: Utc::now(),
};
Self {
events: std::collections::VecDeque::from([Some(Ok(vec![out])), None]),
block_when_exhausted: false,
}
}
}
#[async_trait::async_trait]
impl SessionBackend for StubBackend {
async fn send(&mut self, _msg: SessionInput) -> anyhow::Result<()> {
Ok(())
}
async fn recv(&mut self) -> Option<anyhow::Result<Vec<SessionEvent>>> {
if let Some(ev) = self.events.pop_front() {
ev
} else if self.block_when_exhausted {
std::future::pending::<Option<anyhow::Result<Vec<SessionEvent>>>>().await
} else {
None
}
}
async fn stop(self) -> anyhow::Result<()> {
Ok(())
}
}
fn make_handle(
_id: &ControlSessionId,
_project: &str,
command_tx: mpsc::Sender<ActorCommand>,
event_tx: broadcast::Sender<SessionEvent>,
metadata: Arc<RwLock<SessionMetadata>>,
) -> SessionActorHandle {
SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::new(AtomicBool::new(false)),
metadata,
}
}
#[tokio::test]
async fn actor_broadcasts_started_event() {
let id = ControlSessionId::new("proj", 0);
let handle = spawn_actor(
id.clone(),
"proj".into(),
BackendKind::StreamJson,
StubBackend::new_clean_exit(),
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
let mut rx = handle.event_tx.subscribe();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
let mut found_started = false;
loop {
match rx.try_recv() {
Ok(SessionEvent::Started { .. }) => {
found_started = true;
break;
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::task::yield_now().await;
}
Err(_) => break,
}
}
assert!(found_started, "actor must emit SessionEvent::Started");
}
#[tokio::test]
async fn actor_command_stop_terminates() {
let id = ControlSessionId::new("proj", 1);
let handle = spawn_actor(
id.clone(),
"proj".into(),
BackendKind::StreamJson,
StubBackend::new_never_exits(),
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
handle.command_tx.send(ActorCommand::Stop).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let m = handle.metadata.read().await;
assert!(m.state.is_terminal(), "expected terminal, got: {}", m.state);
}
#[tokio::test]
async fn actor_clean_exit_from_backend_stops_actor() {
let id = ControlSessionId::new("proj", 2);
let handle = spawn_actor(
id.clone(),
"proj".into(),
BackendKind::StreamJson,
StubBackend::new_with_output(&id),
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let m = handle.metadata.read().await;
assert!(m.state.is_terminal(), "expected terminal, got: {}", m.state);
}
#[test]
fn actor_handle_write_lock_cas() {
let lock = Arc::new(AtomicBool::new(false));
let (command_tx, _) = mpsc::channel(1);
let (event_tx, _) = broadcast::channel(4);
let id = ControlSessionId::new("p", 0);
let handle = SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::clone(&lock),
metadata: Arc::new(RwLock::new(SessionMetadata::new(
id,
"p".into(),
BackendKind::StreamJson,
))),
};
assert!(handle.try_acquire_write_lock());
assert!(!handle.try_acquire_write_lock());
handle.release_write_lock();
assert!(handle.try_acquire_write_lock());
}
#[tokio::test]
async fn actor_activity_parsed_on_output() {
let id = ControlSessionId::new("parse-proj", 0);
let output_event = SessionEvent::Output {
session_id: id.clone(),
raw: "Tool use: Bash".into(),
structured: None,
ts: Utc::now(),
};
let backend = StubBackend {
events: std::collections::VecDeque::from([Some(Ok(vec![output_event])), None]),
block_when_exhausted: false,
};
let handle = spawn_actor(
id.clone(),
"parse-proj".into(),
BackendKind::StreamJson,
backend,
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
let m = handle.metadata.read().await;
assert!(
m.last_summary.is_some(),
"last_summary should be set after tool-use output"
);
assert!(
m.last_summary.as_deref().unwrap_or("").contains("Bash"),
"last_summary should mention 'Bash'"
);
}
#[tokio::test]
async fn actor_pending_decision_extracted() {
let id = ControlSessionId::new("decision-proj", 0);
let output_event = SessionEvent::Output {
session_id: id.clone(),
raw: "Proceed with deletion? [Y/n]:".into(),
structured: None,
ts: Utc::now(),
};
let backend = StubBackend {
events: std::collections::VecDeque::from([Some(Ok(vec![output_event])), None]),
block_when_exhausted: false,
};
let handle = spawn_actor(
id.clone(),
"decision-proj".into(),
BackendKind::StreamJson,
backend,
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
let m = handle.metadata.read().await;
assert!(
m.pending_decision.is_some(),
"pending_decision should be set for a [Y/n] prompt"
);
assert!(
m.proposed_default.is_some(),
"proposed_default should be set when [Y/n] default is present"
);
}
#[tokio::test]
async fn actor_pending_decision_cleared_on_next_output() {
let id = ControlSessionId::new("clear-proj", 0);
let decision_event = SessionEvent::Output {
session_id: id.clone(),
raw: "Confirm? [Y/n]:".into(),
structured: None,
ts: Utc::now(),
};
let normal_event = SessionEvent::Output {
session_id: id.clone(),
raw: "Tool use: Bash".into(),
structured: None,
ts: Utc::now(),
};
let backend = StubBackend {
events: std::collections::VecDeque::from([
Some(Ok(vec![decision_event])),
Some(Ok(vec![normal_event])),
None,
]),
block_when_exhausted: false,
};
let handle = spawn_actor(
id.clone(),
"clear-proj".into(),
BackendKind::StreamJson,
backend,
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let m = handle.metadata.read().await;
assert!(
m.pending_decision.is_none(),
"pending_decision must be cleared after a non-decision output, \
got: {:?}",
m.pending_decision
);
assert!(
m.proposed_default.is_none(),
"proposed_default must be cleared after a non-decision output"
);
}
#[tokio::test]
async fn actor_auth_prompt_batch_trailing_event_forwarded() {
let id = ControlSessionId::new("auth-batch-proj", 0);
let batch = vec![
SessionEvent::Output {
session_id: id.clone(),
raw: "Normal output before auth".into(),
structured: None,
ts: Utc::now(),
},
SessionEvent::Output {
session_id: id.clone(),
raw: "Please log in to claude.ai".into(), structured: None,
ts: Utc::now(),
},
SessionEvent::Output {
session_id: id.clone(),
raw: "trailing-sentinel-event".into(), structured: None,
ts: Utc::now(),
},
];
let backend = StubBackend {
events: std::collections::VecDeque::from([Some(Ok(batch)), None]),
block_when_exhausted: false,
};
let handle = spawn_actor(
id.clone(),
"auth-batch-proj".into(),
BackendKind::Tmux, backend,
DEFAULT_BROADCAST_CAPACITY,
SessionActorConfig::default(),
);
let mut rx = handle.event_tx.subscribe();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
let mut raw_outputs: Vec<String> = Vec::new();
loop {
match rx.try_recv() {
Ok(SessionEvent::Output { raw, .. }) => {
raw_outputs.push(raw);
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::task::yield_now().await;
}
Err(_) => break,
}
}
assert!(
raw_outputs
.iter()
.any(|r| r.contains("trailing-sentinel-event")),
"trailing event after AuthPrompt in the same batch must be broadcast; \
received outputs: {raw_outputs:?}"
);
assert!(
raw_outputs
.iter()
.any(|r| r.contains("Normal output before auth")),
"first event before AuthPrompt must also be broadcast; \
received outputs: {raw_outputs:?}"
);
}
#[tokio::test]
async fn critical_observer_lag_yields_awaiting_intervention() {
let id = ControlSessionId::new("crit-proj", 0);
let stop_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_flag_clone = Arc::clone(&stop_called);
let (backend_tx, backend_rx) = mpsc::channel::<Option<anyhow::Result<Vec<SessionEvent>>>>(32);
let backend = ChanBackend {
rx: backend_rx,
stop_flag: Some(stop_flag_clone),
};
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let bcast_cap = (CRITICAL_OBSERVER_CAPACITY + 16) * 4;
let (event_tx, _) = broadcast::channel::<SessionEvent>(bcast_cap);
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"crit-proj".into(),
BackendKind::StreamJson,
)));
let handle = make_handle(
&id,
"crit-proj",
command_tx,
event_tx.clone(),
Arc::clone(&metadata),
);
let mut bcast_rx = event_tx.subscribe();
tokio::spawn(run_actor(
id.clone(),
backend,
BackendKind::StreamJson,
command_rx,
event_tx.clone(),
Arc::clone(&metadata),
SessionActorConfig::default(),
));
let (sub_tx, sub_rx) = tokio::sync::oneshot::channel();
handle
.command_tx
.send(ActorCommand::SubscribeCritical(sub_tx))
.await
.unwrap();
let _critical_rx = sub_rx.await.expect("actor must reply to SubscribeCritical");
let overflow_count = CRITICAL_OBSERVER_CAPACITY + 2;
let big_batch: Vec<SessionEvent> = (0..overflow_count as u32)
.map(|i| SessionEvent::Output {
session_id: id.clone(),
raw: format!("overflow-{i}"),
structured: None,
ts: Utc::now(),
})
.collect();
backend_tx
.send(Some(Ok(big_batch)))
.await
.expect("backend channel open");
let lag_seen = tokio::time::timeout(tokio::time::Duration::from_secs(2), async {
loop {
match bcast_rx.recv().await {
Ok(SessionEvent::ObserverCriticalLag { .. }) => break true,
Ok(_) => {} Err(broadcast::error::RecvError::Lagged(_)) => {
}
Err(broadcast::error::RecvError::Closed) => break false,
}
}
})
.await
.unwrap_or(false);
assert!(
lag_seen,
"ObserverCriticalLag event must be emitted when critical mpsc is full"
);
let m = handle.metadata.read().await;
assert_eq!(
m.state,
SessionState::AwaitingIntervention,
"critical lag must transition to AwaitingIntervention, got: {}",
m.state
);
assert!(
stop_called.load(std::sync::atomic::Ordering::SeqCst),
"backend stop() must be called on critical-lag path (no resource leak)"
);
}
#[tokio::test]
async fn critical_observer_disconnected_keeps_session_running() {
let id = ControlSessionId::new("disc-proj", 0);
let (backend_tx, backend_rx) = mpsc::channel::<Option<anyhow::Result<Vec<SessionEvent>>>>(32);
let backend = ChanBackend {
rx: backend_rx,
stop_flag: None,
};
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let (event_tx, _) = broadcast::channel::<SessionEvent>(DEFAULT_BROADCAST_CAPACITY);
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"disc-proj".into(),
BackendKind::StreamJson,
)));
let handle = make_handle(
&id,
"disc-proj",
command_tx,
event_tx.clone(),
Arc::clone(&metadata),
);
let mut bcast_rx = event_tx.subscribe();
tokio::spawn(run_actor(
id.clone(),
backend,
BackendKind::StreamJson,
command_rx,
event_tx.clone(),
Arc::clone(&metadata),
SessionActorConfig::default(),
));
let (sub_tx, sub_rx) = tokio::sync::oneshot::channel();
handle
.command_tx
.send(ActorCommand::SubscribeCritical(sub_tx))
.await
.unwrap();
let critical_rx = sub_rx.await.expect("actor must reply to SubscribeCritical");
drop(critical_rx);
let events: Vec<SessionEvent> = (0..5u32)
.map(|i| SessionEvent::Output {
session_id: id.clone(),
raw: format!("after-disconnect-{i}"),
structured: None,
ts: Utc::now(),
})
.collect();
backend_tx.send(Some(Ok(events))).await.unwrap();
backend_tx.send(None).await.unwrap();
let stopped = tokio::time::timeout(tokio::time::Duration::from_secs(2), async {
loop {
match bcast_rx.recv().await {
Ok(SessionEvent::Stopped { .. }) => break true,
Ok(_) => {}
Err(_) => break false,
}
}
})
.await
.unwrap_or(false);
assert!(
stopped,
"session should reach Stopped after clean backend exit"
);
let m = handle.metadata.read().await;
assert_ne!(
m.state,
SessionState::AwaitingIntervention,
"Disconnected critical observer must NOT trigger AwaitingIntervention; \
got: {}",
m.state
);
}
#[tokio::test]
async fn critical_observer_double_subscribe_rejects_second() {
let id = ControlSessionId::new("dbl-proj", 0);
let (_backend_tx, backend_rx) = mpsc::channel::<Option<anyhow::Result<Vec<SessionEvent>>>>(32);
let backend = ChanBackend {
rx: backend_rx,
stop_flag: None,
};
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let (event_tx, _) = broadcast::channel::<SessionEvent>(DEFAULT_BROADCAST_CAPACITY);
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"dbl-proj".into(),
BackendKind::StreamJson,
)));
let _handle = make_handle(
&id,
"dbl-proj",
command_tx.clone(),
event_tx.clone(),
Arc::clone(&metadata),
);
tokio::spawn(run_actor(
id.clone(),
backend,
BackendKind::StreamJson,
command_rx,
event_tx.clone(),
Arc::clone(&metadata),
SessionActorConfig::default(),
));
let (sub_tx1, sub_rx1) = tokio::sync::oneshot::channel();
command_tx
.send(ActorCommand::SubscribeCritical(sub_tx1))
.await
.unwrap();
let first_rx = sub_rx1.await.expect("first SubscribeCritical must succeed");
let (sub_tx2, sub_rx2) = tokio::sync::oneshot::channel();
command_tx
.send(ActorCommand::SubscribeCritical(sub_tx2))
.await
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let second_result = sub_rx2.await;
assert!(
second_result.is_err(),
"second SubscribeCritical must be rejected (reply_tx dropped) \
while first observer is connected"
);
assert!(
!first_rx.is_closed(),
"first critical Receiver must remain open after a rejected second subscription"
);
}
#[tokio::test]
async fn critical_observer_no_false_positive_when_keeping_up() {
let id = ControlSessionId::new("nofp-proj", 0);
let (backend_tx, backend_rx) = mpsc::channel::<Option<anyhow::Result<Vec<SessionEvent>>>>(32);
let backend = ChanBackend {
rx: backend_rx,
stop_flag: None,
};
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let (event_tx, _) = broadcast::channel::<SessionEvent>(DEFAULT_BROADCAST_CAPACITY);
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"nofp-proj".into(),
BackendKind::StreamJson,
)));
let handle = make_handle(
&id,
"nofp-proj",
command_tx,
event_tx.clone(),
Arc::clone(&metadata),
);
tokio::spawn(run_actor(
id.clone(),
backend,
BackendKind::StreamJson,
command_rx,
event_tx.clone(),
Arc::clone(&metadata),
SessionActorConfig::default(),
));
let (sub_tx, sub_rx) = tokio::sync::oneshot::channel();
handle
.command_tx
.send(ActorCommand::SubscribeCritical(sub_tx))
.await
.unwrap();
let mut critical_rx = sub_rx.await.expect("actor must reply");
tokio::spawn(async move { while critical_rx.recv().await.is_some() {} });
let burst: Vec<SessionEvent> = (0..10u32)
.map(|i| SessionEvent::Output {
session_id: id.clone(),
raw: format!("burst-{i}"),
structured: None,
ts: Utc::now(),
})
.collect();
backend_tx.send(Some(Ok(burst))).await.unwrap();
drop(backend_tx);
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
let m = handle.metadata.read().await;
assert_ne!(
m.state,
SessionState::AwaitingIntervention,
"no false positive: state should not be AwaitingIntervention when \
consumer keeps up; got: {}",
m.state
);
}
type BackendFeed = mpsc::Sender<Option<anyhow::Result<Vec<SessionEvent>>>>;
type ChanActorRig = (
SessionActorHandle,
broadcast::Receiver<SessionEvent>,
BackendFeed,
);
fn spawn_chan_actor(
id: &ControlSessionId,
backend_kind: BackendKind,
actor_config: SessionActorConfig,
) -> ChanActorRig {
let (backend_tx, backend_rx) = mpsc::channel::<Option<anyhow::Result<Vec<SessionEvent>>>>(32);
let backend = ChanBackend {
rx: backend_rx,
stop_flag: None,
};
let (command_tx, command_rx) = mpsc::channel::<ActorCommand>(32);
let (event_tx, _) = broadcast::channel::<SessionEvent>(DEFAULT_BROADCAST_CAPACITY);
let metadata = Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"auth-proj".into(),
backend_kind,
)));
let handle = make_handle(
id,
"auth-proj",
command_tx,
event_tx.clone(),
Arc::clone(&metadata),
);
let bcast_rx = event_tx.subscribe();
tokio::spawn(run_actor(
id.clone(),
backend,
backend_kind,
command_rx,
event_tx.clone(),
metadata,
actor_config,
));
(handle, bcast_rx, backend_tx)
}
async fn await_terminal(
handle: &SessionActorHandle,
timeout: tokio::time::Duration,
) -> SessionState {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let state = { handle.metadata.read().await.state.clone() };
if state.is_terminal() || tokio::time::Instant::now() >= deadline {
return state;
}
tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
}
}
#[tokio::test(start_paused = true)]
async fn actor_auth_timeout_fires_and_stops() {
let id = ControlSessionId::new("auth-to", 0);
let cfg = SessionActorConfig {
auth_timeout: tokio::time::Duration::from_secs(5),
classifier: None,
};
let (handle, _bcast, backend_tx) = spawn_chan_actor(&id, BackendKind::Tmux, cfg);
let auth_event = SessionEvent::Output {
session_id: id.clone(),
raw: "Please log in to claude.ai to continue".into(),
structured: None,
ts: Utc::now(),
};
backend_tx.send(Some(Ok(vec![auth_event]))).await.unwrap();
tokio::task::yield_now().await;
tokio::time::advance(tokio::time::Duration::from_secs(10)).await;
tokio::task::yield_now().await;
for _ in 0..20 {
let state = handle.metadata.read().await.state.clone();
if state.is_terminal() {
break;
}
tokio::task::yield_now().await;
}
let state = handle.metadata.read().await.state.clone();
assert_eq!(
state,
SessionState::AuthFailed,
"session stuck in AwaitingAuth past the timeout must become AuthFailed; got: {state}"
);
}
#[tokio::test(start_paused = true)]
async fn actor_auth_timeout_does_not_fire_without_awaiting_auth() {
let id = ControlSessionId::new("auth-noto", 0);
let cfg = SessionActorConfig {
auth_timeout: tokio::time::Duration::from_secs(5),
classifier: None,
};
let (handle, _bcast, backend_tx) = spawn_chan_actor(&id, BackendKind::Tmux, cfg);
let normal = SessionEvent::Output {
session_id: id.clone(),
raw: "Tool use: Bash".into(),
structured: None,
ts: Utc::now(),
};
backend_tx.send(Some(Ok(vec![normal]))).await.unwrap();
tokio::task::yield_now().await;
tokio::time::advance(tokio::time::Duration::from_secs(30)).await;
tokio::task::yield_now().await;
backend_tx.send(None).await.unwrap();
tokio::task::yield_now().await;
for _ in 0..20 {
let state = handle.metadata.read().await.state.clone();
if state.is_terminal() {
break;
}
tokio::task::yield_now().await;
}
let state = handle.metadata.read().await.state.clone();
assert_eq!(
state,
SessionState::Stopped,
"a session that never awaits auth must complete cleanly, never AuthFailed; got: {state}"
);
}
use crate::control::classifier::ActivityClassifier;
use crate::control::event::ActivityKind;
struct MockClassifier {
called: Arc<AtomicBool>,
result: Result<ActivityKind, String>,
}
#[async_trait::async_trait]
impl ActivityClassifier for MockClassifier {
async fn classify(&self, _raw_output: &str) -> Result<Option<ActivityKind>, String> {
self.called.store(true, std::sync::atomic::Ordering::SeqCst);
match &self.result {
Ok(kind) => Ok(Some(kind.clone())),
Err(e) => Err(e.clone()),
}
}
}
#[tokio::test]
async fn actor_classifier_gate_on_invokes_and_surfaces_label() {
let id = ControlSessionId::new("clf-on", 0);
let called = Arc::new(AtomicBool::new(false));
let mock = Arc::new(MockClassifier {
called: Arc::clone(&called),
result: Ok(ActivityKind::RateLimited),
});
let cfg = SessionActorConfig {
auth_timeout: tokio::time::Duration::from_secs(30),
classifier: Some(mock),
};
let (handle, _bcast, backend_tx) = spawn_chan_actor(&id, BackendKind::StreamJson, cfg);
let gibberish = SessionEvent::Output {
session_id: id.clone(),
raw: "zzxq unmatched gibberish no pattern here".into(),
structured: None,
ts: Utc::now(),
};
backend_tx.send(Some(Ok(vec![gibberish]))).await.unwrap();
backend_tx.send(None).await.unwrap();
let _ = await_terminal(&handle, tokio::time::Duration::from_secs(2)).await;
assert!(
called.load(std::sync::atomic::Ordering::SeqCst),
"gate-on must invoke the injected classifier on unmatched output"
);
let m = handle.metadata.read().await;
assert!(
m.last_summary
.as_deref()
.unwrap_or("")
.contains("llm-classified"),
"classified label must surface into last_summary; got: {:?}",
m.last_summary
);
}
#[tokio::test]
async fn actor_classifier_failure_is_fail_open() {
let id = ControlSessionId::new("clf-fail", 0);
let called = Arc::new(AtomicBool::new(false));
let mock = Arc::new(MockClassifier {
called: Arc::clone(&called),
result: Err("simulated openrouter outage".to_string()),
});
let cfg = SessionActorConfig {
auth_timeout: tokio::time::Duration::from_secs(30),
classifier: Some(mock),
};
let (handle, _bcast, backend_tx) = spawn_chan_actor(&id, BackendKind::StreamJson, cfg);
let gibberish = SessionEvent::Output {
session_id: id.clone(),
raw: "zzxq unmatched gibberish no pattern here".into(),
structured: None,
ts: Utc::now(),
};
backend_tx.send(Some(Ok(vec![gibberish]))).await.unwrap();
backend_tx.send(None).await.unwrap();
let state = await_terminal(&handle, tokio::time::Duration::from_secs(2)).await;
assert!(
called.load(std::sync::atomic::Ordering::SeqCst),
"the classifier must have been invoked"
);
assert_eq!(
state,
SessionState::Stopped,
"a classifier failure must be fail-open: session completes cleanly; got: {state}"
);
}