From b25c44f7cb5d9253b741e9a1d79f45dabe7b503a Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 20 Jul 2026 18:20:45 +0200 Subject: [PATCH] feat(#2196): move send/ask/answer body to backend _body field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three message-bearing hyperhive tools (send, ask, answer) previously had named JS branches in renderRichToolUse that each: - computed a summary string (recipient / line count) - rendered the body text via detailsOpenMd (marked + DOMPurify) This commit moves the body text and summary string to the backend, reducing the JS dispatch table to a single generic markdown path. Backend (stream_enrich.rs): - rich_tool_body: new 'markdown' body_type for send/ask/answer — stamps _body with input.body / input.question / input.answer - fmt_hyperhive_message_tool: new helper formats _summary as 'send* → to' / 'ask* → to' / 'answer* #id' with ' · NL' when the body spans multiple lines; extracted out of fmt_hyperhive_tool to keep it under the too_many_lines limit - doc: updated rich_tool_body docstring to list the new 'markdown' type Frontend (app.js): - Remove the three named branches (send/ask/answer) from renderRichToolUse - Extend the generic _body path: 'markdown' type calls detailsOpenMd - The ask-form slot logic (operator inline-answer binding) is preserved within the markdown branch, now reading the question from c._body instead of input.question — DOM mounting remains client-side --- frontend/packages/agent/src/app.js | 75 ++++++++++------------------ hive-agent/src/stream_enrich.rs | 80 ++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 53 deletions(-) diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 3b3362c3..da1812d7 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1486,60 +1486,39 @@ window.marked = marked; // Message-bearing tools render default-open with a markdown body so // the operator sees the content without an extra click. send / ask // address a target; answer attaches to an existing question id. - if (name === 'mcp__hyperhive__send') { - const to = input.to || '?'; - const body = String(input.body || ''); - const lines = body.split('\n').length; - return detailsOpenMd(api, 'tool-use', - 'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''), - body, icon); - } - if (name === 'mcp__hyperhive__ask') { - const to = input.to || 'operator'; - const q = String(input.question || ''); - const lines = q.split('\n').length; - const d = detailsOpenMd(api, 'tool-use', - 'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''), - q, icon); - // When the ask targets the operator, mount an inline answer - // slot in the live terminal — see docs/web-ui.md::Per-agent - // page (Ask → operator inline-answer binding) for the slot - // registry + reconciler + [resolved] semantics. - if (to === 'operator') { - const slot = el('div', { class: 'ask-answer-inline-slot' }); - // Stash the question text on the slot so the reconciler - // can match against `lastLooseEnds` entries without - // walking the row's text content. Options/multi can be - // surfaced later when the harness emits them on the - // tool_result; phase A is pure text-match. - slot._askQuestion = q; - d.appendChild(slot); - pendingAskBinds.push(slot); - // Mid-turn refresh — the standard `turn_end` refresh - // won't fire until the agent's turn finishes; we want - // the form to show up as soon as the ask lands. Cheap - // best-effort (silent on failure). - if (!api.fromHistory) refreshLooseEnds(); - else reconcileAskBinds(); - } - return d; - } - if (name === 'mcp__hyperhive__answer') { - const id = input.id != null ? String(input.id) : '?'; - const a = String(input.answer || ''); - const lines = a.split('\n').length; - return detailsOpenMd(api, 'tool-use', - 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), - a, icon); - } // Generic backend-computed body: the backend stamps `_body` + `_body_type` - // for tools whose expandable content is pre-computable (Edit diff, - // mcp__bash__run command). Dispatch on type — no tool-specific JS needed. + // for tools whose expandable content is pre-computable. Dispatch on type — + // no tool-specific JS needed for most cases. if (c._body != null) { const summary = c._summary || name || '?'; if (c._body_type === 'diff') { return api.detailsDiff('tool-use', summary, c._body, icon); } + if (c._body_type === 'markdown') { + const d = detailsOpenMd(api, 'tool-use', summary, c._body, icon); + // For ask → operator: mount an inline answer slot in the live terminal + // — see docs/web-ui.md::Per-agent page (Ask → operator inline-answer + // binding) for the slot registry + reconciler + [resolved] semantics. + // Body text and recipient are both available from backend fields. + if (name === 'mcp__hyperhive__ask') { + const to = (input.to || 'operator'); + if (to === 'operator') { + const slot = el('div', { class: 'ask-answer-inline-slot' }); + // Stash the question text on the slot so the reconciler + // can match against `lastLooseEnds` entries without + // walking the row's text content. + slot._askQuestion = c._body; + d.appendChild(slot); + pendingAskBinds.push(slot); + // Mid-turn refresh — the standard `turn_end` refresh + // won't fire until the agent's turn finishes; we want + // the form to show up as soon as the ask lands. + if (!api.fromHistory) refreshLooseEnds(); + else reconcileAskBinds(); + } + } + return d; + } return api.details('tool-use', summary, c._body, icon); } return null; diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 942b98a5..9241b49f 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -270,9 +270,10 @@ fn is_rich_tool(name: &str) -> bool { /// which renderer to use: /// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines) /// - `"plain"` → `api.details` (plain `
` block)
+/// - `"markdown"` → `api.detailsOpenMd` (markdown rendered via marked + `DOMPurify`,
+///   default-open; used for message-bearing tools: send, ask, answer)
 ///
-/// Returns `None` for tools whose body is inherently client-side (markdown
-/// rendering, DOM forms) or that have no body at all.
+/// Returns `None` for tools that have no body at all.
 ///
 /// **`Write` is intentionally absent**: its `content` field can be megabytes
 /// and is one-sided (no `old_string` to diff against), making it too large to
@@ -317,6 +318,33 @@ fn rich_tool_body(name: &str, input: &Value) -> Option<(String, &'static str)> {
                 Some((format!("$ {cmd}"), "plain"))
             }
         }
+        // Message-bearing tools: body is markdown text rendered by the client.
+        // The ask form (operator reply slot) is still mounted client-side;
+        // only the raw body text moves to the backend here.
+        "mcp__hyperhive__send" => {
+            let body = input.get("body").and_then(Value::as_str).unwrap_or("");
+            if body.is_empty() {
+                None
+            } else {
+                Some((body.to_owned(), "markdown"))
+            }
+        }
+        "mcp__hyperhive__ask" => {
+            let q = input.get("question").and_then(Value::as_str).unwrap_or("");
+            if q.is_empty() {
+                None
+            } else {
+                Some((q.to_owned(), "markdown"))
+            }
+        }
+        "mcp__hyperhive__answer" => {
+            let a = input.get("answer").and_then(Value::as_str).unwrap_or("");
+            if a.is_empty() {
+                None
+            } else {
+                Some((a.to_owned(), "markdown"))
+            }
+        }
         _ => None,
     }
 }
@@ -443,12 +471,54 @@ fn fmt_builtin_tool(name: &str, short: &str, input: &Value) -> String {
     }
 }
 
+/// Summary for message-bearing hyperhive tools (send / ask / answer).
+///
+/// Format: `"{short} → {recipient}"` or `"{short} #{id}"` with ` · NL`
+/// appended when the body spans multiple lines.
+fn fmt_hyperhive_message_tool(name: &str, short: &str, input: &Value) -> String {
+    match name {
+        "mcp__hyperhive__send" => {
+            let to = sv(input, "to");
+            let lines = sv(input, "body").lines().count();
+            if lines > 1 {
+                format!("{short} → {to} · {lines}L")
+            } else {
+                format!("{short} → {to}")
+            }
+        }
+        "mcp__hyperhive__ask" => {
+            let to = input
+                .get("to")
+                .and_then(Value::as_str)
+                .unwrap_or("operator");
+            let lines = sv(input, "question").lines().count();
+            if lines > 1 {
+                format!("{short} → {to} · {lines}L")
+            } else {
+                format!("{short} → {to}")
+            }
+        }
+        "mcp__hyperhive__answer" => {
+            let id = input
+                .get("id")
+                .and_then(Value::as_u64)
+                .map_or_else(|| "?".to_owned(), |n| n.to_string());
+            let lines = sv(input, "answer").lines().count();
+            if lines > 1 {
+                format!("{short} #{id} · {lines}L")
+            } else {
+                format!("{short} #{id}")
+            }
+        }
+        _ => fmt_args_generic(short, input),
+    }
+}
+
 /// `mcp__hyperhive__*` tools.
 fn fmt_hyperhive_tool(name: &str, short: &str, input: &Value) -> String {
     match name {
-        "mcp__hyperhive__send" => {
-            let body = trim_str(&sv(input, "body"), 80);
-            format!("{short} → {}: {}", sv(input, "to"), json_str(&body))
+        "mcp__hyperhive__send" | "mcp__hyperhive__ask" | "mcp__hyperhive__answer" => {
+            fmt_hyperhive_message_tool(name, short, input)
         }
         "mcp__hyperhive__recv" => {
             let mut parts = Vec::new();