use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures_util::StreamExt;
use lc_agents::types::{AgentAction, AgentOutput, AgentStep, ToolInput};
use lc_agents::{AgentError, AgentExecutor, AgentStreamEvent, BaseAgent};
use lc_core::tools::{BaseTool, ToolError};
struct GatedTool {
started: tokio::sync::watch::Sender<bool>,
release: tokio::sync::watch::Sender<bool>,
}
#[async_trait]
impl BaseTool for GatedTool {
fn name(&self) -> &str {
"gate"
}
fn description(&self) -> &str {
"blocks until released"
}
async fn run(&self, _input: String) -> Result<String, ToolError> {
let _ = self.started.send(true);
let mut rx = self.release.subscribe();
while !*rx.borrow() {
if rx.changed().await.is_err() {
break;
}
}
Ok("gated-ok".to_string())
}
}
struct SingleCallAgent {
plan_calls: Arc<AtomicUsize>,
}
#[async_trait]
impl BaseAgent for SingleCallAgent {
async fn plan(
&self,
_intermediate_steps: &[AgentStep],
_inputs: &HashMap<String, String>,
_config: Option<&lc_core::runnables::RunnableConfig>,
) -> Result<AgentOutput, AgentError> {
self.plan_calls.fetch_add(1, Ordering::SeqCst);
Ok(AgentOutput::Action(AgentAction {
tool: "gate".to_string(),
tool_input: ToolInput::Object {
value: serde_json::json!({}),
},
log: "call_gate".to_string(),
}))
}
}
#[tokio::test]
async fn dropped_stream_stops_background_agent_loop() {
let (started_tx, mut started_rx) = tokio::sync::watch::channel(false);
let (release_tx, _release_rx) = tokio::sync::watch::channel(false);
let plan_calls = Arc::new(AtomicUsize::new(0));
let executor = AgentExecutor::new(
Arc::new(SingleCallAgent {
plan_calls: plan_calls.clone(),
}),
vec![Arc::new(GatedTool {
started: started_tx,
release: release_tx.clone(),
})],
);
let stream = executor.stream("go".to_string());
let (drop_tx, drop_rx) = tokio::sync::oneshot::channel::<()>();
let reader = tokio::spawn(async move {
let mut stream = stream;
while let Some(ev) = stream.next().await {
if matches!(ev, Ok(AgentStreamEvent::ToolStart { .. })) {
break;
}
}
let _ = drop_rx.await;
});
tokio::time::timeout(Duration::from_secs(5), async {
while !*started_rx.borrow() {
if started_rx.changed().await.is_err() {
break;
}
}
})
.await
.expect("gated tool should start within 5s");
assert_eq!(
plan_calls.load(Ordering::SeqCst),
1,
"one plan before the drop"
);
let _ = drop_tx.send(());
tokio::time::timeout(Duration::from_secs(5), reader)
.await
.expect("reader should finish after dropping the stream")
.expect("reader task should not panic");
let _ = release_tx.send(true);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
plan_calls.load(Ordering::SeqCst),
1,
"dropped stream must stop the agent loop — plan must not run again"
);
}