use harness_core::{Event, Hook, HookOutcome, World};
use serde::Serialize;
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::broadcast;
#[derive(Debug, Clone, Serialize)]
pub struct BroadcastEvent {
pub seq: u64,
pub at_ms: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
pub event: &'static str,
pub payload: Value,
}
pub struct BroadcastHook {
tx: broadcast::Sender<BroadcastEvent>,
seq: AtomicU64,
with_deltas: bool,
}
impl BroadcastHook {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity.max(1));
Self {
tx,
seq: AtomicU64::new(0),
with_deltas: true,
}
}
pub fn without_deltas(mut self) -> Self {
self.with_deltas = false;
self
}
pub fn subscribe(&self) -> broadcast::Receiver<BroadcastEvent> {
self.tx.subscribe()
}
pub fn sender(&self) -> broadcast::Sender<BroadcastEvent> {
self.tx.clone()
}
}
fn project(ev: &Event<'_>) -> Option<Value> {
Some(match ev {
Event::PostModel { out } => json!({
"text": out.text,
"reasoning": out.reasoning,
"tool_calls": out.tool_calls.iter().map(|c| json!({
"id": c.id, "name": c.name, "args": c.args,
})).collect::<Vec<_>>(),
"usage": out.usage,
"stop_reason": out.stop_reason,
}),
Event::ModelTokenDelta { text } => json!({ "text": text }),
Event::PreToolUse { action } => json!({
"tool": action.tool, "call_id": action.call_id, "args": action.args,
}),
Event::PostToolUse { action, result } => json!({
"tool": action.tool, "call_id": action.call_id,
"ok": result.ok, "content": result.content,
}),
Event::SessionStart { source } => json!({ "source": source }),
Event::SessionEnd => json!({}),
Event::Cancelled => json!({}),
Event::TaskCompleted => json!({}),
Event::SubagentStart { name } => json!({ "name": name }),
Event::SubagentReport { status } => json!({ "status": status }),
Event::PostCompact {
stage,
before,
after,
} => json!({
"stage": format!("{stage:?}"), "tokens_before": before, "tokens_after": after,
}),
Event::BudgetWarning { ratio } => json!({ "ratio": ratio }),
Event::Error { message } => json!({ "message": message }),
Event::Heartbeat { iter } => json!({ "iter": iter }),
_ => return None,
})
}
impl Hook for BroadcastHook {
fn name(&self) -> &str {
"broadcast"
}
fn matches(&self, ev: &Event<'_>) -> bool {
if !self.with_deltas && matches!(ev, Event::ModelTokenDelta { .. }) {
return false;
}
project(ev).is_some()
}
fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
if let Some(payload) = project(ev) {
let _ = self.tx.send(BroadcastEvent {
seq: self.seq.fetch_add(1, Ordering::Relaxed),
at_ms: world.clock.now_ms(),
session: world.session.as_ref().map(|s| s.id.clone()),
actor: world
.session
.as_ref()
.filter(|s| !s.actor.is_empty())
.map(|s| s.actor.clone()),
event: ev.name(),
payload,
});
}
HookOutcome::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::{ModelOutput, StopReason, ToolCall, Usage};
fn world() -> World {
let ws = std::env::temp_dir().join(format!("bcast-{}", std::process::id()));
std::fs::create_dir_all(&ws).unwrap();
harness_context::default_world(&ws)
}
fn model_out() -> ModelOutput {
ModelOutput {
text: Some("the answer".into()),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "read_file".into(),
args: serde_json::json!({"path": "a.txt"}),
}],
usage: Usage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
},
stop_reason: StopReason::EndTurn,
..Default::default()
}
}
#[tokio::test]
async fn every_subscriber_gets_every_model_return() {
let hook = BroadcastHook::new(16);
let mut ui = hook.subscribe();
let mut audit = hook.subscribe();
let mut w = world();
let out = model_out();
hook.fire(&Event::PostModel { out: &out }, &mut w);
for rx in [&mut ui, &mut audit] {
let ev = rx.recv().await.unwrap();
assert_eq!(ev.event, "PostModel");
assert_eq!(ev.payload["text"], "the answer");
assert_eq!(ev.payload["tool_calls"][0]["name"], "read_file");
assert_eq!(ev.payload["usage"]["input_tokens"], 10);
}
}
#[tokio::test]
async fn a_slow_subscriber_lags_alone_and_the_loop_never_blocks() {
let hook = BroadcastHook::new(2); let mut slow = hook.subscribe();
let mut w = world();
let out = model_out();
for _ in 0..5 {
hook.fire(&Event::PostModel { out: &out }, &mut w);
}
match slow.recv().await {
Err(broadcast::error::RecvError::Lagged(n)) => assert_eq!(n, 3),
other => panic!("expected Lagged, got {other:?}"),
}
assert_eq!(slow.recv().await.unwrap().seq, 3);
assert_eq!(slow.recv().await.unwrap().seq, 4);
}
#[tokio::test]
async fn without_deltas_keeps_turn_level_events_only() {
let hook = BroadcastHook::new(16).without_deltas();
assert!(!hook.matches(&Event::ModelTokenDelta { text: "x" }));
let out = model_out();
assert!(hook.matches(&Event::PostModel { out: &out }));
}
}