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:
iris 2026-07-20 18:20:45 +02:00 committed by mara
commit b25c44f7cb
2 changed files with 102 additions and 53 deletions

View file

@ -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;