use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
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_frame, emit_status, emit_text, emit_thinking, MurmurFrame, StreamArtifact, StreamSink,
StreamTask,
},
tool_call_progress::ToolCallProgress,
};
struct A2aTarget {
sse: StreamSink,
task: StreamTask,
}
#[derive(Default)]
struct Segment {
open: bool,
streamed_text: bool,
cursor_removal_owed: bool,
streamed_thinking: bool,
}
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: StreamSink,
task_id: Option<String>,
context_id: Option<String>,
chunks_emitted: Arc<AtomicBool>,
) -> Self {
Self {
target: StreamTask::of(task_id.as_deref(), context_id.as_deref())
.map(|task| A2aTarget { sse, task }),
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);
if let Some(target) = &self.target {
emit_status(
&target.sse,
&target.task,
TaskState::Working,
&format!("inference turn {turn}"),
None,
);
}
}
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(target) = &self.target {
emit_text(&target.sse, &target.task, text, false);
}
}
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(target) = &self.target {
emit_thinking(&target.sse, &target.task, text);
}
}
pub(super) fn complete_thinking(&mut self, text: &str) {
if self.segment.streamed_thinking {
return;
}
if let Some(target) = &self.target {
emit_thinking(&target.sse, &target.task, 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;
self.frame(MurmurFrame::ToolCallStarted {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
});
}
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);
}
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);
}
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.frame(MurmurFrame::Artifact(artifact));
}
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.frame(MurmurFrame::Artifact(StreamArtifact::hook(
hook_artifact.hook_name,
hook_artifact.payload,
)));
}
if self.segment.streamed_text || result.is_empty() {
return;
}
if let Some(target) = &self.target {
emit_text(&target.sse, &target.task, result, true);
}
}
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(target) = &self.target {
emit_text(&target.sse, &target.task, "", true);
}
}
fn frame(&self, frame: MurmurFrame) {
if let Some(target) = &self.target {
emit_frame(&target.sse, &target.task, frame);
}
}
fn progress(&self, tool_call_id: &str, input_bytes: u64) {
self.frame(MurmurFrame::ToolCallProgress {
tool_call_id: tool_call_id.to_string(),
input_bytes,
});
}
}
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 std::sync::Mutex;
use super::*;
use crate::streaming::{buffered, event_kind, SseEventBuffer};
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()),
Some("ctx_test".to_string()),
Arc::new(AtomicBool::new(false)),
);
(stream, frames)
}
fn event_types(frames: &Arc<Mutex<SseEventBuffer>>) -> Vec<String> {
buffered(frames).iter().map(event_kind).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-update", "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());
}
}