//! Terminal-message wire shape: one classified terminal row, as the //! per-agent web UI's live/history endpoints serve it and as the swarm queue //! carries it from an agent and from its subagents. //! //! Seven fields, and classification belongs **here**, not in the client: the //! web UI renders what it is handed and owns no per-tool dispatch table, so //! a new tool needs no frontend change. `level` carries styling, so a row //! never names a CSS class. `kind`, `unread`, `from` and `expanded_default` //! are deliberately absent — adding one back is a design change, not an //! oversight. //! //! The seventh field is `ts`, and it is the row's own: whoever classifies an //! event stamps every row it produced with the time of that event, not the //! time it ran. The swarm queue publishes the bare row with no envelope //! around it, so a subscriber that reads a row has nowhere else to learn //! when the thing happened; carrying the source event's time means a row //! replayed out of sqlite months later still says when it happened rather //! than when it was read. use chrono::{DateTime, SecondsFormat}; use serde::Serialize; use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum Level { /// Low-signal / ambient chatter — thinking, progress ticks, harness /// housekeeping. The client's default rendering can dim/de-emphasize /// these without hiding them outright. Debug, /// Routine substantive content — turn boundaries, assistant text, tool /// calls/results, message bodies. Info, /// Heads-up, not necessarily broken — stderr lines, an unclassified /// event shape landing (the old `.sys` catch-all), API retries. Warn, /// Something actually failed — a turn ending non-ok, a tool result with /// `is_error: true`. Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum BodyFormat { Markdown, Diff, } /// One terminal row. `body_format: None` with `body: Some(_)` means plain /// text (the common case — no explicit tag on the wire for it, same logic /// as `body` itself being absent meaning "nothing to expand"). #[derive(Debug, Clone, Serialize)] pub struct TermMsg { /// When the classified event happened, ISO 8601 / RFC 3339 UTC. /// /// Empty on a freshly built row and filled with [`TermMsg::at`] by the /// caller that classified the event — a builder that had to be handed /// the time would repeat the same value across the several rows one /// `stream-json` line expands into. pub ts: String, #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, pub level: Level, pub summary: String, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body_format: Option, #[serde(skip_serializing_if = "Option::is_none")] pub coalesce_key: Option, } impl TermMsg { pub fn new(level: Level, summary: impl Into) -> Self { Self { ts: String::new(), icon: None, level, summary: summary.into(), body: None, body_format: None, coalesce_key: None, } } #[must_use] pub fn icon(mut self, icon: impl Into) -> Self { self.icon = Some(icon.into()); self } #[must_use] pub fn body(mut self, body: impl Into, format: Option) -> Self { self.body = Some(body.into()); self.body_format = format; self } #[must_use] pub fn coalesce(mut self, key: impl Into) -> Self { self.coalesce_key = Some(key.into()); self } /// Stamp the row with the time of the event it came from. #[must_use] pub fn at(mut self, ts: impl Into) -> Self { self.ts = ts.into(); self } } /// Render an event's unix-seconds stamp as ISO 8601 / RFC 3339 UTC. /// /// Seconds rather than milliseconds because that is the resolution the /// event bus and the sqlite `events.ts` column actually carry /// (`hive-agent`'s `events`) — a `.000` on every row would be precision the source /// does not have. UTC rather than local: the reader of a swarm-published /// row is not on the machine that wrote it, and an offset-carrying stamp /// would make two agents' rows sort by string differently than by time. /// /// A stamp outside the representable range renders as the epoch: a row /// whose only defect is an absurd clock is still worth reading. #[must_use] pub fn iso8601_utc(unix_seconds: i64) -> String { DateTime::from_timestamp(unix_seconds, 0) .unwrap_or_default() .to_rfc3339_opts(SecondsFormat::Secs, true) } /// Per-connection/per-request classification state. A live SSE stream keeps /// one of these alive for the connection's lifetime — `tool_use` id → name /// correlation, so a `tool_result` can tell it's answering a `recv` call and /// render its body as markdown instead of plain text. The history endpoint /// uses a fresh one per page: correlation only works within the page /// actually returned, not across the live/history boundary. Accepted /// degradation — the only user-visible effect is a `recv` result whose /// `tool_use` fell on the other side of a page/reconnect boundary rendering /// its body as plain text instead of markdown. #[derive(Default)] pub struct ClassifyCtx { tool_name_by_id: HashMap, } impl ClassifyCtx { pub fn record_tool_use(&mut self, id: &str, name: &str) { self.tool_name_by_id.insert(id.to_owned(), name.to_owned()); } #[must_use] pub fn tool_name(&self, id: &str) -> Option<&str> { self.tool_name_by_id.get(id).map(String::as_str) } } /// What a row that lost its body to the payload limit carries instead. pub const DROPPED_BODY: &str = "[body dropped: over the queue's payload limit]"; /// Bring `msg` under `limit` serialized bytes, or report that it cannot be. /// /// The body is the only field that carries arbitrary length — a diff, a whole /// tool result — so it is the only one worth spending: replacing it keeps the /// row's identity, level, summary and time, which is what makes the row /// readable at all, and a reader sees that something was there rather than /// seeing nothing. /// /// `None` means even the degraded row does not fit, so the caller reports it /// instead of publishing. That matters more than it looks: an oversize publish /// is not truncated by the server, it is refused and the connection is closed, /// which costs the row *and* every row racing behind it through the reconnect. #[must_use] pub fn fit(msg: TermMsg, limit: usize) -> Option { if serialized_len(&msg)? <= limit { return Some(msg); } // Rebuilt rather than mutated in place: `body_format` describes the body, // and a marker left tagged `Diff` renders as a broken diff downstream. let degraded = TermMsg { body: Some(DROPPED_BODY.to_owned()), body_format: None, ..msg }; (serialized_len(°raded)? <= limit).then_some(degraded) } /// Serialized size of a row, or `None` if it does not serialize at all. /// /// Measured by serializing rather than estimated from field lengths: the /// payload is what the server measures, and JSON escaping makes the two differ /// by an unbounded factor on exactly the rows that are already near the limit. fn serialized_len(msg: &TermMsg) -> Option { serde_json::to_vec(msg).ok().map(|v| v.len()) } #[cfg(test)] mod tests { use super::{BodyFormat, DROPPED_BODY, Level, TermMsg, fit}; /// A row's time, for the tests below that need one. const ROW_TS: &str = "2026-09-13T12:35:03Z"; #[test] fn a_row_that_already_fits_is_published_unchanged() { let msg = TermMsg::new(Level::Info, "turn ok") .icon("✅") .body("a short body", Some(BodyFormat::Markdown)); let fitted = fit(msg, 4096).expect("a small row fits"); assert_eq!(fitted.body.as_deref(), Some("a short body")); assert_eq!(fitted.body_format, Some(BodyFormat::Markdown)); } /// The case the degrade exists for: a body larger than the limit costs the /// body and nothing else, and what comes back is actually under the limit /// rather than merely smaller. #[test] fn an_oversize_body_is_replaced_and_the_result_fits() { let limit = 512; let msg = TermMsg::new(Level::Info, "Edit(src/main.rs)") .icon("🔧") .body("x".repeat(limit * 4), Some(BodyFormat::Diff)) .coalesce("tool-1") .at(ROW_TS); let fitted = fit(msg, limit).expect("dropping the body brings this under the limit"); assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY)); // The tag describes a body that is no longer there; left set, a reader // renders the marker as a diff. assert_eq!(fitted.body_format, None); // The fields that make the row readable survive. assert_eq!(fitted.summary, "Edit(src/main.rs)"); assert_eq!(fitted.icon.as_deref(), Some("🔧")); assert_eq!(fitted.coalesce_key.as_deref(), Some("tool-1")); // Including the time: a degraded row that lost it would be a row a // subscriber cannot place, and nothing downstream could tell. assert_eq!(fitted.ts, ROW_TS); assert!( serde_json::to_vec(&fitted).expect("serialises").len() <= limit, "the degraded row must be under the limit, not merely smaller" ); } /// A row whose summary alone exceeds the limit cannot be degraded into /// one, and publishing it anyway would cost the connection rather than the /// row. Reported by the caller, never sent. #[test] fn a_row_too_large_even_without_its_body_is_refused() { let limit = 256; let msg = TermMsg::new(Level::Warn, "s".repeat(limit * 4)).body("x".repeat(limit), None); assert!(fit(msg, limit).is_none()); } /// Control for the test above: the same oversize summary with a body that /// would fit still refuses, so the refusal is about total size and not /// about the body having been present. #[test] fn the_refusal_is_about_size_rather_than_the_body_being_present() { let limit = 256; let msg = TermMsg::new(Level::Warn, "s".repeat(limit * 4)); assert!(fit(msg, limit).is_none()); } /// JSON escaping is why the size is measured by serializing: a body of /// quotes serializes to twice its own length, so a row that fits by /// character count can still be refused on the wire. #[test] fn the_limit_is_measured_on_the_serialized_bytes() { let body = "\"".repeat(200); let msg = TermMsg::new(Level::Info, "quotes").body(body.clone(), None); let limit = body.len() + 64; // Well under the limit as characters, over it once escaped. assert!( serde_json::to_vec(&msg).expect("serialises").len() > limit, "this fixture must be oversize only after escaping" ); let fitted = fit(msg, limit).expect("dropping the body fits"); assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY)); } }