hyperhive/hive-agent/src/term_msg.rs
atlas efbfeb3954 docs: say what these things are, not where they used to be
mara on #3923: "past tense is itself a smell for docs, we dont need any
'used to be somewhere else'" and "any gate we add should not have false
positives". Those are one instruction: the entire false-positive set of a
repo-path checker was prose naming files that are deliberately gone, so
removing the prose is what lets the gate be strict instead of carrying a
skip-list nobody maintains.

Three sites, and only two of them are past tense:

`agent-hierarchy.md` named two removed modules to explain that a code path
is gone. A reader cannot act on where it used to live; they can act on
where cancellation happens now, which is the half the sentence buried.

`term_msg.rs` carried a "Design history" paragraph whose live content was
three rules -- classification is server-side, `level` carries styling, four
named fields are deliberately absent -- wrapped in narration about a
redesign and a pointer to a deleted frontend file. Restated as the rules.

`gotchas.md` is NOT past tense: it correctly names a file in the website
repo. Qualifying it as `hyperhive/website:nix/options.nix` did not work --
the extractor still matches `nix/options.nix` as a substring, so the fix
has to be prose that contains no repo-relative path at all. Naming the
file alone does that and reads better.

Verified against this tree rather than the default checkout: the audit
script takes a directory argument and defaults elsewhere, so its first
(unchanged) DEAD:3 was a true statement about a different branch. On this
one: DEAD 3 -> 0, 141 distinct paths enumerated, and an injected dead path
is still caught.

Refs #3923
2026-09-07 15:06:12 +02:00

281 lines
9.5 KiB
Rust

//! 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.
//!
//! Six 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.
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<String>,
pub level: Level,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_format: Option<BodyFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coalesce_key: Option<String>,
}
impl TermMsg {
pub fn new(level: Level, summary: impl Into<String>) -> 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<String>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
self.body = Some(body.into());
self.body_format = format;
self
}
#[must_use]
pub fn coalesce(mut self, key: impl Into<String>) -> 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<String, String>,
}
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<TermMsg> {
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()
);
}
}