use std::collections::VecDeque;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
pub const JOURNAL_MAX_BYTES: usize = 2 * 1024 * 1024;
pub const PAGE_MAX_BYTES: usize = 1024 * 1024;
pub const TOOL_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WatchEvent {
ResponseStart,
ResponseReset,
Thinking {
text: String,
},
Text {
text: String,
},
ToolUseStart {
tool_id: String,
tool_name: String,
},
ToolUse {
tool_id: String,
tool_name: String,
input: Value,
},
ToolResultDelta {
tool_id: String,
delta: String,
},
ToolResult {
tool_id: String,
result: String,
},
ToolQueued {
tool_id: String,
},
ToolStarted {
tool_id: String,
},
Steered {
text: String,
},
Done,
Failed {
category: String,
},
}
impl WatchEvent {
pub fn from_stream(event: &StreamEvent) -> Option<Self> {
Some(match event {
StreamEvent::Llm(LlmEvent::ResponseStart) => Self::ResponseStart,
StreamEvent::Llm(LlmEvent::ResponseReset) => Self::ResponseReset,
StreamEvent::Llm(LlmEvent::Thinking(text)) => Self::Thinking { text: text.clone() },
StreamEvent::Llm(LlmEvent::Text(text)) => Self::Text { text: text.clone() },
StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => Self::ToolUseStart {
tool_id: tool_id.clone(),
tool_name: tool_name.clone(),
},
StreamEvent::Llm(LlmEvent::ToolUse {
tool_name,
tool_id,
input,
}) => Self::ToolUse {
tool_id: tool_id.clone(),
tool_name: tool_name.clone(),
input: cap_input(input),
},
StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id, delta }) => {
Self::ToolResultDelta {
tool_id: tool_id.clone(),
delta: delta.clone(),
}
}
StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => Self::ToolResult {
tool_id: tool_id.clone(),
result: cap_text(result, TOOL_PAYLOAD_MAX_BYTES),
},
StreamEvent::Llm(LlmEvent::ToolQueued { tool_id }) => Self::ToolQueued {
tool_id: tool_id.clone(),
},
StreamEvent::Llm(LlmEvent::ToolStarted { tool_id }) => Self::ToolStarted {
tool_id: tool_id.clone(),
},
StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => Self::Steered {
text: message.clone(),
},
StreamEvent::Session(SessionEvent::Done) => Self::Done,
StreamEvent::Session(SessionEvent::Error(err)) => Self::Failed {
category: err.category_label(),
},
_ => return None,
})
}
pub fn to_stream(&self) -> Option<StreamEvent> {
Some(match self {
Self::ResponseStart => StreamEvent::Llm(LlmEvent::ResponseStart),
Self::ResponseReset => StreamEvent::Llm(LlmEvent::ResponseReset),
Self::Thinking { text } => StreamEvent::Llm(LlmEvent::Thinking(text.clone())),
Self::Text { text } => StreamEvent::Llm(LlmEvent::Text(text.clone())),
Self::ToolUseStart { tool_id, tool_name } => StreamEvent::Llm(LlmEvent::ToolUseStart {
tool_name: tool_name.clone(),
tool_id: tool_id.clone(),
}),
Self::ToolUse {
tool_id,
tool_name,
input,
} => StreamEvent::Llm(LlmEvent::ToolUse {
tool_name: tool_name.clone(),
tool_id: tool_id.clone(),
input: input.clone(),
}),
Self::ToolResultDelta { tool_id, delta } => {
StreamEvent::Llm(LlmEvent::ToolResultDelta {
tool_id: tool_id.clone(),
delta: delta.clone(),
})
}
Self::ToolResult { tool_id, result } => StreamEvent::Llm(LlmEvent::ToolResult {
tool_id: tool_id.clone(),
result: result.clone(),
}),
Self::ToolQueued { tool_id } => StreamEvent::Llm(LlmEvent::ToolQueued {
tool_id: tool_id.clone(),
}),
Self::ToolStarted { tool_id } => StreamEvent::Llm(LlmEvent::ToolStarted {
tool_id: tool_id.clone(),
}),
Self::Steered { text } => StreamEvent::Agent(AgentEvent::SteeringDelivered {
message: text.clone(),
}),
Self::Done => StreamEvent::Session(SessionEvent::Done),
Self::Failed { .. } => return None,
})
}
fn size(&self) -> usize {
const OVERHEAD: usize = 32;
OVERHEAD
+ match self {
Self::ResponseStart | Self::ResponseReset | Self::Done => 0,
Self::Thinking { text } | Self::Text { text } | Self::Steered { text } => {
text.len()
}
Self::ToolUseStart { tool_id, tool_name } => tool_id.len() + tool_name.len(),
Self::ToolUse {
tool_id,
tool_name,
input,
} => tool_id.len() + tool_name.len() + input.to_string().len(),
Self::ToolResultDelta { tool_id, delta } => tool_id.len() + delta.len(),
Self::ToolResult { tool_id, result } => tool_id.len() + result.len(),
Self::ToolQueued { tool_id } | Self::ToolStarted { tool_id } => tool_id.len(),
Self::Failed { category } => category.len(),
}
}
fn absorb(&mut self, next: &WatchEvent) -> Option<usize> {
match (self, next) {
(Self::Text { text }, Self::Text { text: more })
| (Self::Thinking { text }, Self::Thinking { text: more }) => {
text.push_str(more);
Some(more.len())
}
(
Self::ToolResultDelta { tool_id, delta },
Self::ToolResultDelta {
tool_id: next_id,
delta: more,
},
) if tool_id == next_id && delta.len() < TOOL_PAYLOAD_MAX_BYTES => {
delta.push_str(more);
Some(more.len())
}
_ => None,
}
}
}
fn cap_text(text: &str, max: usize) -> String {
if text.len() <= max {
return text.to_string();
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n… [{} more bytes not shown]",
&text[..end],
text.len() - end
)
}
fn cap_input(input: &Value) -> Value {
let raw = input.to_string();
if raw.len() <= TOOL_PAYLOAD_MAX_BYTES {
input.clone()
} else {
Value::String(cap_text(&raw, TOOL_PAYLOAD_MAX_BYTES))
}
}
#[derive(Debug)]
pub struct WatchJournal {
entries: VecDeque<(u64, u64, WatchEvent)>,
epoch: Instant,
next_seq: u64,
sealed: u64,
bytes: usize,
}
impl Default for WatchJournal {
fn default() -> Self {
Self {
entries: VecDeque::new(),
epoch: Instant::now(),
next_seq: 0,
sealed: 0,
bytes: 0,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct WatchPage {
pub handle_id: String,
pub subagent_id: u64,
pub agent_name: String,
#[serde(default)]
pub model: String,
pub status: String,
pub steerable: bool,
pub next_seq: u64,
pub truncated: bool,
pub more: bool,
pub events: Vec<WatchEvent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub at_ms: Vec<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub now_ms: Option<u64>,
}
impl WatchPage {
pub fn without_tool_queue(mut self) -> Self {
let queue = |ev: &WatchEvent| {
matches!(ev, WatchEvent::ToolQueued { .. } | WatchEvent::ToolStarted { .. })
};
if !self.events.iter().any(queue) {
return self;
}
let timed = self.at_ms.len() == self.events.len();
let at_ms = std::mem::take(&mut self.at_ms);
let events = std::mem::take(&mut self.events);
for (i, ev) in events.into_iter().enumerate() {
if queue(&ev) {
continue;
}
if timed {
self.at_ms.push(at_ms[i]);
}
self.events.push(ev);
}
self
}
}
impl WatchJournal {
pub fn record(&mut self, event: &StreamEvent) {
if let Some(ev) = WatchEvent::from_stream(event) {
self.push(ev);
}
}
pub fn push(&mut self, ev: WatchEvent) {
let at_ms = self.clock_ms();
self.push_at(ev, at_ms);
}
fn clock_ms(&self) -> u64 {
u64::try_from(self.epoch.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn push_at(&mut self, ev: WatchEvent, at_ms: u64) {
if let Some((seq, _, last)) = self.entries.back_mut() {
if *seq >= self.sealed {
if let Some(added) = last.absorb(&ev) {
self.bytes += added;
self.trim();
return;
}
}
}
self.bytes += ev.size();
self.entries.push_back((self.next_seq, at_ms, ev));
self.next_seq += 1;
self.trim();
}
fn trim(&mut self) {
while self.bytes > JOURNAL_MAX_BYTES && self.entries.len() > 1 {
if let Some((_, _, ev)) = self.entries.pop_front() {
self.bytes = self.bytes.saturating_sub(ev.size());
}
}
}
pub fn read(&mut self, since: u64) -> WatchPage {
self.read_at(since, self.clock_ms())
}
fn read_at(&mut self, since: u64, now_ms: u64) -> WatchPage {
let first = self.entries.front().map_or(self.next_seq, |(s, _, _)| *s);
let truncated = since < first;
let mut page = WatchPage {
truncated,
next_seq: since.clamp(first, self.next_seq),
now_ms: Some(now_ms),
..WatchPage::default()
};
let mut budget = 0usize;
for (seq, at_ms, ev) in self.entries.iter().filter(|(s, _, _)| *s >= since) {
let size = ev.size();
if !page.events.is_empty() && budget + size > PAGE_MAX_BYTES {
page.more = true;
break;
}
budget += size;
page.events.push(ev.clone());
page.at_ms.push(*at_ms);
page.next_seq = seq + 1;
}
self.sealed = self.sealed.max(page.next_seq);
page
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn bytes(&self) -> usize {
self.bytes
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> WatchEvent {
WatchEvent::Text { text: s.into() }
}
#[test]
fn entries_replay_as_their_stream_events() {
let all = vec![
WatchEvent::ResponseStart,
WatchEvent::ResponseReset,
WatchEvent::Thinking { text: "hmm".into() },
text("hi"),
WatchEvent::ToolUseStart {
tool_id: "t1".into(),
tool_name: "bash".into(),
},
WatchEvent::ToolUse {
tool_id: "t1".into(),
tool_name: "bash".into(),
input: serde_json::json!({ "command": "ls" }),
},
WatchEvent::ToolResultDelta {
tool_id: "t1".into(),
delta: "a".into(),
},
WatchEvent::ToolResult {
tool_id: "t1".into(),
result: "a\n".into(),
},
WatchEvent::Steered {
text: "focus".into(),
},
WatchEvent::Done,
];
for ev in all {
let stream = ev.to_stream().expect("replayable");
assert_eq!(WatchEvent::from_stream(&stream), Some(ev));
}
assert!(WatchEvent::Failed {
category: "network".into()
}
.to_stream()
.is_none());
}
#[test]
fn deltas_coalesce_until_a_reader_sees_them() {
let mut j = WatchJournal::default();
j.push(text("hel"));
j.push(text("lo"));
assert_eq!(j.len(), 1, "unseen deltas merge");
let p1 = j.read(0);
assert_eq!(p1.events, vec![text("hello")]);
assert_eq!(p1.next_seq, 1);
j.push(text(" world"));
let p2 = j.read(p1.next_seq);
assert_eq!(p2.events, vec![text(" world")]);
assert!(!p2.truncated);
let p3 = j.read(p2.next_seq);
assert!(p3.events.is_empty());
assert_eq!(p3.next_seq, p2.next_seq);
}
#[test]
fn different_kinds_and_tools_do_not_merge() {
let mut j = WatchJournal::default();
j.push(WatchEvent::Thinking { text: "a".into() });
j.push(text("b"));
j.push(WatchEvent::ToolResultDelta {
tool_id: "t1".into(),
delta: "x".into(),
});
j.push(WatchEvent::ToolResultDelta {
tool_id: "t2".into(),
delta: "y".into(),
});
j.push(WatchEvent::ToolResultDelta {
tool_id: "t2".into(),
delta: "z".into(),
});
assert_eq!(j.len(), 4);
let page = j.read(0);
assert_eq!(
page.events.last(),
Some(&WatchEvent::ToolResultDelta {
tool_id: "t2".into(),
delta: "yz".into()
})
);
}
#[test]
fn the_cap_drops_oldest_and_late_readers_see_truncated() {
let mut j = WatchJournal::default();
let chunk = "x".repeat(256 * 1024);
for i in 0..12 {
j.push(WatchEvent::ToolResult {
tool_id: format!("t{i}"),
result: chunk.clone(),
});
}
assert!(j.bytes <= JOURNAL_MAX_BYTES, "bounded: {}", j.bytes);
let page = j.read(0);
assert!(page.truncated, "seq 0 was dropped");
assert!(page.events.len() < 12);
}
#[test]
fn pages_respect_the_byte_budget_and_resume() {
let mut j = WatchJournal::default();
let chunk = "y".repeat(400 * 1024);
for i in 0..4 {
j.push(WatchEvent::ToolResult {
tool_id: format!("t{i}"),
result: chunk.clone(),
});
}
let p1 = j.read(0);
assert!(
p1.more && p1.events.len() == 2,
"1 MiB budget: {}",
p1.events.len()
);
let p2 = j.read(p1.next_seq);
assert_eq!(p2.events.len(), 2);
assert!(!p2.more);
}
#[test]
fn stream_events_map_and_errors_keep_only_the_category() {
let err = agent_core::TurnError::provider("secret upstream body", "auth_error", "turn-9");
let ev = WatchEvent::from_stream(&StreamEvent::Session(SessionEvent::Error(err))).unwrap();
match ev {
WatchEvent::Failed { category } => assert!(!category.contains("secret")),
other => panic!("unexpected {other:?}"),
}
assert!(
WatchEvent::from_stream(&StreamEvent::Session(SessionEvent::Notice(
"retry: upstream said …".into()
)))
.is_none()
);
let big = serde_json::json!({ "content": "z".repeat(TOOL_PAYLOAD_MAX_BYTES * 2) });
let ev = WatchEvent::from_stream(&StreamEvent::Llm(LlmEvent::ToolUse {
tool_name: "write".into(),
tool_id: "t".into(),
input: big,
}))
.unwrap();
assert!(
ev.size() < TOOL_PAYLOAD_MAX_BYTES + 1024,
"tool input capped"
);
}
#[test]
fn queue_signals_are_journaled_and_replay() {
let mut j = WatchJournal::default();
for ev in [
StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "t2".into() }),
StreamEvent::Llm(LlmEvent::ToolStarted { tool_id: "t2".into() }),
] {
j.record(&ev);
}
let page = j.read(0);
assert_eq!(
page.events,
vec![
WatchEvent::ToolQueued { tool_id: "t2".into() },
WatchEvent::ToolStarted { tool_id: "t2".into() },
]
);
assert!(matches!(
page.events[0].to_stream(),
Some(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id })) if tool_id == "t2"
));
assert!(matches!(
page.events[1].to_stream(),
Some(StreamEvent::Llm(LlmEvent::ToolStarted { tool_id })) if tool_id == "t2"
));
}
#[test]
fn a_page_for_an_old_client_has_no_queue_entries() {
let mut j = WatchJournal::default();
j.push(WatchEvent::ToolUseStart { tool_id: "t2".into(), tool_name: "bash".into() });
j.push(WatchEvent::ToolQueued { tool_id: "t2".into() });
j.push(WatchEvent::ToolStarted { tool_id: "t2".into() });
j.push(WatchEvent::ToolResult { tool_id: "t2".into(), result: "ok".into() });
let full = j.read(0);
let old = full.clone().without_tool_queue();
assert_eq!(old.next_seq, full.next_seq);
assert_eq!(old.events.len(), 2, "{:?}", old.events);
assert_eq!(old.at_ms.len(), 2);
let json = serde_json::to_string(&old).unwrap();
assert!(!json.contains("tool_queued") && !json.contains("tool_started"), "{json}");
}
#[test]
fn pages_round_trip_through_json() {
let mut j = WatchJournal::default();
j.push(WatchEvent::ToolUse {
tool_id: "t".into(),
tool_name: "bash".into(),
input: serde_json::json!({ "command": "ls" }),
});
j.push(WatchEvent::Failed {
category: "canceled".into(),
});
let page = j.read(0);
let back: WatchPage = serde_json::from_value(serde_json::to_value(&page).unwrap()).unwrap();
assert_eq!(back, page);
}
#[test]
fn page_without_a_model_deserializes() {
let old = r#"{"handle_id":"sa_1","subagent_id":1,"agent_name":"scout",
"status":"running","steerable":true,"next_seq":0,"truncated":false,
"more":false,"events":[]}"#;
let page: WatchPage = serde_json::from_str(old).expect("old page");
assert_eq!(page.model, "", "older peers send no model");
assert_eq!(page.agent_name, "scout");
assert!(page.at_ms.is_empty() && page.now_ms.is_none(), "nor timing");
}
#[test]
fn pages_say_when_each_entry_was_recorded() {
let mut j = WatchJournal::default();
j.push_at(
WatchEvent::ToolUseStart {
tool_id: "t".into(),
tool_name: "bash".into(),
},
1_000,
);
j.push_at(text("a"), 2_000);
j.push_at(text("b"), 3_000); j.push_at(
WatchEvent::ToolResult {
tool_id: "t".into(),
result: String::new(),
},
41_000,
);
let page = j.read_at(0, 41_250);
assert_eq!(page.events.len(), 3);
assert_eq!(page.at_ms, vec![1_000, 2_000, 41_000]);
assert_eq!(page.now_ms, Some(41_250));
let later = j.read_at(page.next_seq, 50_000);
assert!(later.events.is_empty() && later.at_ms.is_empty());
assert_eq!(
later.now_ms,
Some(50_000),
"an empty page still says the time"
);
}
#[test]
fn the_live_clock_stamps_entries_in_order() {
let mut j = WatchJournal::default();
j.push(text("a"));
let _ = j.read(0); j.push(WatchEvent::Done);
let page = j.read(0);
assert_eq!(page.at_ms.len(), page.events.len());
assert!(page.at_ms[0] <= page.at_ms[1]);
assert!(page.at_ms[1] <= page.now_ms.unwrap());
}
#[test]
fn timing_fields_are_optional_on_the_wire() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct OldPage {
handle_id: String,
next_seq: u64,
events: Vec<WatchEvent>,
}
let mut j = WatchJournal::default();
j.push(WatchEvent::Done);
let json = serde_json::to_value(j.read(0)).unwrap();
assert!(json.get("at_ms").is_some() && json.get("now_ms").is_some());
let old: OldPage = serde_json::from_value(json).expect("old client reads it");
assert_eq!(old.events, vec![WatchEvent::Done]);
let bare = serde_json::to_value(WatchPage::default()).unwrap();
assert!(bare.get("at_ms").is_none() && bare.get("now_ms").is_none());
}
}