From fef84dd44826dfe9ff44fd50e45a3db06870f7eb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 18:49:23 -0700 Subject: [PATCH] [eric] chat: stop filing the answer as tool chrome, only short unstructured narration folds into the group --- .../src/app/pages/AgentChat/AgentChat.tsx | 5 +- .../AgentChat/parsing/isNarration.test.ts | 70 +++++++++++++++++++ .../pages/AgentChat/parsing/isNarration.ts | 26 +++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts create mode 100644 frontend/src/app/pages/AgentChat/parsing/isNarration.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index c2c57849..ad380fb4 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -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 = { 'opus-4-8': 1_000_000, @@ -1116,7 +1117,9 @@ const AgentChat: React.FC = ({ 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] }); } diff --git a/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts b/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts new file mode 100644 index 00000000..dcb807d4 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/parsing/isNarration.test.ts @@ -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); +}); diff --git a/frontend/src/app/pages/AgentChat/parsing/isNarration.ts b/frontend/src/app/pages/AgentChat/parsing/isNarration.ts new file mode 100644 index 00000000..3ff18921 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/parsing/isNarration.ts @@ -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); +}