fix: stabilize streamed agent text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 21:39:46 +01:00
co-authored by Copilot
parent 039e570348
commit 16229b8b0a
7 changed files with 232 additions and 3 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ import type { SessionEventRecord } from '@shared/domain/event';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
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);
if (existing) {
existing.content += event.contentDelta;
existing.content = mergeStreamingText(existing.content, event.contentDelta);
existing.pending = true;
} else {
session.messages.push({
+2 -1
View File
@@ -1,6 +1,7 @@
import type { SessionEventRecord } from '@shared/domain/event';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { mergeStreamingText } from '@shared/utils/streamingText';
export function applySessionEventWorkspace(
current: WorkspaceState | undefined,
@@ -86,7 +87,7 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
const nextMessage: ChatMessageRecord = {
...existing,
authorName: event.authorName ?? existing.authorName,
content: `${existing.content}${event.contentDelta}`,
content: mergeStreamingText(existing.content, event.contentDelta),
pending: true,
};
+67
View File
@@ -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;
}