Skip to content

Commit c5a56e5

Browse files
committed
fix(files): make streamed file-preview accumulation replay-safe
Guard deriveFilePreviewSession against re-delivered/replayed content events: apply a delta/snapshot only when previewVersion strictly advances, so a client re-render or stream replay can't double-append the tail (the duplicated-content bug) or regress on an older snapshot.
1 parent 1a995ff commit c5a56e5

2 files changed

Lines changed: 87 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,80 @@ describe('deriveFilePreviewSession', () => {
9595
expect(next.previewVersion).toBe(9)
9696
})
9797

98+
it('ignores a re-delivered delta (same version) — no double-append (the duplication bug)', () => {
99+
const prev = session({ previewText: 'the story so far.', previewVersion: 7 })
100+
const replay = deriveFilePreviewSession(
101+
prev,
102+
{
103+
previewPhase: 'file_preview_content',
104+
toolCallId: 'tool-1',
105+
toolName: 'workspace_file',
106+
content: ' the story so far.',
107+
contentMode: 'delta',
108+
previewVersion: 7, // <= prev.previewVersion → a replay, must not re-append
109+
fileName: 'deck.pptx',
110+
},
111+
'stream-1',
112+
NOW
113+
)
114+
expect(replay.previewText).toBe('the story so far.')
115+
expect(replay.previewVersion).toBe(7)
116+
})
117+
118+
it('ignores a replayed older snapshot — no regression of accumulated text', () => {
119+
const prev = session({ previewText: 'full accumulated body', previewVersion: 12 })
120+
const stale = deriveFilePreviewSession(
121+
prev,
122+
{
123+
previewPhase: 'file_preview_content',
124+
toolCallId: 'tool-1',
125+
toolName: 'workspace_file',
126+
content: 'earlier partial',
127+
contentMode: 'snapshot',
128+
previewVersion: 5, // stale replay
129+
fileName: 'deck.pptx',
130+
},
131+
'stream-1',
132+
NOW
133+
)
134+
expect(stale.previewText).toBe('full accumulated body')
135+
expect(stale.previewVersion).toBe(12)
136+
})
137+
138+
it('re-processing the SAME delta stream N times yields the content exactly once', () => {
139+
// Simulates a client re-render/re-subscribe replaying the stream: the accumulated text must be stable.
140+
const deltas = [
141+
{ v: 1, c: 'A' },
142+
{ v: 2, c: 'B' },
143+
{ v: 3, c: 'C' },
144+
]
145+
const run = (start: FilePreviewSession | undefined) =>
146+
deltas.reduce<FilePreviewSession | undefined>(
147+
(acc, d) =>
148+
deriveFilePreviewSession(
149+
acc,
150+
{
151+
previewPhase: 'file_preview_content',
152+
toolCallId: 'tool-1',
153+
toolName: 'workspace_file',
154+
content: d.c,
155+
contentMode: 'delta',
156+
previewVersion: d.v,
157+
fileName: 'deck.pptx',
158+
},
159+
'stream-1',
160+
NOW
161+
),
162+
start
163+
)
164+
const first = run(undefined)
165+
expect(first?.previewText).toBe('ABC')
166+
const second = run(first) // replay the exact same events
167+
expect(second?.previewText).toBe('ABC') // NOT 'ABCABC'
168+
const third = run(second)
169+
expect(third?.previewText).toBe('ABC')
170+
})
171+
98172
it('replaces text on a snapshot and carries forward prior fileId', () => {
99173
const prev = session({ previewText: 'old', fileId: 'file-9', previewVersion: 4 })
100174
const next = deriveFilePreviewSession(

apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,23 @@ export function deriveFilePreviewSession(
7171
return base
7272

7373
case 'file_preview_content': {
74+
const incomingVersion =
75+
typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion)
76+
? payload.previewVersion
77+
: (prev?.previewVersion ?? 0) + 1
78+
// Replay-safe accumulation. A content event may be re-delivered or re-processed (a client
79+
// re-render/re-subscribe, or a stream replay); `previewVersion` is monotonic per tool call, so only
80+
// apply when it STRICTLY advances. Without this guard a re-delivered `delta` double-appends (the
81+
// duplicated-tail bug) and a replayed older `snapshot` regresses the text. `base` already carries
82+
// `prev.previewText`, so an ignored replay leaves the accumulated text untouched.
83+
if (prev && incomingVersion <= prev.previewVersion) {
84+
return { ...base, status: 'streaming' }
85+
}
7486
const previewText =
7587
payload.contentMode === 'delta'
7688
? (prev?.previewText ?? '') + payload.content
7789
: payload.content
78-
const previewVersion =
79-
typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion)
80-
? payload.previewVersion
81-
: (prev?.previewVersion ?? 0) + 1
82-
return { ...base, status: 'streaming', previewText, previewVersion }
90+
return { ...base, status: 'streaming', previewText, previewVersion: incomingVersion }
8391
}
8492

8593
case 'file_preview_complete':

0 commit comments

Comments
 (0)