feat(#2196): move send/ask/answer body to backend _body field
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
This commit is contained in:
parent
6559f3e7b5
commit
b25c44f7cb
2 changed files with 102 additions and 53 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 `<pre>` 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue