From ea5c19e934749396d192127ce541eb89c2e10ec8 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:12:30 +0800 Subject: [PATCH] fix(sync): deduplicate overlapping delta after coalesced part.updated (#916) * fix: hide archived section and empty folders when no sessions remain - Only push archived group in useSessionGrouping when there are archived sessions, preventing an empty archived section from rendering - Hide empty folders in archived bucket via shouldKeepFolder check in SessionGroupSection (folders with no sessions and no content in children are filtered out) - Always filter folders through shouldKeepFolder, not just during search * perf: memoize archived folder filtering * fix(sync): deduplicate overlapping delta after coalesced part.updated When message.part.updated coalesces in the event pipeline and a message.part.delta for the same part arrives in the same flush window, the reducer appends the delta verbatim to the already-complete field value, producing duplicated text in tool output and assistant messages. Add targeted overlap reconciliation: only when a part.updated replaces an existing part with overlapping string content, mark the next delta for that field as dedupe-eligible. Normal streaming deltas remain untouched (pure append). Covers four cases: - Full overlap: delta already present -> no-op - Partial overlap: only non-overlapping suffix appended - No overlap: unchanged append behavior - Legitimate repeated output (ha + ha -> haha): preserved --------- Co-authored-by: Bohdan Triapitsyn --- .../src/sync/__tests__/event-reducer.test.js | 154 ++++++++++++++++++ packages/ui/src/sync/event-reducer.ts | 47 +++++- 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/sync/__tests__/event-reducer.test.js diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.js b/packages/ui/src/sync/__tests__/event-reducer.test.js new file mode 100644 index 00000000..3f7b5004 --- /dev/null +++ b/packages/ui/src/sync/__tests__/event-reducer.test.js @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'bun:test' +import { applyDirectoryEvent } from '../event-reducer' +import { INITIAL_STATE } from '../types' + +describe('applyDirectoryEvent', () => { + it('does not duplicate overlapping delta text after a newer part.updated replaces an older one', () => { + const state = structuredClone(INITIAL_STATE) + const messageID = 'msg-1' + const partID = 'part-1' + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'Fix typo in ToolOutputDialog — ', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'Fix typo in ToolOutputDialog — toolFailedToReadDiagram vs toolFailedReadDiagram • Let me fix it.', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.delta', + properties: { + messageID, + partID, + field: 'text', + delta: 'toolFailedToReadDiagram vs toolFailedReadDiagram • Let me fix it.', + }, + }) + + expect(state.part[messageID]).toHaveLength(1) + expect(state.part[messageID]?.[0]?.text).toBe( + 'Fix typo in ToolOutputDialog — toolFailedToReadDiagram vs toolFailedReadDiagram • Let me fix it.', + ) + }) + + it('appends only the non-overlapping suffix of a streaming delta', () => { + const state = structuredClone(INITIAL_STATE) + const messageID = 'msg-2' + const partID = 'part-2' + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'toolFailedToReadDiagram vs toolFailedRead', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'toolFailedToReadDiagram vs toolFailedReadDiagra', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.delta', + properties: { + messageID, + partID, + field: 'text', + delta: 'Diagram • Let me fix it.', + }, + }) + + expect(state.part[messageID]?.[0]?.text).toBe( + 'toolFailedToReadDiagram vs toolFailedReadDiagram • Let me fix it.', + ) + }) + + it('appends a non-overlapping delta unchanged', () => { + const state = structuredClone(INITIAL_STATE) + const messageID = 'msg-3' + const partID = 'part-3' + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'PR comment done — ', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.delta', + properties: { + messageID, + partID, + field: 'text', + delta: 'Let me fix it.', + }, + }) + + expect(state.part[messageID]?.[0]?.text).toBe('PR comment done — Let me fix it.') + }) + + it('preserves legitimate repeated output when no updated-to-delta dedupe window is active', () => { + const state = structuredClone(INITIAL_STATE) + const messageID = 'msg-4' + const partID = 'part-4' + + applyDirectoryEvent(state, { + type: 'message.part.updated', + properties: { + part: { + id: partID, + type: 'text', + messageID, + text: 'ha', + }, + }, + }) + + applyDirectoryEvent(state, { + type: 'message.part.delta', + properties: { + messageID, + partID, + field: 'text', + delta: 'ha', + }, + }) + + expect(state.part[messageID]?.[0]?.text).toBe('haha') + }) +}) diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index bb2b1aaa..2bdb07ff 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -16,6 +16,39 @@ import { stripSessionDiffSnapshots } from "./sanitize" import { syncDebug } from "./debug" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const DELTA_OVERLAP_FIELDS = ["text", "output"] as const + +type DedupeMetadata = { + __dedupeNextDeltaFields?: string[] +} + +function appendNonOverlappingDelta(existingValue: string | undefined, delta: string) { + if (!existingValue || delta.length === 0) return (existingValue ?? "") + delta + if (existingValue.endsWith(delta)) return existingValue + + const maxOverlap = Math.min(existingValue.length, delta.length) + for (let overlap = maxOverlap; overlap > 0; overlap--) { + if (existingValue.endsWith(delta.slice(0, overlap))) { + return existingValue + delta.slice(overlap) + } + } + + return existingValue + delta +} + +function getUpdatedDeltaFields(previous: Part, next: Part) { + const dedupeFields: string[] = [] + for (const field of DELTA_OVERLAP_FIELDS) { + const previousValue = (previous as Record)[field] + const nextValue = (next as Record)[field] + if (typeof previousValue !== "string" || typeof nextValue !== "string") continue + if (previousValue.length === 0 || nextValue.length === 0) continue + if (nextValue === previousValue || nextValue.startsWith(previousValue) || previousValue.startsWith(nextValue)) { + dedupeFields.push(field) + } + } + return dedupeFields +} // --------------------------------------------------------------------------- // Global events @@ -205,7 +238,11 @@ export function applyDirectoryEvent( const next = [...parts] const result = Binary.search(next, part.id, (p) => p.id) if (result.found) { - next[result.index] = part + const previous = next[result.index] + const dedupeFields = getUpdatedDeltaFields(previous, part) + next[result.index] = dedupeFields.length > 0 + ? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part + : part } else { // Replace optimistic part (no sessionID) with server part of same type. // Gate: only scan if the first part lacks sessionID (optimistic parts are @@ -262,9 +299,15 @@ export function applyDirectoryEvent( } const existing = parts[result.index] as Record const existingValue = existing[props.field] as string | undefined + const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? [] + const shouldDedupe = dedupeFields.includes(props.field) // Create new Part object + new array so React detects the change const next = [...parts] - next[result.index] = { ...existing, [props.field]: (existingValue ?? "") + props.delta } as Part + next[result.index] = { + ...existing, + [props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta, + __dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field), + } as unknown as Part draft.part[props.messageID] = next return true }