mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 13:23:57 +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 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;
|
return current + incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +77,8 @@ internal static partial class StreamingTextMerger
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
HashSet<string> currentTokens = Tokenize(current);
|
HashSet<string> currentTokens = Tokenize(current).ToHashSet(StringComparer.Ordinal);
|
||||||
HashSet<string> incomingTokens = Tokenize(incoming);
|
HashSet<string> incomingTokens = Tokenize(incoming).ToHashSet(StringComparer.Ordinal);
|
||||||
if (currentTokens.Count < 3 || incomingTokens.Count < 3)
|
if (currentTokens.Count < 3 || incomingTokens.Count < 3)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -73,15 +88,66 @@ internal static partial class StreamingTextMerger
|
|||||||
return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5;
|
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()
|
return TokenRegex()
|
||||||
.Matches(value.ToLowerInvariant())
|
.Matches(value.ToLowerInvariant())
|
||||||
.Select(match => match.Value)
|
.Select(match => match.Value)
|
||||||
.Where(token => token.Length > 0)
|
.Where(token => token.Length > 0);
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex TokenRegex();
|
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));
|
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);
|
.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 {
|
function computeSuffixPrefixOverlap(current: string, incoming: string): number {
|
||||||
const maxOverlap = Math.min(current.length, incoming.length);
|
const maxOverlap = Math.min(current.length, incoming.length);
|
||||||
for (let length = maxOverlap; length > 0; length -= 1) {
|
for (let length = maxOverlap; length > 0; length -= 1) {
|
||||||
@@ -63,5 +109,5 @@ export function mergeStreamingText(current: string, incoming: string): string {
|
|||||||
return incoming;
|
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', () => {
|
test('updates session status and error state from session events', () => {
|
||||||
const running = applySessionEventWorkspace(createWorkspace(), {
|
const running = applySessionEventWorkspace(createWorkspace(), {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
|
|||||||
@@ -25,4 +25,19 @@ describe('streaming text merge', () => {
|
|||||||
|
|
||||||
expect(mergeStreamingText(current, incoming)).toBe(incoming);
|
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