mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -84,6 +84,91 @@ describe('splitAgentMessageIntoBubbles', () => {
|
||||
expect(splitAgentMessageIntoBubbles('___')).toEqual([]);
|
||||
expect(splitAgentMessageIntoBubbles('Before\n\n---\n\nAfter')).toEqual(['Before', 'After']);
|
||||
});
|
||||
|
||||
// Issue #3807: a heading and its body must never land in separate bubbles.
|
||||
it('keeps an ATX heading together with its body in one bubble', () => {
|
||||
expect(splitAgentMessageIntoBubbles('## 📅 Calendar\n\n- 9am standup')).toEqual([
|
||||
'## 📅 Calendar\n\n- 9am standup',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a bold-line heading together with its body in one bubble', () => {
|
||||
expect(splitAgentMessageIntoBubbles('**Tasks**\n\n- Ship the PR')).toEqual([
|
||||
'**Tasks**\n\n- Ship the PR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a multi-section morning briefing as one bubble per section', () => {
|
||||
const briefing = [
|
||||
'Good morning! Here is what today looks like.',
|
||||
'## 📅 Calendar',
|
||||
'- 9:00 Standup\n- 14:00 Design review',
|
||||
'## ✅ Tasks',
|
||||
'- Finish the proposal (due today)',
|
||||
'## 📧 Emails',
|
||||
'2 unread threads from key contacts',
|
||||
].join('\n\n');
|
||||
|
||||
const bubbles = splitAgentMessageIntoBubbles(briefing);
|
||||
|
||||
expect(bubbles).toEqual([
|
||||
'Good morning! Here is what today looks like.',
|
||||
'## 📅 Calendar\n\n- 9:00 Standup\n- 14:00 Design review',
|
||||
'## ✅ Tasks\n\n- Finish the proposal (due today)',
|
||||
'## 📧 Emails\n\n2 unread threads from key contacts',
|
||||
]);
|
||||
|
||||
// No bubble is a heading with no body, and no body bubble lacks its heading.
|
||||
for (const bubble of bubbles.slice(1)) {
|
||||
const lines = bubble.split('\n').filter(line => line.trim().length > 0);
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
expect(lines[0].startsWith('#')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('absorbs trailing closing lines into the final section bubble', () => {
|
||||
const briefing = '## 📧 Emails\n\n2 unread threads\n\nHave a great day! ☀️';
|
||||
expect(splitAgentMessageIntoBubbles(briefing)).toEqual([
|
||||
'## 📧 Emails\n\n2 unread threads\n\nHave a great day! ☀️',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a table out of the heading bubble so the table renderer still fires', () => {
|
||||
// A table folded behind a heading would not sit at the bubble start and
|
||||
// would render as raw pipe text, so it stays its own segment.
|
||||
const content = '## 📅 Calendar\n\n| Time | Event |\n| --- | --- |\n| 9:00 | Standup |';
|
||||
expect(splitAgentMessageIntoBubbles(content)).toEqual([
|
||||
'## 📅 Calendar',
|
||||
'| Time | Event |\n| --- | --- |\n| 9:00 | Standup |',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still groups body paragraphs that follow a table under their heading', () => {
|
||||
const content =
|
||||
'## 📅 Calendar\n\n| Time | Event |\n| --- | --- |\n| 9:00 | Standup |\n\n## ✅ Tasks\n\n- Ship the PR';
|
||||
expect(splitAgentMessageIntoBubbles(content)).toEqual([
|
||||
'## 📅 Calendar',
|
||||
'| Time | Event |\n| --- | --- |\n| 9:00 | Standup |',
|
||||
'## ✅ Tasks\n\n- Ship the PR',
|
||||
]);
|
||||
});
|
||||
|
||||
it('folds a trailing body-less heading into the previous bubble', () => {
|
||||
const content = '## 📅 Calendar\n\n- 9:00 Standup\n\n## ✅ Tasks';
|
||||
expect(splitAgentMessageIntoBubbles(content)).toEqual([
|
||||
'## 📅 Calendar\n\n- 9:00 Standup\n\n## ✅ Tasks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat inline bold emphasis with trailing prose as a heading', () => {
|
||||
// Only a fully-bold line is a heading; emphasis followed by prose stays a
|
||||
// plain paragraph so ordinary content is not misclassified.
|
||||
const content = '**Heads up:** the meeting moved\n\nSee you at 3pm';
|
||||
expect(splitAgentMessageIntoBubbles(content)).toEqual([
|
||||
'**Heads up:** the meeting moved',
|
||||
'See you at 3pm',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMarkdownTable', () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import debugFactory from 'debug';
|
||||
|
||||
const debug = debugFactory('agent-message-bubbles');
|
||||
|
||||
/**
|
||||
* Split an agent message into render-time bubble segments.
|
||||
*
|
||||
@@ -5,6 +9,11 @@
|
||||
* newlines. Fenced code blocks stay intact as a single segment so
|
||||
* Markdown/code rendering does not fragment unexpectedly.
|
||||
* Markdown tables also stay grouped so they can render as dedicated table UI.
|
||||
*
|
||||
* Finally, regroup so a section heading and the body that follows it always
|
||||
* share one bubble (issue #3807): structured output such as the morning
|
||||
* briefing writes `## Heading\n\n- body…`, and splitting on the blank line
|
||||
* would otherwise orphan every heading into its own content-less bubble.
|
||||
*/
|
||||
export function splitAgentMessageIntoBubbles(content: string): string[] {
|
||||
const normalized = content
|
||||
@@ -77,7 +86,106 @@ export function splitAgentMessageIntoBubbles(content: string): string[] {
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
return segments.filter(segment => !isVisualSeparatorOnly(segment));
|
||||
const cleaned = segments.filter(segment => !isVisualSeparatorOnly(segment));
|
||||
const grouped = groupSectionsUnderHeadings(cleaned);
|
||||
debug(
|
||||
'[bubbles] %d char message -> %d raw segment(s) -> %d bubble(s) after heading grouping',
|
||||
content.length,
|
||||
cleaned.length,
|
||||
grouped.length
|
||||
);
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge each section heading with the body segments that follow it (up to the
|
||||
* next heading) into a single bubble, so a heading is never stranded in a
|
||||
* content-less bubble of its own. Segments before the first heading (e.g. a
|
||||
* greeting) are left untouched.
|
||||
*
|
||||
* Tables are deliberately NOT absorbed into a heading bubble: `AgentMessageBubble`
|
||||
* only renders the dedicated table UI when the table sits at the start of the
|
||||
* bubble (`parseMarkdownTable`), and the Markdown renderer has no GFM-table
|
||||
* support, so folding a table behind a heading would render it as raw pipe
|
||||
* text. A table therefore stays its own bubble and ends the current section.
|
||||
*/
|
||||
function groupSectionsUnderHeadings(segments: string[]): string[] {
|
||||
const grouped: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < segments.length) {
|
||||
const segment = segments[index];
|
||||
|
||||
if (!startsWithHeading(segment)) {
|
||||
grouped.push(segment);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absorb the body paragraphs below this heading — but stop at the next
|
||||
// heading or a table, both of which must begin their own bubble.
|
||||
const body: string[] = [];
|
||||
index += 1;
|
||||
while (
|
||||
index < segments.length &&
|
||||
!startsWithHeading(segments[index]) &&
|
||||
!isTableSegment(segments[index])
|
||||
) {
|
||||
body.push(segments[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (body.length > 0) {
|
||||
grouped.push([segment, ...body].join('\n\n'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading with no inline body. Avoid an orphan heading bubble: when the
|
||||
// next segment is a table the heading labels it from the bubble directly
|
||||
// above (and must not be folded into the table); otherwise fold the
|
||||
// heading into the previous bubble, unless that bubble is itself a table.
|
||||
const nextIsTable = index < segments.length && isTableSegment(segments[index]);
|
||||
const previous = grouped[grouped.length - 1];
|
||||
if (!nextIsTable && previous !== undefined && !isTableSegment(previous)) {
|
||||
grouped[grouped.length - 1] = `${previous}\n\n${segment}`;
|
||||
} else {
|
||||
grouped.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/** A segment "starts a section" when its first non-empty line is a heading. */
|
||||
function startsWithHeading(segment: string): boolean {
|
||||
const firstLine = segment.split('\n').find(line => line.trim().length > 0) ?? '';
|
||||
return isHeadingLine(firstLine);
|
||||
}
|
||||
|
||||
/** A segment is a table when its first non-empty line begins a markdown table. */
|
||||
function isTableSegment(segment: string): boolean {
|
||||
const lines = segment.split('\n');
|
||||
let index = 0;
|
||||
while (index < lines.length && lines[index].trim().length === 0) index += 1;
|
||||
return isMarkdownTableStart(lines, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize the heading shapes structured agent output uses to label a
|
||||
* section: Markdown ATX headings (`#`..`######`) and a single fully-bold line
|
||||
* (`**Calendar**`), optionally with a trailing colon.
|
||||
*
|
||||
* The bold form deliberately requires the WHOLE line to be bold — inline
|
||||
* emphasis with trailing prose (`**Heads up:** the meeting moved`) is NOT a
|
||||
* heading, so ordinary emphasized content is not misclassified. The residual
|
||||
* heuristic risk is a standalone fully-bold sentence used as content; in that
|
||||
* case it is simply merged with the following segment (kept together, never
|
||||
* orphaned), which is harmless for the structured briefing shapes this serves.
|
||||
*/
|
||||
function isHeadingLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (/^#{1,6}\s+\S/.test(trimmed)) return true;
|
||||
return /^\*\*[^*]+\*\*:?$/.test(trimmed);
|
||||
}
|
||||
|
||||
export interface ParsedMarkdownTable {
|
||||
|
||||
Reference in New Issue
Block a user