Skip to content

Commit 27da25b

Browse files
floodsungFlood Sungclaude
authored
feat(ui): compact 'Agent activity between turns' card — drop tool noise, show only final result (#269)
PR #268 hid the running tool list from the main card. The "Agent activity between turns" spontaneous card had the same problem one layer deeper — it accumulated every assistant message (including bare `tool_use` blocks that rendered as `🔧 Bash`/`🔧 Read` lines) into a numbered list, exactly the play-by-play we just hid from the main card. Apply the same UX bet here: - `extractSpontaneousSnippet`: tool_use-only assistant messages now return null. Text snippets (the agent's actual conclusion) still survive. Mixed messages still surface the text and ignore the adjacent tool_use, same as before. - `formatSpontaneousCardBody`: render only the LATEST snippet (the conclusion of the burst), not a numbered list of all snippets. When N>1 were coalesced, append a small `_(N events coalesced; showing latest)_` footer so the user knows there was more activity if they want to dig into pm2 logs or the web UI. Side effects: - A between-turn burst that produced ONLY tool calls (no agent text) no longer triggers a card at all — the buffer stays empty, the 30 s flush is a no-op. This is the correct outcome: nothing user-meaningful to report. - The continuation-turn render path (handleContinuationTurn) was already covered by PR #268 since it goes through the same card-builder.ts surface. No change needed there. Tests: - extractSpontaneousSnippet: assert tool_use-only returns null, text+tool_use still returns the text, multi-tool_use returns null. - formatSpontaneousCardBody: assert single-snippet renders without a numbered prefix; N>1 renders the latest snippet plus the coalesced-count footer, and the earlier snippets are NOT in the body. 290/290 vitest pass. Build + lint clean (2 pre-existing warnings). Co-authored-by: Flood Sung <floodsung@xvirobotics.ai> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d6291a8 commit 27da25b

2 files changed

Lines changed: 74 additions & 21 deletions

File tree

src/bridge/message-bridge.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,16 @@ export const SPONTANEOUS_CARD_HEADER =
7272
* Extract a one-line summary from an SDK stream message for the spontaneous
7373
* activity card. Returns null if the message has nothing user-readable.
7474
*
75-
* Intentionally only handles `assistant` messages — `result` messages would
76-
* always duplicate the last assistant text block (SDK's `result.result` is a
77-
* verbatim echo), so including them caused the same content to show up twice
78-
* in the card with two different prefixes. Don't add a `result` branch back
79-
* without first verifying the SDK no longer echoes.
75+
* Intentionally only handles `assistant` *text* blocks — same UX bet we made
76+
* for the main card in PR #268: the user only cares about the agent's
77+
* conclusion, not the per-tool play-by-play. `🔧 <ToolName>` lines used to
78+
* be included for tool_use blocks but that's the exact intermediate noise
79+
* we just hid from the live card; surfacing it on the spontaneous card
80+
* would just re-introduce the same complaint between turns.
81+
*
82+
* Result-type messages are also ignored — SDK's `result.result` is a
83+
* verbatim echo of the last assistant text block, so including them caused
84+
* the same content to show up twice in the card.
8085
*/
8186
export function extractSpontaneousSnippet(msg: unknown): string | null {
8287
const m = msg as { type?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } };
@@ -85,20 +90,29 @@ export function extractSpontaneousSnippet(msg: unknown): string | null {
8590
if (blk.type === 'text' && blk.text) {
8691
const trimmed = String(blk.text).trim();
8792
if (trimmed) return trimmed.slice(0, 400);
88-
} else if (blk.type === 'tool_use' && blk.name) {
89-
return `🔧 ${blk.name}`;
9093
}
94+
// tool_use blocks intentionally fall through — see docstring above.
9195
}
9296
return null;
9397
}
9498

95-
/** Build the markdown body of a spontaneous activity card from collected snippets. */
99+
/**
100+
* Build the markdown body of a spontaneous activity card from collected
101+
* snippets. Shows ONLY the latest snippet (the agent's conclusion of the
102+
* burst); if multiple snippets were coalesced, a small footer notes the
103+
* count so users know there was more activity if they want to dig into
104+
* logs. Mirrors the "show only the final result, hide the play-by-play"
105+
* pattern from PR #268's main-card tool indicator.
106+
*/
96107
export function formatSpontaneousCardBody(snippets: string[]): string {
97-
return [
98-
`_${SPONTANEOUS_CARD_HEADER}_`,
99-
'',
100-
...snippets.map((s, i) => `**${i + 1}.** ${s}`),
101-
].join('\n');
108+
const lines: string[] = [`_${SPONTANEOUS_CARD_HEADER}_`, ''];
109+
if (snippets.length === 0) return lines.join('\n');
110+
lines.push(snippets[snippets.length - 1]);
111+
if (snippets.length > 1) {
112+
lines.push('');
113+
lines.push(`_(${snippets.length} events coalesced; showing latest)_`);
114+
}
115+
return lines.join('\n');
102116
}
103117

