1use std::collections::VecDeque;
36use std::time::Instant;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
42
43pub const JOURNAL_MAX_BYTES: usize = 2 * 1024 * 1024;
45pub const PAGE_MAX_BYTES: usize = 1024 * 1024;
47pub const TOOL_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
49
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum WatchEvent {
55 ResponseStart,
56 ResponseReset,
57 Thinking {
58 text: String,
59 },
60 Text {
61 text: String,
62 },
63 ToolUseStart {
64 tool_id: String,
65 tool_name: String,
66 },
67 ToolUse {
68 tool_id: String,
69 tool_name: String,
70 input: Value,
71 },
72 ToolResultDelta {
73 tool_id: String,
74 delta: String,
75 },
76 ToolResult {
77 tool_id: String,
78 result: String,
79 },
80 ToolQueued {
85 tool_id: String,
86 },
87 ToolStarted {
89 tool_id: String,
90 },
91 Steered {
93 text: String,
94 },
95 Done,
96 Failed {
98 category: String,
99 },
100}
101
102impl WatchEvent {
103 pub fn from_stream(event: &StreamEvent) -> Option<Self> {
106 Some(match event {
107 StreamEvent::Llm(LlmEvent::ResponseStart) => Self::ResponseStart,
108 StreamEvent::Llm(LlmEvent::ResponseReset) => Self::ResponseReset,
109 StreamEvent::Llm(LlmEvent::Thinking(text)) => Self::Thinking { text: text.clone() },
110 StreamEvent::Llm(LlmEvent::Text(text)) => Self::Text { text: text.clone() },
111 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => Self::ToolUseStart {
112 tool_id: tool_id.clone(),
113 tool_name: tool_name.clone(),
114 },
115 StreamEvent::Llm(LlmEvent::ToolUse {
116 tool_name,
117 tool_id,
118 input,
119 }) => Self::ToolUse {
120 tool_id: tool_id.clone(),
121 tool_name: tool_name.clone(),
122 input: cap_input(input),
123 },
124 StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id, delta }) => {
125 Self::ToolResultDelta {
126 tool_id: tool_id.clone(),
127 delta: delta.clone(),
128 }
129 }
130 StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => Self::ToolResult {
131 tool_id: tool_id.clone(),
132 result: cap_text(result, TOOL_PAYLOAD_MAX_BYTES),
133 },
134 StreamEvent::Llm(LlmEvent::ToolQueued { tool_id }) => Self::ToolQueued {
135 tool_id: tool_id.clone(),
136 },
137 StreamEvent::Llm(LlmEvent::ToolStarted { tool_id }) => Self::ToolStarted {
138 tool_id: tool_id.clone(),
139 },
140 StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => Self::Steered {
141 text: message.clone(),
142 },
143 StreamEvent::Session(SessionEvent::Done) => Self::Done,
144 StreamEvent::Session(SessionEvent::Error(err)) => Self::Failed {
145 category: err.category_label(),
146 },
147 _ => return None,
150 })
151 }
152
153 pub fn to_stream(&self) -> Option<StreamEvent> {
157 Some(match self {
158 Self::ResponseStart => StreamEvent::Llm(LlmEvent::ResponseStart),
159 Self::ResponseReset => StreamEvent::Llm(LlmEvent::ResponseReset),
160 Self::Thinking { text } => StreamEvent::Llm(LlmEvent::Thinking(text.clone())),
161 Self::Text { text } => StreamEvent::Llm(LlmEvent::Text(text.clone())),
162 Self::ToolUseStart { tool_id, tool_name } => StreamEvent::Llm(LlmEvent::ToolUseStart {
163 tool_name: tool_name.clone(),
164 tool_id: tool_id.clone(),
165 }),
166 Self::ToolUse {
167 tool_id,
168 tool_name,
169 input,
170 } => StreamEvent::Llm(LlmEvent::ToolUse {
171 tool_name: tool_name.clone(),
172 tool_id: tool_id.clone(),
173 input: input.clone(),
174 }),
175 Self::ToolResultDelta { tool_id, delta } => {
176 StreamEvent::Llm(LlmEvent::ToolResultDelta {
177 tool_id: tool_id.clone(),
178 delta: delta.clone(),
179 })
180 }
181 Self::ToolResult { tool_id, result } => StreamEvent::Llm(LlmEvent::ToolResult {
182 tool_id: tool_id.clone(),
183 result: result.clone(),
184 }),
185 Self::ToolQueued { tool_id } => StreamEvent::Llm(LlmEvent::ToolQueued {
186 tool_id: tool_id.clone(),
187 }),
188 Self::ToolStarted { tool_id } => StreamEvent::Llm(LlmEvent::ToolStarted {
189 tool_id: tool_id.clone(),
190 }),
191 Self::Steered { text } => StreamEvent::Agent(AgentEvent::SteeringDelivered {
192 message: text.clone(),
193 }),
194 Self::Done => StreamEvent::Session(SessionEvent::Done),
195 Self::Failed { .. } => return None,
196 })
197 }
198
199 fn size(&self) -> usize {
200 const OVERHEAD: usize = 32;
201 OVERHEAD
202 + match self {
203 Self::ResponseStart | Self::ResponseReset | Self::Done => 0,
204 Self::Thinking { text } | Self::Text { text } | Self::Steered { text } => {
205 text.len()
206 }
207 Self::ToolUseStart { tool_id, tool_name } => tool_id.len() + tool_name.len(),
208 Self::ToolUse {
209 tool_id,
210 tool_name,
211 input,
212 } => tool_id.len() + tool_name.len() + input.to_string().len(),
213 Self::ToolResultDelta { tool_id, delta } => tool_id.len() + delta.len(),
214 Self::ToolResult { tool_id, result } => tool_id.len() + result.len(),
215 Self::ToolQueued { tool_id } | Self::ToolStarted { tool_id } => tool_id.len(),
216 Self::Failed { category } => category.len(),
217 }
218 }
219
220 fn absorb(&mut self, next: &WatchEvent) -> Option<usize> {
223 match (self, next) {
224 (Self::Text { text }, Self::Text { text: more })
225 | (Self::Thinking { text }, Self::Thinking { text: more }) => {
226 text.push_str(more);
227 Some(more.len())
228 }
229 (
230 Self::ToolResultDelta { tool_id, delta },
231 Self::ToolResultDelta {
232 tool_id: next_id,
233 delta: more,
234 },
235 ) if tool_id == next_id && delta.len() < TOOL_PAYLOAD_MAX_BYTES => {
236 delta.push_str(more);
237 Some(more.len())
238 }
239 _ => None,
240 }
241 }
242}
243
244fn cap_text(text: &str, max: usize) -> String {
245 if text.len() <= max {
246 return text.to_string();
247 }
248 let mut end = max;
249 while !text.is_char_boundary(end) {
250 end -= 1;
251 }
252 format!(
253 "{}\n… [{} more bytes not shown]",
254 &text[..end],
255 text.len() - end
256 )
257}
258
259fn cap_input(input: &Value) -> Value {
260 let raw = input.to_string();
261 if raw.len() <= TOOL_PAYLOAD_MAX_BYTES {
262 input.clone()
263 } else {
264 Value::String(cap_text(&raw, TOOL_PAYLOAD_MAX_BYTES))
265 }
266}
267
268#[derive(Debug)]
270pub struct WatchJournal {
271 entries: VecDeque<(u64, u64, WatchEvent)>,
274 epoch: Instant,
276 next_seq: u64,
278 sealed: u64,
280 bytes: usize,
281}
282
283impl Default for WatchJournal {
284 fn default() -> Self {
285 Self {
286 entries: VecDeque::new(),
287 epoch: Instant::now(),
288 next_seq: 0,
289 sealed: 0,
290 bytes: 0,
291 }
292 }
293}
294
295#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
297pub struct WatchPage {
298 pub handle_id: String,
299 pub subagent_id: u64,
300 pub agent_name: String,
301 #[serde(default)]
303 pub model: String,
304 pub status: String,
306 pub steerable: bool,
308 pub next_seq: u64,
310 pub truncated: bool,
312 pub more: bool,
314 pub events: Vec<WatchEvent>,
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub at_ms: Vec<u64>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub now_ms: Option<u64>,
324}
325
326impl WatchPage {
327 pub fn without_tool_queue(mut self) -> Self {
332 let queue = |ev: &WatchEvent| {
333 matches!(ev, WatchEvent::ToolQueued { .. } | WatchEvent::ToolStarted { .. })
334 };
335 if !self.events.iter().any(queue) {
336 return self;
337 }
338 let timed = self.at_ms.len() == self.events.len();
339 let at_ms = std::mem::take(&mut self.at_ms);
340 let events = std::mem::take(&mut self.events);
341 for (i, ev) in events.into_iter().enumerate() {
342 if queue(&ev) {
343 continue;
344 }
345 if timed {
346 self.at_ms.push(at_ms[i]);
347 }
348 self.events.push(ev);
349 }
350 self
351 }
352}
353
354impl WatchJournal {
355 pub fn record(&mut self, event: &StreamEvent) {
357 if let Some(ev) = WatchEvent::from_stream(event) {
358 self.push(ev);
359 }
360 }
361
362 pub fn push(&mut self, ev: WatchEvent) {
363 let at_ms = self.clock_ms();
364 self.push_at(ev, at_ms);
365 }
366
367 fn clock_ms(&self) -> u64 {
369 u64::try_from(self.epoch.elapsed().as_millis()).unwrap_or(u64::MAX)
370 }
371
372 fn push_at(&mut self, ev: WatchEvent, at_ms: u64) {
373 if let Some((seq, _, last)) = self.entries.back_mut() {
374 if *seq >= self.sealed {
375 if let Some(added) = last.absorb(&ev) {
376 self.bytes += added;
377 self.trim();
378 return;
379 }
380 }
381 }
382 self.bytes += ev.size();
383 self.entries.push_back((self.next_seq, at_ms, ev));
384 self.next_seq += 1;
385 self.trim();
386 }
387
388 fn trim(&mut self) {
389 while self.bytes > JOURNAL_MAX_BYTES && self.entries.len() > 1 {
390 if let Some((_, _, ev)) = self.entries.pop_front() {
391 self.bytes = self.bytes.saturating_sub(ev.size());
392 }
393 }
394 }
395
396 pub fn read(&mut self, since: u64) -> WatchPage {
400 self.read_at(since, self.clock_ms())
401 }
402
403 fn read_at(&mut self, since: u64, now_ms: u64) -> WatchPage {
404 let first = self.entries.front().map_or(self.next_seq, |(s, _, _)| *s);
405 let truncated = since < first;
406 let mut page = WatchPage {
407 truncated,
408 next_seq: since.clamp(first, self.next_seq),
410 now_ms: Some(now_ms),
411 ..WatchPage::default()
412 };
413 let mut budget = 0usize;
414 for (seq, at_ms, ev) in self.entries.iter().filter(|(s, _, _)| *s >= since) {
415 let size = ev.size();
416 if !page.events.is_empty() && budget + size > PAGE_MAX_BYTES {
417 page.more = true;
418 break;
419 }
420 budget += size;
421 page.events.push(ev.clone());
422 page.at_ms.push(*at_ms);
423 page.next_seq = seq + 1;
424 }
425 self.sealed = self.sealed.max(page.next_seq);
426 page
427 }
428
429 pub fn len(&self) -> usize {
430 self.entries.len()
431 }
432
433 pub fn bytes(&self) -> usize {
435 self.bytes
436 }
437
438 pub fn is_empty(&self) -> bool {
439 self.entries.is_empty()
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn text(s: &str) -> WatchEvent {
448 WatchEvent::Text { text: s.into() }
449 }
450
451 #[test]
454 fn entries_replay_as_their_stream_events() {
455 let all = vec![
456 WatchEvent::ResponseStart,
457 WatchEvent::ResponseReset,
458 WatchEvent::Thinking { text: "hmm".into() },
459 text("hi"),
460 WatchEvent::ToolUseStart {
461 tool_id: "t1".into(),
462 tool_name: "bash".into(),
463 },
464 WatchEvent::ToolUse {
465 tool_id: "t1".into(),
466 tool_name: "bash".into(),
467 input: serde_json::json!({ "command": "ls" }),
468 },
469 WatchEvent::ToolResultDelta {
470 tool_id: "t1".into(),
471 delta: "a".into(),
472 },
473 WatchEvent::ToolResult {
474 tool_id: "t1".into(),
475 result: "a\n".into(),
476 },
477 WatchEvent::Steered {
478 text: "focus".into(),
479 },
480 WatchEvent::Done,
481 ];
482 for ev in all {
483 let stream = ev.to_stream().expect("replayable");
484 assert_eq!(WatchEvent::from_stream(&stream), Some(ev));
485 }
486 assert!(WatchEvent::Failed {
487 category: "network".into()
488 }
489 .to_stream()
490 .is_none());
491 }
492
493 #[test]
494 fn deltas_coalesce_until_a_reader_sees_them() {
495 let mut j = WatchJournal::default();
496 j.push(text("hel"));
497 j.push(text("lo"));
498 assert_eq!(j.len(), 1, "unseen deltas merge");
499
500 let p1 = j.read(0);
501 assert_eq!(p1.events, vec![text("hello")]);
502 assert_eq!(p1.next_seq, 1);
503
504 j.push(text(" world"));
507 let p2 = j.read(p1.next_seq);
508 assert_eq!(p2.events, vec![text(" world")]);
509 assert!(!p2.truncated);
510
511 let p3 = j.read(p2.next_seq);
513 assert!(p3.events.is_empty());
514 assert_eq!(p3.next_seq, p2.next_seq);
515 }
516
517 #[test]
518 fn different_kinds_and_tools_do_not_merge() {
519 let mut j = WatchJournal::default();
520 j.push(WatchEvent::Thinking { text: "a".into() });
521 j.push(text("b"));
522 j.push(WatchEvent::ToolResultDelta {
523 tool_id: "t1".into(),
524 delta: "x".into(),
525 });
526 j.push(WatchEvent::ToolResultDelta {
527 tool_id: "t2".into(),
528 delta: "y".into(),
529 });
530 j.push(WatchEvent::ToolResultDelta {
531 tool_id: "t2".into(),
532 delta: "z".into(),
533 });
534 assert_eq!(j.len(), 4);
535 let page = j.read(0);
536 assert_eq!(
537 page.events.last(),
538 Some(&WatchEvent::ToolResultDelta {
539 tool_id: "t2".into(),
540 delta: "yz".into()
541 })
542 );
543 }
544
545 #[test]
546 fn the_cap_drops_oldest_and_late_readers_see_truncated() {
547 let mut j = WatchJournal::default();
548 let chunk = "x".repeat(256 * 1024);
549 for i in 0..12 {
550 j.push(WatchEvent::ToolResult {
551 tool_id: format!("t{i}"),
552 result: chunk.clone(),
553 });
554 }
555 assert!(j.bytes <= JOURNAL_MAX_BYTES, "bounded: {}", j.bytes);
556 let page = j.read(0);
557 assert!(page.truncated, "seq 0 was dropped");
558 assert!(page.events.len() < 12);
559 }
560
561 #[test]
562 fn pages_respect_the_byte_budget_and_resume() {
563 let mut j = WatchJournal::default();
564 let chunk = "y".repeat(400 * 1024);
565 for i in 0..4 {
566 j.push(WatchEvent::ToolResult {
567 tool_id: format!("t{i}"),
568 result: chunk.clone(),
569 });
570 }
571 let p1 = j.read(0);
572 assert!(
573 p1.more && p1.events.len() == 2,
574 "1 MiB budget: {}",
575 p1.events.len()
576 );
577 let p2 = j.read(p1.next_seq);
578 assert_eq!(p2.events.len(), 2);
579 assert!(!p2.more);
580 }
581
582 #[test]
583 fn stream_events_map_and_errors_keep_only_the_category() {
584 let err = agent_core::TurnError::provider("secret upstream body", "auth_error", "turn-9");
585 let ev = WatchEvent::from_stream(&StreamEvent::Session(SessionEvent::Error(err))).unwrap();
586 match ev {
587 WatchEvent::Failed { category } => assert!(!category.contains("secret")),
588 other => panic!("unexpected {other:?}"),
589 }
590 assert!(
591 WatchEvent::from_stream(&StreamEvent::Session(SessionEvent::Notice(
592 "retry: upstream said …".into()
593 )))
594 .is_none()
595 );
596 let big = serde_json::json!({ "content": "z".repeat(TOOL_PAYLOAD_MAX_BYTES * 2) });
597 let ev = WatchEvent::from_stream(&StreamEvent::Llm(LlmEvent::ToolUse {
598 tool_name: "write".into(),
599 tool_id: "t".into(),
600 input: big,
601 }))
602 .unwrap();
603 assert!(
604 ev.size() < TOOL_PAYLOAD_MAX_BYTES + 1024,
605 "tool input capped"
606 );
607 }
608
609 #[test]
612 fn queue_signals_are_journaled_and_replay() {
613 let mut j = WatchJournal::default();
614 for ev in [
615 StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "t2".into() }),
616 StreamEvent::Llm(LlmEvent::ToolStarted { tool_id: "t2".into() }),
617 ] {
618 j.record(&ev);
619 }
620 let page = j.read(0);
621 assert_eq!(
622 page.events,
623 vec![
624 WatchEvent::ToolQueued { tool_id: "t2".into() },
625 WatchEvent::ToolStarted { tool_id: "t2".into() },
626 ]
627 );
628 assert!(matches!(
629 page.events[0].to_stream(),
630 Some(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id })) if tool_id == "t2"
631 ));
632 assert!(matches!(
633 page.events[1].to_stream(),
634 Some(StreamEvent::Llm(LlmEvent::ToolStarted { tool_id })) if tool_id == "t2"
635 ));
636 }
637
638 #[test]
641 fn a_page_for_an_old_client_has_no_queue_entries() {
642 let mut j = WatchJournal::default();
643 j.push(WatchEvent::ToolUseStart { tool_id: "t2".into(), tool_name: "bash".into() });
644 j.push(WatchEvent::ToolQueued { tool_id: "t2".into() });
645 j.push(WatchEvent::ToolStarted { tool_id: "t2".into() });
646 j.push(WatchEvent::ToolResult { tool_id: "t2".into(), result: "ok".into() });
647 let full = j.read(0);
648 let old = full.clone().without_tool_queue();
649 assert_eq!(old.next_seq, full.next_seq);
650 assert_eq!(old.events.len(), 2, "{:?}", old.events);
651 assert_eq!(old.at_ms.len(), 2);
652 let json = serde_json::to_string(&old).unwrap();
653 assert!(!json.contains("tool_queued") && !json.contains("tool_started"), "{json}");
654 }
655
656 #[test]
657 fn pages_round_trip_through_json() {
658 let mut j = WatchJournal::default();
659 j.push(WatchEvent::ToolUse {
660 tool_id: "t".into(),
661 tool_name: "bash".into(),
662 input: serde_json::json!({ "command": "ls" }),
663 });
664 j.push(WatchEvent::Failed {
665 category: "canceled".into(),
666 });
667 let page = j.read(0);
668 let back: WatchPage = serde_json::from_value(serde_json::to_value(&page).unwrap()).unwrap();
669 assert_eq!(back, page);
670 }
671
672 #[test]
674 fn page_without_a_model_deserializes() {
675 let old = r#"{"handle_id":"sa_1","subagent_id":1,"agent_name":"scout",
676 "status":"running","steerable":true,"next_seq":0,"truncated":false,
677 "more":false,"events":[]}"#;
678 let page: WatchPage = serde_json::from_str(old).expect("old page");
679 assert_eq!(page.model, "", "older peers send no model");
680 assert_eq!(page.agent_name, "scout");
681 assert!(page.at_ms.is_empty() && page.now_ms.is_none(), "nor timing");
682 }
683
684 #[test]
688 fn pages_say_when_each_entry_was_recorded() {
689 let mut j = WatchJournal::default();
690 j.push_at(
691 WatchEvent::ToolUseStart {
692 tool_id: "t".into(),
693 tool_name: "bash".into(),
694 },
695 1_000,
696 );
697 j.push_at(text("a"), 2_000);
698 j.push_at(text("b"), 3_000); j.push_at(
700 WatchEvent::ToolResult {
701 tool_id: "t".into(),
702 result: String::new(),
703 },
704 41_000,
705 );
706 let page = j.read_at(0, 41_250);
707 assert_eq!(page.events.len(), 3);
708 assert_eq!(page.at_ms, vec![1_000, 2_000, 41_000]);
709 assert_eq!(page.now_ms, Some(41_250));
710
711 let later = j.read_at(page.next_seq, 50_000);
712 assert!(later.events.is_empty() && later.at_ms.is_empty());
713 assert_eq!(
714 later.now_ms,
715 Some(50_000),
716 "an empty page still says the time"
717 );
718 }
719
720 #[test]
721 fn the_live_clock_stamps_entries_in_order() {
722 let mut j = WatchJournal::default();
723 j.push(text("a"));
724 let _ = j.read(0); j.push(WatchEvent::Done);
726 let page = j.read(0);
727 assert_eq!(page.at_ms.len(), page.events.len());
728 assert!(page.at_ms[0] <= page.at_ms[1]);
729 assert!(page.at_ms[1] <= page.now_ms.unwrap());
730 }
731
732 #[test]
736 fn timing_fields_are_optional_on_the_wire() {
737 #[derive(Deserialize)]
738 #[allow(dead_code)]
739 struct OldPage {
740 handle_id: String,
741 next_seq: u64,
742 events: Vec<WatchEvent>,
743 }
744 let mut j = WatchJournal::default();
745 j.push(WatchEvent::Done);
746 let json = serde_json::to_value(j.read(0)).unwrap();
747 assert!(json.get("at_ms").is_some() && json.get("now_ms").is_some());
748 let old: OldPage = serde_json::from_value(json).expect("old client reads it");
749 assert_eq!(old.events, vec![WatchEvent::Done]);
750
751 let bare = serde_json::to_value(WatchPage::default()).unwrap();
752 assert!(bare.get("at_ms").is_none() && bare.get("now_ms").is_none());
753 }
754}