fix: improve streaming message merging

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 23:31:06 +01:00
co-authored by Copilot
parent 794722673b
commit b88299f962
5 changed files with 187 additions and 6 deletions
@@ -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<string> currentTokens = Tokenize(current);
HashSet<string> incomingTokens = Tokenize(incoming);
HashSet<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal);
HashSet<string> 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<string> 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<string> 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();
}
@@ -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"));
}
}
+47 -1
View File
@@ -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);
}
+35
View File
@@ -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',
+15
View File
@@ -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',
);
});
});