Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions src/bridge/message-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,16 @@ export const SPONTANEOUS_CARD_HEADER =
* Extract a one-line summary from an SDK stream message for the spontaneous
* activity card. Returns null if the message has nothing user-readable.
*
* Intentionally only handles `assistant` messages — `result` messages would
* always duplicate the last assistant text block (SDK's `result.result` is a
* verbatim echo), so including them caused the same content to show up twice
* in the card with two different prefixes. Don't add a `result` branch back
* without first verifying the SDK no longer echoes.
* Intentionally only handles `assistant` *text* blocks — same UX bet we made
* for the main card in PR #268: the user only cares about the agent's
* conclusion, not the per-tool play-by-play. `🔧 <ToolName>` lines used to
* be included for tool_use blocks but that's the exact intermediate noise
* we just hid from the live card; surfacing it on the spontaneous card
* would just re-introduce the same complaint between turns.
*
* Result-type messages are also ignored — SDK's `result.result` is a
* verbatim echo of the last assistant text block, so including them caused
* the same content to show up twice in the card.
*/
export function extractSpontaneousSnippet(msg: unknown): string | null {
const m = msg as { type?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } };
Expand All @@ -85,20 +90,29 @@ export function extractSpontaneousSnippet(msg: unknown): string | null {
if (blk.type === 'text' && blk.text) {
const trimmed = String(blk.text).trim();
if (trimmed) return trimmed.slice(0, 400);
} else if (blk.type === 'tool_use' && blk.name) {
return `🔧 ${blk.name}`;
}
// tool_use blocks intentionally fall through — see docstring above.
}
return null;
}

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

interface PendingBatch {
Expand Down
55 changes: 47 additions & 8 deletions tests/message-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,20 @@ describe('extractSpontaneousSnippet', () => {
expect(extractSpontaneousSnippet(msg)).toBe('Weather is sunny');
});

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

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

it('returns null when only tool_use blocks are present (text-less burst)', () => {
const msg = {
type: 'assistant',
message: { content: [
{ type: 'tool_use', name: 'Read' },
{ type: 'tool_use', name: 'Bash' },
] },
};
expect(extractSpontaneousSnippet(msg)).toBeNull();
});

it('truncates very long text to 400 chars', () => {
const long = 'x'.repeat(800);
const out = extractSpontaneousSnippet({
Expand All @@ -124,7 +140,7 @@ describe('extractSpontaneousSnippet', () => {
expect(extractSpontaneousSnippet({})).toBeNull();
});

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

describe('formatSpontaneousCardBody', () => {
it('renders snippets as a numbered list under the header', () => {
const body = formatSpontaneousCardBody(['🔧 Bash', 'Weather is sunny']);
// After the post-#268 simplification, the card shows ONLY the latest
// snippet (the agent's conclusion of the burst). Earlier snippets are
// hidden — same UX bet as the main card's single-line tool indicator,
// i.e. surface only the final result, not the play-by-play. If the user
// wants the intermediate steps, they can read pm2 logs or the web UI's
// expandable tool view.
it('renders only the latest snippet when a single snippet is present', () => {
const body = formatSpontaneousCardBody(['Weather is sunny']);
expect(body).toContain(SPONTANEOUS_CARD_HEADER);
expect(body).toContain('Weather is sunny');
// No numbered prefix — single snippet doesn't need one.
expect(body).not.toMatch(/\*\*1\.\*\*/);
});

it('renders only the latest snippet + a coalesced-count footer when N>1', () => {
const body = formatSpontaneousCardBody([
'Looking at the PR comments…',
'Found 3 things to address.',
'Pushed commit abc1234 to the branch.',
]);
expect(body).toContain(SPONTANEOUS_CARD_HEADER);
expect(body).toMatch(/\*\*1\.\*\*\s+🔧 Bash/);
expect(body).toMatch(/\*\*2\.\*\*\s+Weather is sunny/);
expect(body).toContain('Pushed commit abc1234 to the branch.');
expect(body).toMatch(/3 events coalesced/);
// Earlier snippets must NOT appear in the body.
expect(body).not.toContain('Looking at the PR comments');
expect(body).not.toContain('Found 3 things to address');
// No numbered list prefixes either.
expect(body).not.toMatch(/\*\*1\.\*\*/);
});

// Regression for Bug A (misleading title): the header used to say
Expand Down
Loading