use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::Instant,
};
use crate::{
a2a::TaskState,
agent::{
failure_message, task_state_for, AgentLoopExit, AttemptEnding, SESSION_ENDED_STATUS_MESSAGE,
},
cancel::CANCELED_STATUS_MESSAGE,
errors::RuntimeError,
hooks::HookArtifact,
streaming::{
emit_chunk_sse, emit_chunk_sse_final, emit_sse, emit_thinking_chunk_sse, SseBroadcast,
SseEventBuffer, StreamArtifact, StreamFrame, StreamStatus, TaskArtifactUpdateEvent,
TaskStatusUpdateEvent, TaskToolCallProgressEvent, TaskToolCallStartedEvent,
},
tool_call_progress::ToolCallProgress,
};
struct A2aTarget {
sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
task_id: String,
context_id: Option<String>,
}
#[derive(Default)]
struct Segment {
open: bool,
streamed_text: bool,
cursor_removal_owed: bool,
streamed_thinking: bool,
}
struct Wire<'a> {
tx: &'a SseBroadcast,
buf: &'a Arc<Mutex<SseEventBuffer>>,
task_id: &'a str,
}
pub(super) struct A2aStream {
target: Option<A2aTarget>,
chunks_emitted: Arc<AtomicBool>,
segment: Segment,
result: String,
interrupted: bool,
harness_killed: bool,
spend_refusal: Option<String>,
calls: ToolCallProgress,
}
impl A2aStream {
pub(super) fn new(
sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
task_id: Option<String>,
context_id: Option<String>,
chunks_emitted: Arc<AtomicBool>,
) -> Self {
Self {
target: task_id.map(|task_id| A2aTarget {
sse,
task_id,
context_id,
}),
chunks_emitted,
segment: Segment::default(),
result: String::new(),
interrupted: false,
harness_killed: false,
spend_refusal: None,
calls: ToolCallProgress::default(),
}
}
pub(super) fn mark_interrupted(&mut self) {
self.interrupted = true;
}
pub(super) fn mark_harness_killed(&mut self) {
self.harness_killed = true;
}
pub(super) fn mark_spend_refused(&mut self, refusal: &crate::spend::SpendRefusal) {
self.spend_refusal = Some(refusal.to_string());
}
pub(super) async fn open_segment(&mut self, turn: u32) {
if self.segment.open {
return;
}
self.segment = Segment {
open: true,
..Segment::default()
};
self.chunks_emitted.store(false, Ordering::Relaxed);
self.status("working", &format!("inference turn {turn}"))
.await;
}
pub(super) fn text_delta(&mut self, text: &str) {
self.segment.streamed_text = true;
self.segment.cursor_removal_owed = true;
self.chunks_emitted.store(true, Ordering::Relaxed);
if let Some(wire) = self.wire() {
emit_chunk_sse(wire.tx, wire.buf, wire.task_id, text);
}
}
pub(super) fn complete_text(&mut self) {
self.pay_cursor_removal();
}
pub(super) fn thinking_delta(&mut self, text: &str) {
self.segment.streamed_thinking = true;
if let Some(wire) = self.wire() {
emit_thinking_chunk_sse(wire.tx, wire.buf, wire.task_id, text);
}
}
pub(super) fn complete_thinking(&mut self, text: &str) {
if self.segment.streamed_thinking {
return;
}
if let Some(wire) = self.wire() {
emit_thinking_chunk_sse(wire.tx, wire.buf, wire.task_id, text);
}
}
pub(super) async fn tool_call_started(&mut self, turn: u32, id: &str, name: &str) {
if !self.calls.start(id, name) {
return;
}
self.open_segment(turn).await;
let Some(target) = self.target.as_ref() else {
return;
};
emit_sse(
&target.sse,
StreamFrame::ToolCallStarted,
&TaskToolCallStartedEvent {
id: target.task_id.clone(),
tool_call_id: id.to_string(),
tool_name: name.to_string(),
},
)
.await;
}
pub(super) async fn tool_call_progress(&mut self, id: &str, input_bytes: u64, now: Instant) {
let Some(size) = self.calls.report(id, input_bytes, now) else {
return;
};
self.progress(id, size).await;
}
pub(super) async fn tool_call_complete(&mut self, id: &str, now: Instant) -> Option<String> {
let call = self.calls.complete(id, now)?;
if let Some(size) = call.held {
self.progress(id, size).await;
}
Some(call.name)
}
pub(super) async fn tool_result(&mut self, artifact: StreamArtifact) {
if let Some(id) = artifact.tool_call_id.as_deref() {
self.calls.settle(id);
}
self.pay_cursor_removal();
self.segment.open = false;
self.artifact(artifact).await;
}
pub(super) async fn turn_end(&mut self, result: &str, hook_artifacts: Vec<HookArtifact>) {
self.pay_cursor_removal();
self.segment.open = false;
self.result = result.to_string();
if self.interrupted {
return;
}
for hook_artifact in hook_artifacts {
self.artifact(StreamArtifact::hook(
hook_artifact.hook_name,
hook_artifact.payload,
))
.await;
}
if self.segment.streamed_text || result.is_empty() {
return;
}
if let Some(wire) = self.wire() {
emit_chunk_sse_final(wire.tx, wire.buf, wire.task_id, result);
}
}
pub(super) fn turn_failed(&mut self) {
self.pay_cursor_removal();
self.segment.open = false;
}
pub(super) fn ending(&self, outcome: &Result<AgentLoopExit, RuntimeError>) -> AttemptEnding {
match task_state_for(outcome) {
TaskState::Completed => AttemptEnding {
state: TaskState::Completed,
message: SESSION_ENDED_STATUS_MESSAGE.to_string(),
response: Some(self.result.clone()),
no_answer: false,
},
TaskState::Canceled => AttemptEnding {
state: TaskState::Canceled,
message: canceled_message(self.harness_killed).to_string(),
response: None,
no_answer: false,
},
_ => AttemptEnding {
state: TaskState::Failed,
message: match outcome {
Ok(AgentLoopExit::SpendCeilingReached) => {
self.spend_refusal.clone().unwrap_or_default()
}
Ok(exit) => exit.as_str().to_string(),
Err(error) => failure_message(error),
},
response: None,
no_answer: false,
},
}
}
fn pay_cursor_removal(&mut self) {
if !self.segment.cursor_removal_owed {
return;
}
self.segment.cursor_removal_owed = false;
if let Some(wire) = self.wire() {
emit_chunk_sse_final(wire.tx, wire.buf, wire.task_id, "");
}
}
async fn artifact(&self, artifact: StreamArtifact) {
let Some(target) = self.target.as_ref() else {
return;
};
emit_sse(
&target.sse,
StreamFrame::Artifact,
&TaskArtifactUpdateEvent {
id: target.task_id.clone(),
artifact,
},
)
.await;
}
async fn progress(&self, tool_call_id: &str, input_bytes: u64) {
let Some(target) = self.target.as_ref() else {
return;
};
emit_sse(
&target.sse,
StreamFrame::ToolCallProgress,
&TaskToolCallProgressEvent {
id: target.task_id.clone(),
tool_call_id: tool_call_id.to_string(),
input_bytes,
},
)
.await;
}
async fn status(&self, state: &str, message: &str) {
let Some(target) = self.target.as_ref() else {
return;
};
emit_sse(
&target.sse,
StreamFrame::Status,
&TaskStatusUpdateEvent {
id: target.task_id.clone(),
context_id: target.context_id.clone(),
status: StreamStatus {
state: state.to_string(),
message: message.to_string(),
response: None,
reopen: None,
},
r#final: false,
},
)
.await;
}
fn wire(&self) -> Option<Wire<'_>> {
let target = self.target.as_ref()?;
let (tx, buf) = target.sse.as_ref()?;
Some(Wire {
tx,
buf,
task_id: &target.task_id,
})
}
}
fn canceled_message(harness_killed: bool) -> &'static str {
if harness_killed {
CANCELED_KILLED_STATUS_MESSAGE
} else {
CANCELED_STATUS_MESSAGE
}
}
const CANCELED_KILLED_STATUS_MESSAGE: &str =
"task canceled; the harness was killed and its session may not resume cleanly";
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::streaming::ReplayResult;
fn at(base: Instant, ms: u64) -> Instant {
base + Duration::from_millis(ms)
}
fn stream() -> (A2aStream, Arc<Mutex<SseEventBuffer>>) {
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let frames = Arc::new(Mutex::new(SseEventBuffer::new(64)));
let stream = A2aStream::new(
Some((tx, Arc::clone(&frames))),
Some("tsk_test".to_string()),
None,
Arc::new(AtomicBool::new(false)),
);
(stream, frames)
}
fn event_types(frames: &Arc<Mutex<SseEventBuffer>>) -> Vec<String> {
let ReplayResult::Complete(frames) = frames.lock().unwrap().replay_from(0) else {
panic!("a gap in a test buffer");
};
frames
.iter()
.map(|frame| {
frame
.lines()
.find_map(|line| line.strip_prefix("event: "))
.unwrap()
.to_string()
})
.collect()
}
#[tokio::test]
async fn progress_a_report_after_the_complete_call_writes_nothing() {
let (mut stream, frames) = stream();
let now = Instant::now();
stream.tool_call_started(1, "c1", "write_file").await;
stream.tool_call_progress("c1", 12, now).await;
assert_eq!(
stream.tool_call_complete("c1", at(now, 1)).await.as_deref(),
Some("write_file")
);
stream.tool_call_progress("c1", 40, at(now, 1_000)).await;
assert_eq!(
event_types(&frames),
["status", "tool-call-started", "tool-call-progress"]
);
}
#[tokio::test]
async fn a_start_after_the_complete_call_opens_no_segment() {
let (mut stream, frames) = stream();
assert_eq!(stream.tool_call_complete("c1", Instant::now()).await, None);
stream.tool_call_started(1, "c1", "write_file").await;
assert!(event_types(&frames).is_empty());
}
}