mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 14:07:13 +02:00
fix: stabilize streamed agent text
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -118,7 +118,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
|
|
||||||
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
|
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
|
||||||
StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName);
|
StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName);
|
||||||
segment.Content.Append(update.Update.Text);
|
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text));
|
||||||
|
|
||||||
await onDelta(new TurnDeltaEventDto
|
await onDelta(new TurnDeltaEventDto
|
||||||
{
|
{
|
||||||
@@ -416,6 +416,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
public string AuthorName { get; }
|
public string AuthorName { get; }
|
||||||
|
|
||||||
public StringBuilder Content { get; } = new();
|
public StringBuilder Content { get; } = new();
|
||||||
|
|
||||||
|
public void SetContent(string value)
|
||||||
|
{
|
||||||
|
Content.Clear();
|
||||||
|
Content.Append(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class AgentBundle : IAsyncDisposable
|
private sealed class AgentBundle : IAsyncDisposable
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Kopaya.AgentHost.Services;
|
||||||
|
|
||||||
|
internal static partial class StreamingTextMerger
|
||||||
|
{
|
||||||
|
public static string Merge(string current, string incoming)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(current))
|
||||||
|
{
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(incoming))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||||
|
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
int overlap = ComputeSuffixPrefixOverlap(current, incoming);
|
||||||
|
if (overlap > 0)
|
||||||
|
{
|
||||||
|
return current + incoming[overlap..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldReplaceWithSnapshot(current, incoming))
|
||||||
|
{
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current + incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
|
||||||
|
{
|
||||||
|
int maxOverlap = Math.Min(current.Length, incoming.Length);
|
||||||
|
for (int length = maxOverlap; length > 0; length--)
|
||||||
|
{
|
||||||
|
if (string.CompareOrdinal(current, current.Length - length, incoming, 0, length) == 0)
|
||||||
|
{
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldReplaceWithSnapshot(string current, string incoming)
|
||||||
|
{
|
||||||
|
if (incoming.Length < Math.Floor(current.Length * 0.6))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> currentTokens = Tokenize(current);
|
||||||
|
HashSet<string> incomingTokens = Tokenize(incoming);
|
||||||
|
if (currentTokens.Count < 3 || incomingTokens.Count < 3)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int shared = incomingTokens.Count(token => currentTokens.Contains(token));
|
||||||
|
return shared / (double)Math.Min(currentTokens.Count, incomingTokens.Count) >= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> Tokenize(string value)
|
||||||
|
{
|
||||||
|
return TokenRegex()
|
||||||
|
.Matches(value.ToLowerInvariant())
|
||||||
|
.Select(match => match.Value)
|
||||||
|
.Where(token => token.Length > 0)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex TokenRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Kopaya.AgentHost.Services;
|
||||||
|
|
||||||
|
namespace Kopaya.AgentHost.Tests;
|
||||||
|
|
||||||
|
public sealed class StreamingTextMergerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Merge_AppendsPlainDeltas()
|
||||||
|
{
|
||||||
|
Assert.Equal("I am going", StreamingTextMerger.Merge("I am", " going"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_ReplacesWithGrowingSnapshot()
|
||||||
|
{
|
||||||
|
Assert.Equal("I am going", StreamingTextMerger.Merge("I am", "I am going"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_PreservesCurrentTextForDuplicateSubset()
|
||||||
|
{
|
||||||
|
Assert.Equal("I am going", StreamingTextMerger.Merge("I am going", "going"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_UsesOverlapToAvoidDuplicateJoins()
|
||||||
|
{
|
||||||
|
Assert.Equal("Hello world", StreamingTextMerger.Merge("Hello wor", "world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_ReplacesWithRevisedSnapshotWhenMostTokensOverlap()
|
||||||
|
{
|
||||||
|
const string current = "I mirror the existing button pattern and add brief toggle docs.";
|
||||||
|
const string incoming = "I found the standalone component pattern and I am updating toggle docs next.";
|
||||||
|
|
||||||
|
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { SessionEventRecord } from '@shared/domain/event';
|
|||||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
|
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||||
|
|
||||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||||
import { SecretStore } from '@main/secrets/secretStore';
|
import { SecretStore } from '@main/secrets/secretStore';
|
||||||
@@ -278,7 +279,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const existing = session.messages.find((message) => message.id === event.messageId);
|
const existing = session.messages.find((message) => message.id === event.messageId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.content += event.contentDelta;
|
existing.content = mergeStreamingText(existing.content, event.contentDelta);
|
||||||
existing.pending = true;
|
existing.pending = true;
|
||||||
} else {
|
} else {
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||||
|
|
||||||
export function applySessionEventWorkspace(
|
export function applySessionEventWorkspace(
|
||||||
current: WorkspaceState | undefined,
|
current: WorkspaceState | undefined,
|
||||||
@@ -86,7 +87,7 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
|
|||||||
const nextMessage: ChatMessageRecord = {
|
const nextMessage: ChatMessageRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
authorName: event.authorName ?? existing.authorName,
|
authorName: event.authorName ?? existing.authorName,
|
||||||
content: `${existing.content}${event.contentDelta}`,
|
content: mergeStreamingText(existing.content, event.contentDelta),
|
||||||
pending: true,
|
pending: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
function tokenize(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/i)
|
||||||
|
.filter((token) => token.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSuffixPrefixOverlap(current: string, incoming: string): number {
|
||||||
|
const maxOverlap = Math.min(current.length, incoming.length);
|
||||||
|
for (let length = maxOverlap; length > 0; length -= 1) {
|
||||||
|
if (current.slice(-length) === incoming.slice(0, length)) {
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReplaceWithSnapshot(current: string, incoming: string): boolean {
|
||||||
|
if (incoming.length < Math.floor(current.length * 0.6)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTokens = new Set(tokenize(current));
|
||||||
|
const incomingTokens = new Set(tokenize(incoming));
|
||||||
|
if (currentTokens.size < 3 || incomingTokens.size < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared = 0;
|
||||||
|
for (const token of incomingTokens) {
|
||||||
|
if (currentTokens.has(token)) {
|
||||||
|
shared += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shared / Math.min(currentTokens.size, incomingTokens.size) >= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeStreamingText(current: string, incoming: string): string {
|
||||||
|
if (!current) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!incoming) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.startsWith(current) || incoming.includes(current)) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.includes(incoming)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlap = computeSuffixPrefixOverlap(current, incoming);
|
||||||
|
if (overlap > 0) {
|
||||||
|
return current + incoming.slice(overlap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReplaceWithSnapshot(current, incoming)) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current + incoming;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||||
|
|
||||||
|
describe('streaming text merge', () => {
|
||||||
|
test('appends plain deltas', () => {
|
||||||
|
expect(mergeStreamingText('I am', ' going')).toBe('I am going');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces with a growing snapshot when the incoming text already contains the current text', () => {
|
||||||
|
expect(mergeStreamingText('I am', 'I am going')).toBe('I am going');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves the current text when the incoming update is a duplicate subset', () => {
|
||||||
|
expect(mergeStreamingText('I am going', 'going')).toBe('I am going');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses overlap matching to avoid duplicated joins', () => {
|
||||||
|
expect(mergeStreamingText('Hello wor', 'world')).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces with a revised snapshot when the updates share most of the same tokens', () => {
|
||||||
|
const current = 'I mirror the existing button pattern and add brief toggle docs.';
|
||||||
|
const incoming = 'I found the standalone component pattern and I am updating toggle docs next.';
|
||||||
|
|
||||||
|
expect(mergeStreamingText(current, incoming)).toBe(incoming);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user