diff --git a/src/core/bot.ts b/src/core/bot.ts index 3158d91..6efd03d 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -20,7 +20,13 @@ import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/lo import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import type { GroupBatcher } from './group-batcher.js'; import { redactOutbound } from './redact.js'; -import { parseDirectives, stripActionsBlock, type Directive } from './directives.js'; +import { + hasIncompleteActionsTag, + hasUnclosedActionsBlock, + parseDirectives, + stripActionsBlock, + type Directive, +} from './directives.js'; import { resolveEmoji } from './emoji.js'; import { SessionManager } from './session-manager.js'; import { createDisplayPipeline, type DisplayEvent, type CompleteEvent, type ErrorEvent } from './display-pipeline.js'; @@ -1437,8 +1443,8 @@ export class LettaBot implements AgentSession { const canEdit = adapter.supportsEditing?.() ?? false; const trimmed = response.trim(); const mayBeHidden = ''.startsWith(trimmed) - || ''.startsWith(trimmed) - || (trimmed.startsWith('')); + || hasIncompleteActionsTag(response) + || hasUnclosedActionsBlock(response); const streamText = stripActionsBlock(response).trim(); if (canEdit && !mayBeHidden && !suppressDelivery && !this.cancelledKeys.has(convKey) && streamText.length > 0 && Date.now() - lastUpdate > 1500 && Date.now() > rateLimitedUntil) { diff --git a/src/core/directives.test.ts b/src/core/directives.test.ts index a8cfd62..ce8ce6b 100644 --- a/src/core/directives.test.ts +++ b/src/core/directives.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { parseDirectives, stripActionsBlock } from './directives.js'; +import { + hasIncompleteActionsTag, + hasUnclosedActionsBlock, + parseDirectives, + stripActionsBlock, +} from './directives.js'; describe('parseDirectives', () => { it('returns text unchanged when no actions block present', () => { @@ -113,11 +118,34 @@ describe('parseDirectives', () => { expect(result.directives).toEqual([]); }); - it('ignores actions block NOT at start of response', () => { + it('parses actions block in middle of response', () => { const input = 'Some text first '; const result = parseDirectives(input); - expect(result.cleanText).toBe(input); - expect(result.directives).toEqual([]); + expect(result.cleanText).toBe('Some text first'); + expect(result.directives).toEqual([{ type: 'react', emoji: 'eyes' }]); + }); + + it('parses trailing actions block after visible text', () => { + const input = 'Message complete. '; + const result = parseDirectives(input); + expect(result.cleanText).toBe('Message complete.'); + expect(result.directives).toEqual([{ type: 'react', emoji: 'thumbsup' }]); + }); + + it('parses and executes directives across multiple actions blocks in source order', () => { + const input = [ + 'Start', + '', + 'Middle', + 'Hello', + 'End', + ].join(' '); + const result = parseDirectives(input); + expect(result.cleanText).toBe('Start Middle End'); + expect(result.directives).toEqual([ + { type: 'react', emoji: 'eyes' }, + { type: 'voice', text: 'Hello' }, + ]); }); it('handles leading whitespace before actions block', () => { @@ -339,8 +367,37 @@ describe('stripActionsBlock', () => { expect(stripActionsBlock('')).toBe(''); }); - it('does not strip actions block in middle of text', () => { + it('strips actions block in middle of text', () => { const input = 'Before After'; - expect(stripActionsBlock(input)).toBe(input); + expect(stripActionsBlock(input)).toBe('Before After'); + }); + + it('strips multiple actions blocks in one response', () => { + const input = 'A B Hello C'; + expect(stripActionsBlock(input)).toBe('A B C'); + }); +}); + +describe('hasUnclosedActionsBlock', () => { + it('detects unmatched opening actions tag', () => { + expect(hasUnclosedActionsBlock('Before ')).toBe(true); + }); + + it('returns false for complete actions block', () => { + expect(hasUnclosedActionsBlock('Before After')).toBe(false); + }); +}); + +describe('hasIncompleteActionsTag', () => { + it('detects partial opening actions tag while streaming', () => { + expect(hasIncompleteActionsTag('Before { + expect(hasIncompleteActionsTag('Before { + expect(hasIncompleteActionsTag('Before ok')).toBe(false); }); }); diff --git a/src/core/directives.ts b/src/core/directives.ts index 8e2197f..b9979af 100644 --- a/src/core/directives.ts +++ b/src/core/directives.ts @@ -1,11 +1,11 @@ /** * XML Directive Parser * - * Parses an block at the start of agent text responses. + * Parses blocks from agent text responses. * Extends the existing pattern to support richer actions * (reactions, file sends, etc.) without requiring tool calls. * - * The block must appear at the start of the response: + * blocks can appear anywhere in the response: * * * @@ -53,10 +53,14 @@ export interface ParseResult { } /** - * Match the ... wrapper at the start of the response. - * Captures the inner content of the block. + * Match complete ... wrappers anywhere in the response. + * Captures the inner content of each block. */ -const ACTIONS_BLOCK_REGEX = /^\s*([\s\S]*?)<\/actions>/; +const ACTIONS_BLOCK_REGEX_SOURCE = '([\\s\\S]*?)<\\/actions>'; + +function createActionsBlockRegex(flags = 'g'): RegExp { + return new RegExp(ACTIONS_BLOCK_REGEX_SOURCE, flags); +} /** * Match supported directive tags inside the actions block in source order. @@ -156,28 +160,52 @@ function parseChildDirectives(block: string): Directive[] { /** * Parse XML directives from agent response text. * - * Looks for an ... block at the start of the response. - * Returns the cleaned text (block stripped) and an array of parsed directives. - * If no block is found, the text is returned unchanged. + * Looks for complete ... blocks anywhere in the response. + * Returns the cleaned text (all complete blocks stripped) and parsed directives. + * If no complete block is found, the text is returned unchanged. */ export function parseDirectives(text: string): ParseResult { - const match = text.match(ACTIONS_BLOCK_REGEX); - - if (!match) { + const blockRegex = createActionsBlockRegex(); + if (!blockRegex.test(text)) { return { cleanText: text, directives: [] }; } - const actionsContent = match[1]; - const cleanText = text.slice(match[0].length).trim(); - const directives = parseChildDirectives(actionsContent); + const directives: Directive[] = []; + const cleanText = text.replace(createActionsBlockRegex(), (_, actionsContent: string) => { + directives.push(...parseChildDirectives(actionsContent)); + return ''; + }).trim(); return { cleanText, directives }; } /** - * Strip a leading ... block from text for streaming display. - * Returns the text after the block, or the original text if no complete block found. + * Returns true when text contains an opening tag with no matching + * closing tag yet. Used during streaming to avoid flashing raw XML. + */ +export function hasUnclosedActionsBlock(text: string): boolean { + const lastOpen = text.lastIndexOf(''); + if (lastOpen < 0) return false; + const lastClose = text.lastIndexOf(''); + return lastOpen > lastClose; +} + +/** + * Returns true when the tail of the text contains a partial actions tag + * (opening or closing) that has not streamed fully yet. + */ +export function hasIncompleteActionsTag(text: string): boolean { + const lastLt = text.lastIndexOf('<'); + const lastGt = text.lastIndexOf('>'); + if (lastLt < 0 || lastLt <= lastGt) return false; + const tail = text.slice(lastLt); + return ''.startsWith(tail) || ''.startsWith(tail); +} + +/** + * Strip complete ... blocks from text for streaming display. + * Returns the text after stripping blocks, or the original text if none found. */ export function stripActionsBlock(text: string): string { - return text.replace(ACTIONS_BLOCK_REGEX, '').trim(); + return text.replace(createActionsBlockRegex(), '').trim(); }