Skip to main content

agent_engine/runtime/
subagent_watch.rs

1//! Watch journal: what a running subagent has shown so far, for clients that
2//! watch it in its own thread view.
3//!
4//! A worker's real message history only reaches [`SubagentState`] when its
5//! turn ends, so a live view can't be built from it. Instead the worker's
6//! event loop (`tools/subagent/drive.rs`) records every presentation event
7//! here, and clients page through it with `SessionQuery::SubagentWatch`
8//! (`since` = the `next_seq` of their previous page). The first page is the
9//! whole run so far; later pages are just what is new.
10//!
11//! Bounded on purpose:
12//! - consecutive text / thinking / tool-output deltas coalesce into one entry
13//!   — but only into an entry no reader has seen yet (`sealed`), so a client
14//!   paging by `seq` never misses an append;
15//! - large tool inputs and results are capped per entry;
16//! - past [`JOURNAL_MAX_BYTES`] the oldest entries drop and readers asking
17//!   for them get `truncated: true`.
18//!
19//! Every entry is stamped with when it was recorded (ms on the journal's own
20//! monotonic clock), and each page says what that clock read when it was
21//! taken (`at_ms`, `now_ms`). A client only applies a page when it polls —
22//! only while the view is focused, and the first page is the whole run so
23//! far — so its own arrival times say nothing about when a worker's tool
24//! started or how long it ran. With the stamps it can place each event in
25//! its own clock and show the same running clock and `└─ ok … Ns` duration
26//! the main thread shows.
27//!
28//! Raw provider error text never enters the journal (same rule as
29//! [`SubagentState::safe_fail`]): errors carry only their category label,
30//! and retry notices are not recorded.
31//!
32//! [`SubagentState`]: super::subagent::SubagentState
33//! [`SubagentState::safe_fail`]: super::subagent::SubagentState::safe_fail
34
35use std::collections::VecDeque;
36use std::time::Instant;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
42
43/// Total retained bytes per worker before the oldest entries drop.
44pub const JOURNAL_MAX_BYTES: usize = 2 * 1024 * 1024;
45/// Most bytes of events one page returns; the client pages on from `next_seq`.
46pub const PAGE_MAX_BYTES: usize = 1024 * 1024;
47/// Cap on one tool input (serialized) or one tool result.
48pub const TOOL_PAYLOAD_MAX_BYTES: usize = 64 * 1024;
49
50/// One presentation event of a worker, in the shape the TUI's transcript
51/// applies (`stream_handler::apply_transcript_event`).
52#[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    /// The call waits behind another in its scheduler lane
81    /// (`LlmEvent::ToolQueued`). Only in pages a client asked for with
82    /// `tool_queue: true`: this enum has no `#[serde(other)]`, so an older
83    /// client would fail the whole page on it.
84    ToolQueued {
85        tool_id: String,
86    },
87    /// A queued call began executing (`LlmEvent::ToolStarted`). Same gate.
88    ToolStarted {
89        tool_id: String,
90    },
91    /// A steering message the worker took in (from the user or its parent).
92    Steered {
93        text: String,
94    },
95    Done,
96    /// Terminal failure: the category label only, never the raw message.
97    Failed {
98        category: String,
99    },
100}
101
102impl WatchEvent {
103    /// The journal entry for a worker stream event, if it is one the
104    /// watch view shows.
105    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            // Input-JSON deltas are superseded by `ToolUse`; notices can echo
148            // provider error bodies; the rest is bookkeeping.
149            _ => return None,
150        })
151    }
152
153    /// The stream event a client replays to render this entry through the
154    /// same transcript path as the main conversation. `Failed` has none:
155    /// clients render its category themselves.
156    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    /// Append `next` onto `self` when both are deltas of the same stream.
221    /// Returns the bytes added, or `None` when they don't merge.
222    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/// Bounded, append-only record of a worker's presentation events.
269#[derive(Debug)]
270pub struct WatchJournal {
271    /// `(seq, at_ms, event)`, oldest first; `seq` is contiguous; `at_ms` is
272    /// when the entry was recorded, on the [`Self::epoch`] clock.
273    entries: VecDeque<(u64, u64, WatchEvent)>,
274    /// The journal's clock origin (`at_ms` / `now_ms` count from here).
275    epoch: Instant,
276    /// `seq` the next new entry gets.
277    next_seq: u64,
278    /// Entries with `seq < sealed` were returned to a reader: never mutated.
279    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/// One page of a worker's journal (`SessionQuery::SubagentWatch` reply).
296#[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    /// The model the worker runs on. Empty from older peers.
302    #[serde(default)]
303    pub model: String,
304    /// `running`, `completed`, `cancelled`, `timed_out` or `failed`.
305    pub status: String,
306    /// The worker accepts steering (it is running and has a steer channel).
307    pub steerable: bool,
308    /// Ask for `since = next_seq` next time.
309    pub next_seq: u64,
310    /// Entries before the requested `since` were dropped (journal cap).
311    pub truncated: bool,
312    /// More entries are ready right now (this page hit its byte budget).
313    pub more: bool,
314    pub events: Vec<WatchEvent>,
315    /// When each of `events` was recorded (same length), in ms on the
316    /// journal's clock. A coalesced delta entry carries its first part's
317    /// time. Empty from older peers: clients then time events by arrival.
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub at_ms: Vec<u64>,
320    /// The journal's clock when this page was read, so a client can turn
321    /// `at_ms` into "this long ago". Absent from older peers.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub now_ms: Option<u64>,
324}
325
326impl WatchPage {
327    /// This page for a client that did not ask for queue entries
328    /// (`SessionQuery::SubagentWatch { tool_queue: false }`, i.e. every
329    /// client that predates them): `WatchEvent` has no `#[serde(other)]`,
330    /// so such a client would fail to decode the whole page.
331    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    /// Record a worker stream event (no-op for events the view ignores).
356    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    /// Milliseconds since the journal was created.
368    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    /// Events from `since` on (at most [`PAGE_MAX_BYTES`], always at least
397    /// one). Seals everything returned so later appends start new entries.
398    /// Fills the journal fields of a [`WatchPage`]; the caller adds identity.
399    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            // A cursor past the end (stale client) restarts at the end.
409            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    /// Retained payload bytes (the [`JOURNAL_MAX_BYTES`] measure).
434    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    /// Every journal entry replays as the stream event it was recorded
452    /// from, so a client renders it through the main transcript path.
453    #[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        // The entry the reader saw is sealed: new text starts a new entry,
505        // so paging from next_seq gets exactly the new part.
506        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        // Nothing new: empty page, same cursor.
512        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    /// The scheduler's queue signals are journaled (a worker view shows a
610    /// waiting card as queued) and replay as the same stream events.
611    #[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    /// A client that did not opt in gets the page without them (and their
639    /// stamps), so it decodes exactly what it did before; cursors unchanged.
640    #[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    /// A page from a peer that predates `model` still deserializes.
673    #[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    /// #287: each event carries when it was recorded and the page says what
685    /// the journal's clock read, so a client can tell how long a worker's
686    /// tool has run — it only sees the events when it polls.
687    #[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); // coalesces: keeps the first part's time
699        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); // seal, so the next push is its own entry
725        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    /// A page from this build still reads in a client that predates the
733    /// timing fields (they are extra keys, which serde ignores), and pages
734    /// without them omit the keys entirely.
735    #[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}