[eric] chat: stop filing the answer as tool chrome, only short unstructured narration folds into the group

This commit is contained in:
ciregenz
2026-07-31 18:49:23 -07:00
parent 0f6e110a86
commit fef84dd448
3 changed files with 100 additions and 1 deletions
@@ -78,6 +78,7 @@ import { setCardSidecar, commitDraft, updateWorkflowCard, controlWorkflowRun } f
import { shallowEqual } from 'react-redux';
import { useClaudeTokens, useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta';
import { isNarration } from './parsing/isNarration';
const CONTEXT_WINDOWS: Record<string, number> = {
'opus-4-8': 1_000_000,
@@ -1116,7 +1117,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
let j = i;
while (j < activeBranchMessages.length && activeBranchMessages[j].role === 'assistant') j++;
const next = activeBranchMessages[j];
if (next && (next.role === 'tool_call' || next.role === 'tool_result')) {
const absorbable = activeBranchMessages.slice(i, j).every((a) => isNarration(a.content));
// A long or structured message is the answer, not narration. Absorbing it hides the whole deliverable in a grey tool row and strips its markdown, which is worse than showing one redundant line.
if (next && absorbable && (next.role === 'tool_call' || next.role === 'tool_result')) {
for (let k = i; k < j; k++) {
if (!activeBranchMessages[k].hidden) noteMarks.push({ afterCall: callsSoFar, msg: activeBranchMessages[k] });
}
@@ -0,0 +1,70 @@
// The answer must never be filed as tool chrome.
//
// Live case this exists for: an agent wrote a 1,060-word decision memo, then saved a memory. That
// one trailing tool call reclassified the memo as narration, so the card showed the user's question,
// a grey "3 tool calls" row, and nothing else. 0 of 1,060 words in the DOM. The run was marked done
// and charged $0.22. Expanding the row showed the memo as raw markdown in 12px grey, labelled with
// the file the agent had read.
//
// Run: cd frontend && npx tsx --test src/app/pages/AgentChat/parsing/isNarration.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isNarration } from './isNarration.js';
test('short passing remarks are narration', () => {
for (const s of [
'Let me check that.',
'Found the session log. Setting up the monitor now.',
'Good question, let me look at what the workflow actually does rather than answer from memory.',
'One moment.',
]) {
assert.equal(isNarration(s), true, `should absorb: ${s}`);
}
});
test('a long answer is never narration', () => {
const memo = 'The recommendation is to use a managed platform. '.repeat(12);
assert.ok(memo.length > 240);
assert.equal(isNarration(memo), false);
});
test('structure means it is a deliverable, however short', () => {
for (const s of [
'# Decision Memo',
'## Bottom line\nManaged, not self-hosted.',
'- Docusaurus\n- Astro Starlight',
'1. First\n2. Second',
'> A quoted conclusion.',
'```\nkubectl apply -f .\n```',
'| Platform | Cost |\n| --- | --- |',
]) {
assert.equal(isNarration(s), false, `should stay visible: ${s.slice(0, 24)}`);
}
});
test('the real memo opening is not narration', () => {
const opening = '# Decision Memo: Kubernetes Hosting for a 12-Person Company\n\n'
+ '**Recommendation: use a managed platform. Do not self-host.**';
assert.equal(isNarration(opening), false);
});
test('a bare sentence mentioning a dash is still narration', () => {
// The structure test anchors to line starts, so prose containing a hyphen must not trip it.
assert.equal(isNarration('Checking the well-known ports first.'), true);
});
test('empty and non-string content absorb rather than render an empty bubble', () => {
assert.equal(isNarration(''), true);
assert.equal(isNarration(' '), true);
assert.equal(isNarration(null), true);
assert.equal(isNarration(undefined), true);
assert.equal(isNarration(42), true);
});
test('the asymmetry is respected at the boundary', () => {
// Bias is deliberate: a redundant line costs noise, a hidden answer costs the whole run.
assert.equal(isNarration('x'.repeat(240)), true);
assert.equal(isNarration('x'.repeat(241)), false);
});
@@ -0,0 +1,26 @@
// Is this assistant message throwaway narration, or is it the answer?
//
// The transcript folds narration that sits between two tool runs into the grey tool group, which is
// right for "Let me check that" and catastrophic for a 1,060-word memo: the deliverable vanishes
// from the card entirely, and expanding the group renders it as raw markdown source in tertiary
// grey inside a row labelled with whatever file the agent happened to read. Observed live on a real
// run, with the answer streaming in and then disappearing the instant the run completed.
//
// The two mistakes are not symmetric. Showing one redundant sentence inline costs a line of noise.
// Hiding the answer costs the user the entire run, silently, while it is marked done and billed.
// So this is deliberately biased toward "that is the answer, show it".
/** Past this, it is not a passing remark. Real narration in practice is a short sentence or two. */
const NARRATION_MAX_CHARS = 240;
// Structure means someone is presenting, not muttering: a heading, a list, a table, a code fence, a
// quote, or a horizontal rule. Any of these makes the message a deliverable regardless of length.
const STRUCTURE = /(^|\n)\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\||```|---)/;
export function isNarration(content: unknown): boolean {
if (typeof content !== 'string') return true;
const text = content.trim();
if (text.length === 0) return true;
if (text.length > NARRATION_MAX_CHARS) return false;
return !STRUCTURE.test(text);
}