From b88299f9628a8ac814f67b09252b3fd44f5e1933 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Mon, 23 Mar 2026 23:31:06 +0100 Subject: [PATCH] fix: improve streaming message merging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/StreamingTextMerger.cs | 76 +++++++++++++++++-- .../StreamingTextMergerTests.cs | 19 +++++ src/shared/utils/streamingText.ts | 48 +++++++++++- tests/renderer/sessionWorkspace.test.ts | 35 +++++++++ tests/shared/streamingText.test.ts | 15 ++++ 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs b/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs index b58b9e0..e10b757 100644 --- a/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs +++ b/sidecar/src/Eryx.AgentHost/Services/StreamingTextMerger.cs @@ -38,6 +38,21 @@ internal static partial class StreamingTextMerger return incoming; } + return AppendWithNaturalBoundary(current, incoming); + } + + private static string AppendWithNaturalBoundary(string current, string incoming) + { + if (ShouldInsertNewlineBoundary(current, incoming)) + { + return current + "\n" + incoming; + } + + if (ShouldInsertSpaceBoundary(current, incoming)) + { + return current + " " + incoming; + } + return current + incoming; } @@ -62,8 +77,8 @@ internal static partial class StreamingTextMerger return false; } - HashSet currentTokens = Tokenize(current); - HashSet incomingTokens = Tokenize(incoming); + HashSet currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal); + HashSet incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal); if (currentTokens.Count < 3 || incomingTokens.Count < 3) { return false; @@ -73,15 +88,66 @@ internal static partial class StreamingTextMerger return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5; } - private static HashSet Tokenize(string value) + private static bool ShouldInsertNewlineBoundary(string current, string incoming) + { + if (current.EndsWith('\n')) + { + return false; + } + + return MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart()); + } + + private static bool ShouldInsertSpaceBoundary(string current, string incoming) + { + char lastChar = current[^1]; + char firstChar = incoming[0]; + + if (char.IsWhiteSpace(lastChar) + || char.IsWhiteSpace(firstChar) + || "([{/\"'`".Contains(lastChar)) + { + return false; + } + + if (ClosingPunctuationRegex().IsMatch(incoming)) + { + return false; + } + + if (MarkdownInlinePrefixRegex().IsMatch(incoming) + || char.IsUpper(firstChar) + || char.IsDigit(firstChar)) + { + return true; + } + + string[] currentTokens = Tokenize(current).ToArray(); + string[] incomingTokens = Tokenize(incoming).ToArray(); + string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty; + + return currentTokens.Length >= 2 + && incomingTokens.Length >= 2 + && firstIncomingToken.Length >= 2; + } + + private static IEnumerable Tokenize(string value) { return TokenRegex() .Matches(value.ToLowerInvariant()) .Select(match => match.Value) - .Where(token => token.Length > 0) - .ToHashSet(StringComparer.Ordinal); + .Where(token => token.Length > 0); } [GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)] private static partial Regex TokenRegex(); + + [GeneratedRegex(@"^[.,!?;:%)\]}]")] + private static partial Regex ClosingPunctuationRegex(); + + [GeneratedRegex(@"^[*_`~\[]")] + private static partial Regex MarkdownInlinePrefixRegex(); + + [GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)] + private static partial Regex MarkdownBlockPrefixRegex(); } diff --git a/sidecar/tests/Eryx.AgentHost.Tests/StreamingTextMergerTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/StreamingTextMergerTests.cs index f0b6729..8a2df7e 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/StreamingTextMergerTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/StreamingTextMergerTests.cs @@ -36,4 +36,23 @@ public sealed class StreamingTextMergerTests Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming)); } + + [Fact] + public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether() + { + Assert.Equal( + "How about The **Ashen Crown** feels", + StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels")); + Assert.Equal( + "The **Ashen Crown** feels classic and timeless.", + StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless.")); + } + + [Fact] + public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers() + { + Assert.Equal( + "If you want, I can also give you\n- darker titles", + StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles")); + } } diff --git a/src/shared/utils/streamingText.ts b/src/shared/utils/streamingText.ts index 9e34acd..33fd2a7 100644 --- a/src/shared/utils/streamingText.ts +++ b/src/shared/utils/streamingText.ts @@ -5,6 +5,52 @@ function tokenize(value: string): string[] { .filter((token) => token.length > 0); } +function shouldInsertNewlineBoundary(current: string, incoming: string): boolean { + if (current.endsWith('\n')) { + return false; + } + + return /^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)/.test(incoming.trimStart()); +} + +function shouldInsertSpaceBoundary(current: string, incoming: string): boolean { + const lastChar = current.at(-1); + const firstChar = incoming[0]; + if (!lastChar || !firstChar) { + return false; + } + + if (/\s/.test(lastChar) || /\s/.test(firstChar) || /[([{/"'`]/.test(lastChar)) { + return false; + } + + if (/^[.,!?;:%)\]}]/.test(firstChar)) { + return false; + } + + if (/^[*_`~\[]/.test(firstChar) || /^[A-Z0-9]/.test(firstChar)) { + return true; + } + + const currentTokens = tokenize(current); + const incomingTokens = tokenize(incoming); + const firstIncomingToken = incomingTokens[0] ?? ''; + + return currentTokens.length >= 2 && incomingTokens.length >= 2 && firstIncomingToken.length >= 2; +} + +function appendWithNaturalBoundary(current: string, incoming: string): string { + if (shouldInsertNewlineBoundary(current, incoming)) { + return `${current}\n${incoming}`; + } + + if (shouldInsertSpaceBoundary(current, incoming)) { + return `${current} ${incoming}`; + } + + return current + incoming; +} + function computeSuffixPrefixOverlap(current: string, incoming: string): number { const maxOverlap = Math.min(current.length, incoming.length); for (let length = maxOverlap; length > 0; length -= 1) { @@ -63,5 +109,5 @@ export function mergeStreamingText(current: string, incoming: string): string { return incoming; } - return current + incoming; + return appendWithNaturalBoundary(current, incoming); } diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index 5ed205b..054868b 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -90,6 +90,41 @@ describe('session workspace helpers', () => { }); }); + test('keeps snapshot-like streamed updates readable while a message is pending', () => { + const first = applySessionEventWorkspace(createWorkspace(), { + sessionId: 'session-1', + kind: 'message-delta', + occurredAt: '2026-03-23T00:00:01.000Z', + messageId: 'assistant-1', + authorName: 'Writer', + contentDelta: 'How about', + } satisfies SessionEventRecord); + + const second = applySessionEventWorkspace(first, { + sessionId: 'session-1', + kind: 'message-delta', + occurredAt: '2026-03-23T00:00:02.000Z', + messageId: 'assistant-1', + authorName: 'Writer', + contentDelta: 'The **Ashen Crown** feels', + } satisfies SessionEventRecord); + + const third = applySessionEventWorkspace(second, { + sessionId: 'session-1', + kind: 'message-delta', + occurredAt: '2026-03-23T00:00:03.000Z', + messageId: 'assistant-1', + authorName: 'Writer', + contentDelta: 'classic and timeless.', + } satisfies SessionEventRecord); + + expect(third?.sessions[0].messages[0]).toMatchObject({ + authorName: 'Writer', + content: 'How about The **Ashen Crown** feels classic and timeless.', + pending: true, + }); + }); + test('updates session status and error state from session events', () => { const running = applySessionEventWorkspace(createWorkspace(), { sessionId: 'session-1', diff --git a/tests/shared/streamingText.test.ts b/tests/shared/streamingText.test.ts index b655d83..2544e21 100644 --- a/tests/shared/streamingText.test.ts +++ b/tests/shared/streamingText.test.ts @@ -25,4 +25,19 @@ describe('streaming text merge', () => { expect(mergeStreamingText(current, incoming)).toBe(incoming); }); + + test('inserts whitespace when snapshot-like updates would otherwise glue words together', () => { + expect(mergeStreamingText('How about', 'The **Ashen Crown** feels')).toBe( + 'How about The **Ashen Crown** feels', + ); + expect(mergeStreamingText('The **Ashen Crown** feels', 'classic and timeless.')).toBe( + 'The **Ashen Crown** feels classic and timeless.', + ); + }); + + test('inserts a newline before streamed markdown block markers', () => { + expect(mergeStreamingText('If you want, I can also give you', '- darker titles')).toBe( + 'If you want, I can also give you\n- darker titles', + ); + }); });