mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-03 10:28:43 +02:00
fix: improve streaming message merging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user