//! Terminal-message wire shape: what the per-agent web UI's live/history //! endpoints actually serve for the "terminal" event stream, as opposed to //! agent-state changes (`StatusChanged`/`ModelChanged`/`EffortChanged`/ //! `TokenUsageChanged`/`TurnStateChanged`), which have never rendered as //! terminal rows (the header/badges poll `/api/state`, not this stream) and //! produce zero [`TermMsg`]s here. //! //! One [`crate::events::LiveEvent`] maps to zero or more `TermMsg`s — most //! map to exactly one, but `LiveEvent::Stream` (one raw claude //! `stream-json` line) can expand to several: an `assistant` message with //! both a text block and a `tool_use` block produces two rows. //! //! Design history: mara's terminal-message redesign (six rounds of //! negotiation on the forge) collapsed what was an 11-field frontend-side //! row shape (`StreamRow`, `frontend/packages/agent/src/lib/streamRow.ts`) //! plus raw claude-JSON passthrough into this 6-field shape, with //! classification moved server-side so the client-side `classifyEvent.ts` — //! a large per-tool dispatch table — mostly goes away. `kind`, `unread`, //! `from`, and `expanded_default` were all considered and dropped along the //! way; `level` replaces free-text CSS-class styling. use serde::Serialize; use std::collections::HashMap; use crate::events::LiveEvent; #[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 { #[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 { 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 } } /// 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) } } /// Classify one [`LiveEvent`] into zero or more terminal rows. pub fn classify(ev: &LiveEvent, ctx: &mut ClassifyCtx) -> Vec { match ev { LiveEvent::TurnStart { from, body, .. } => { // `unread` (the third field) was dropped — mara: "i can see // the todo/inbox count in toolbar and dont need the N unread". let mut m = TermMsg::new(Level::Info, format!("TURN ← {from}")).icon("◆"); if !body.trim().is_empty() { m = m.body(body.clone(), None); } vec![m] } LiveEvent::TurnEnd { ok, note } => { let msg = if *ok { TermMsg::new(Level::Info, "turn ok").icon("✅") } else { let summary = note .as_deref() .filter(|n| !n.is_empty()) .map_or_else(|| "turn fail".to_owned(), |n| format!("turn fail — {n}")); TermMsg::new(Level::Error, summary).icon("❌") }; vec![msg] } LiveEvent::Note { text } => vec![classify_note(text)], LiveEvent::Stream(v) => crate::stream_enrich::classify_stream_value(v, ctx), // Agent-state transitions never render as terminal rows — the // header/badges read `/api/state`, not this stream (see module doc). LiveEvent::StatusChanged { .. } | LiveEvent::ModelChanged { .. } | LiveEvent::EffortChanged { .. } | LiveEvent::TokenUsageChanged { .. } | LiveEvent::TurnStateChanged { .. } => vec![], } } fn classify_note(text: &str) -> TermMsg { if let Some(rest) = text.strip_prefix("stderr:") { TermMsg::new(Level::Warn, format!("stderr:{rest}")) } else if let Some(rest) = text.strip_prefix("operator:") { TermMsg::new(Level::Info, format!("operator:{rest}")) } else { // Ambient harness chatter (session archived, plugin loaded, etc.) — // routine, not worth the same visual weight as a tool call or // assistant text. TermMsg::new(Level::Debug, text.to_owned()) } } #[cfg(test)] mod tests { use super::{ClassifyCtx, Level, classify}; use crate::events::LiveEvent; #[test] fn turn_start_carries_from_and_body_no_unread() { let ev = LiveEvent::TurnStart { from: "operator".into(), body: "sweep the backlog".into(), unread: 3, }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].summary, "TURN ← operator"); assert_eq!(msgs[0].body.as_deref(), Some("sweep the backlog")); assert_eq!(msgs[0].level, Level::Info); } #[test] fn turn_start_empty_body_has_no_body() { let ev = LiveEvent::TurnStart { from: "reminder".into(), body: String::new(), unread: 0, }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert!(msgs[0].body.is_none()); } #[test] fn turn_end_ok_is_info() { let ev = LiveEvent::TurnEnd { ok: true, note: None, }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs[0].level, Level::Info); assert_eq!(msgs[0].summary, "turn ok"); } #[test] fn turn_end_fail_is_error_with_note() { let ev = LiveEvent::TurnEnd { ok: false, note: Some("rate limited".into()), }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs[0].level, Level::Error); assert_eq!(msgs[0].summary, "turn fail — rate limited"); } #[test] fn note_stderr_is_warn() { let ev = LiveEvent::Note { text: "stderr: warning: deprecated flag".into(), }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs[0].level, Level::Warn); } #[test] fn note_operator_is_info() { let ev = LiveEvent::Note { text: "operator: /compact requested".into(), }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs[0].level, Level::Info); } #[test] fn note_plain_is_debug() { let ev = LiveEvent::Note { text: "created fresh session".into(), }; let mut ctx = ClassifyCtx::default(); let msgs = classify(&ev, &mut ctx); assert_eq!(msgs[0].level, Level::Debug); } #[test] fn agent_state_events_produce_no_rows() { let mut ctx = ClassifyCtx::default(); assert!( classify( &LiveEvent::StatusChanged { status: "online".into() }, &mut ctx ) .is_empty() ); assert!( classify( &LiveEvent::ModelChanged { model: "opus".into() }, &mut ctx ) .is_empty() ); } }