104118
interface PendingBatch {

tests/message-bridge.test.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,20 @@ describe('extractSpontaneousSnippet', () => {
8181
expect(extractSpontaneousSnippet(msg)).toBe('Weather is sunny');
8282
});
8383

84-
it('returns 🔧 prefixed tool name for tool_use blocks', () => {
84+
// Tool-use blocks used to render as `🔧 <ToolName>` lines in the
85+
// spontaneous card. That's the exact intermediate noise we hid from the
86+
// main card in PR #268 — surfacing it between turns would just put it
87+
// right back. extractSpontaneousSnippet now drops tool_use blocks
88+
// entirely; only text snippets (the agent's actual conclusion) survive.
89+
it('returns null for tool_use-only assistant messages (intermediate noise dropped)', () => {
8590
const msg = {
8691
type: 'assistant',
8792
message: { content: [{ type: 'tool_use', name: 'Bash' }] },
8893
};
89-
expect(extractSpontaneousSnippet(msg)).toBe('🔧 Bash');
94+
expect(extractSpontaneousSnippet(msg)).toBeNull();
9095
});
9196

92-
it('prefers the first usable block (text wins over later tool_use)', () => {
97+
it('returns text and ignores adjacent tool_use blocks', () => {
9398
const msg = {
9499
type: 'assistant',
95100
message: { content: [
@@ -100,6 +105,17 @@ describe('extractSpontaneousSnippet', () => {
100105
expect(extractSpontaneousSnippet(msg)).toBe('hello');
101106
});
102107

108+
it('returns null when only tool_use blocks are present (text-less burst)', () => {
109+
const msg = {
110+
type: 'assistant',
111+
message: { content: [
112+
{ type: 'tool_use', name: 'Read' },
113+
{ type: 'tool_use', name: 'Bash' },
114+
] },
115+
};
116+
expect(extractSpontaneousSnippet(msg)).toBeNull();
117+
});
118+
103119
it('truncates very long text to 400 chars', () => {
104120
const long = 'x'.repeat(800);
105121
const out = extractSpontaneousSnippet({
@@ -124,7 +140,7 @@ describe('extractSpontaneousSnippet', () => {
124140
expect(extractSpontaneousSnippet({})).toBeNull();
125141
});
126142

127-
it('returns null for assistant messages with no text/tool_use content', () => {
143+
it('returns null for assistant messages with no usable text content', () => {
128144
const msg = {
129145
type: 'assistant',
130146
message: { content: [{ type: 'thinking', text: 'silent' }, { type: 'image' }] },
@@ -142,11 +158,34 @@ describe('extractSpontaneousSnippet', () => {
142158
});
143159

144160
describe('formatSpontaneousCardBody', () => {
145-
it('renders snippets as a numbered list under the header', () => {
146-
const body = formatSpontaneousCardBody(['🔧 Bash', 'Weather is sunny']);
161+
// After the post-#268 simplification, the card shows ONLY the latest
162+
// snippet (the agent's conclusion of the burst). Earlier snippets are
163+
// hidden — same UX bet as the main card's single-line tool indicator,
164+
// i.e. surface only the final result, not the play-by-play. If the user
165+
// wants the intermediate steps, they can read pm2 logs or the web UI's
166+
// expandable tool view.
167+
it('renders only the latest snippet when a single snippet is present', () => {
168+
const body = formatSpontaneousCardBody(['Weather is sunny']);
169+
expect(body).toContain(SPONTANEOUS_CARD_HEADER);
170+
expect(body).toContain('Weather is sunny');
171+
// No numbered prefix — single snippet doesn't need one.
172+
expect(body).not.toMatch(/\*\*1\.\*\*/);
173+
});
174+
175+
it('renders only the latest snippet + a coalesced-count footer when N>1', () => {
176+
const body = formatSpontaneousCardBody([
177+
'Looking at the PR comments…',
178+
'Found 3 things to address.',
179+
'Pushed commit abc1234 to the branch.',
180+
]);
147181
expect(body).toContain(SPONTANEOUS_CARD_HEADER);
148-
expect(body).toMatch(/\*\*1\.\*\*\s+🔧 Bash/);
149-
expect(body).toMatch(/\*\*2\.\*\*\s+Weather is sunny/);
182+
expect(body).toContain('Pushed commit abc1234 to the branch.');
183+
expect(body).toMatch(/3 events coalesced/);
184+
// Earlier snippets must NOT appear in the body.
185+
expect(body).not.toContain('Looking at the PR comments');
186+
expect(body).not.toContain('Found 3 things to address');
187+
// No numbered list prefixes either.
188+
expect(body).not.toMatch(/\*\*1\.\*\*/);
150189
});
151190

152191
// Regression for Bug A (misleading title): the header used to say

0 commit comments

Comments
 (0)