Skip to content

Commit 240e314

Browse files
committed
fix(files): apply agent stream as a true CRDT peer + guard base-less snapshots
Review round 1 (Greptile P1s): - apply the stream against a private shadow replica (seeded from the live doc at stream start) and relay only the agent's own delta into the shared doc, so a concurrent peer edit to a region the agent snapshot didn't include is no longer reverted (previously the whole-body reconcile deleted it) - gate append snapshots on "must extend the base": a base-less append fragment (emitted before the base loads) can no longer reconcile the seeded doc to a wipe; patch still legitimately replaces a mid-region - gate the apply on collabReady so diffs never land on an unseeded doc; keep the placeholder visible until the seed swaps in - plumb streamOperation through the preview surfaces to drive the append gate - add a peer-edit-preservation test (fails under whole-body reconcile) and refresh the undo-isolation + broadcast tests for the session API
1 parent ae8c7a5 commit 240e314

5 files changed

Lines changed: 152 additions & 55 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ interface FileViewerProps {
108108
streamingContent?: string
109109
isAgentEditing?: boolean
110110
streamIsIncremental?: boolean
111+
streamOperation?: string
111112
disableStreamingAutoScroll?: boolean
112113
previewContextKey?: string
113114
/**
@@ -150,6 +151,7 @@ function FileViewerContent({
150151
streamingContent,
151152
isAgentEditing,
152153
streamIsIncremental,
154+
streamOperation,
153155
disableStreamingAutoScroll = false,
154156
previewContextKey,
155157
collaborative,
@@ -196,6 +198,7 @@ function FileViewerContent({
196198
streamingContent={streamingContent}
197199
isAgentEditing={isAgentEditing}
198200
streamIsIncremental={streamIsIncremental}
201+
streamOperation={streamOperation}
199202
disableStreamingAutoScroll={disableStreamingAutoScroll}
200203
previewContextKey={previewContextKey}
201204
collaborative={collaborative}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest'
66
import { Awareness } from 'y-protocols/awareness'
77
import * as Y from 'yjs'
88
import { createMarkdownEditorExtensions } from '../editor-extensions'
9-
import { applyStreamedMarkdownToLiveDoc } from './apply-streamed-markdown'
9+
import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown'
1010

1111
beforeAll(() => {
1212
// jsdom does not implement elementFromPoint; the Placeholder extension's viewport tracking calls it
@@ -48,11 +48,13 @@ function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) {
4848
return t
4949
}
5050

51-
describe('applyStreamedMarkdownToLiveDoc', () => {
51+
describe('agent-stream applier', () => {
5252
it('streams agent content into the live collaborative doc and broadcasts it as Yjs ops', () => {
5353
const { editor, doc } = track(makeCollabEditor())
5454

55-
expect(applyStreamedMarkdownToLiveDoc(editor, '# Title\n\nHello world.')).toBe(true)
55+
const session = beginAgentStream(editor)
56+
expect(session).not.toBeNull()
57+
expect(applyAgentStreamFrame(editor, session!, '# Title\n\nHello world.')).toBe(true)
5658
expect(editor.getText()).toContain('Hello world')
5759

5860
// The write lands as ops on the shared doc, so any peer receives it (this is what makes a
@@ -61,25 +63,27 @@ describe('applyStreamedMarkdownToLiveDoc', () => {
6163
Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc))
6264
expect(peer.getXmlFragment('default').toString()).toContain('Hello world')
6365
peer.destroy()
66+
endAgentStream(session!)
6467
})
6568

66-
it('returns false when the doc is not yet bound (no ySync binding)', () => {
67-
// A plain editor with no collaboration has no ySync binding, so the applier reports "not ready"
68-
// rather than throwing — the streaming tick re-arms until the doc is seeded.
69+
it('beginAgentStream returns null when the editor has no ySync binding', () => {
70+
// A plain editor with no collaboration has no ySync binding, so a stream cannot start against it.
6971
const editor = new Editor({
7072
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
7173
content: '',
7274
})
7375
teardown.push(() => editor.destroy())
74-
expect(applyStreamedMarkdownToLiveDoc(editor, '# Nope')).toBe(false)
76+
expect(beginAgentStream(editor)).toBeNull()
7577
})
7678

7779
it('keeps agent-streamed ops out of the undo stack while user edits stay undoable', () => {
7880
const { editor } = track(makeCollabEditor())
7981

80-
applyStreamedMarkdownToLiveDoc(editor, '# Streamed\n\nAgent wrote this.')
81-
// The streamed op used AGENT_STREAM_ORIGIN, which the Collaboration UndoManager does not track —
82-
// so there is nothing to undo, and an undo must not revert the agent's content.
82+
const session = beginAgentStream(editor)!
83+
applyAgentStreamFrame(editor, session, '# Streamed\n\nAgent wrote this.')
84+
endAgentStream(session)
85+
// The streamed op relayed under a non-`ySyncPluginKey` origin, which the Collaboration UndoManager
86+
// does not track — so there is nothing to undo, and an undo must not revert the agent's content.
8387
expect(editor.can().undo()).toBe(false)
8488
editor.commands.undo()
8589
expect(editor.getText()).toContain('Agent wrote this')
@@ -95,31 +99,38 @@ describe('applyStreamedMarkdownToLiveDoc', () => {
9599
expect(editor.getText()).toContain('Agent wrote this')
96100
})
97101

98-
it('merges an agent write with a concurrent peer edit (minimal diff, no clobber)', () => {
102+
it('preserves a concurrent peer edit to a region the agent snapshot does not include', () => {
103+
// This is the core "AI as a CRDT peer" guarantee: the agent relays only its OWN delta (computed
104+
// against a private shadow), never a whole-document reconcile that would revert a peer's edit.
99105
const { editor, doc } = track(makeCollabEditor())
100-
applyStreamedMarkdownToLiveDoc(editor, 'Alpha paragraph.\n\nBeta paragraph.')
101-
102-
// A peer forks the current state and edits the FIRST paragraph directly on the shared type…
103-
const remote = new Y.Doc()
104-
Y.applyUpdate(remote, Y.encodeStateAsUpdate(doc))
105-
const remoteFrag = remote.getXmlFragment('default')
106-
remote.transact(() => {
107-
const firstPara = remoteFrag.get(0) as Y.XmlElement
106+
107+
const session = beginAgentStream(editor)!
108+
applyAgentStreamFrame(editor, session, 'Alpha paragraph.\n\nBeta paragraph.')
109+
110+
// A peer edits the FIRST paragraph directly on the shared doc — the agent's later snapshot still
111+
// carries the ORIGINAL first paragraph (it was built from the base, before this edit).
112+
const peer = new Y.Doc()
113+
Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc))
114+
const peerFrag = peer.getXmlFragment('default')
115+
peer.transact(() => {
116+
const firstPara = peerFrag.get(0) as Y.XmlElement
108117
const textNode = firstPara.get(0) as Y.XmlText
109118
textNode.insert(textNode.toString().length, ' EDITED')
110119
})
120+
Y.applyUpdate(doc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(doc)))
121+
peer.destroy()
111122

112-
// …while the agent rewrites the SECOND paragraph through the live editor binding.
113-
applyStreamedMarkdownToLiveDoc(editor, 'Alpha paragraph.\n\nBeta paragraph, expanded.')
114-
115-
// Exchange updates both ways (as the relay would); a full-document replace would have clobbered
116-
// the peer's concurrent edit — a minimal `updateYFragment` diff preserves both.
117-
Y.applyUpdate(doc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(doc)))
118-
Y.applyUpdate(remote, Y.encodeStateAsUpdate(doc, Y.encodeStateVector(remote)))
119-
120-
const merged = doc.getXmlFragment('default').toString()
121-
expect(merged).toContain('EDITED')
122-
expect(merged).toContain('expanded')
123-
remote.destroy()
123+
// The agent appends a third paragraph. Its snapshot's first paragraph is the stale original, but the
124+
// shadow-relayed delta only inserts the new paragraph — so the peer's " EDITED" must survive.
125+
applyAgentStreamFrame(
126+
editor,
127+
session,
128+
'Alpha paragraph.\n\nBeta paragraph.\n\nGamma paragraph.'
129+
)
130+
endAgentStream(session)
131+
132+
const live = doc.getXmlFragment('default').toString()
133+
expect(live).toContain('EDITED')
134+
expect(live).toContain('Gamma paragraph')
124135
})
125136
})
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { Editor } from '@tiptap/core'
22
import { Node as PMNode } from '@tiptap/pm/model'
3-
import { updateYFragment, ySyncPluginKey } from '@tiptap/y-tiptap'
3+
import { initProseMirrorDoc, updateYFragment, ySyncPluginKey } from '@tiptap/y-tiptap'
4+
import * as Y from 'yjs'
45
import { parseMarkdownToDoc } from '../markdown-parse'
56

7+
/** The Yjs fragment name TipTap's Collaboration extension binds to (its default `field`). */
8+
const COLLAB_DOC_FIELD = 'default'
9+
610
/**
711
* Transaction origin for agent-streamed writes into a live collaborative doc. It is deliberately NOT
812
* the `ySyncPluginKey` origin that local user edits use, so the Collaboration UndoManager — which
@@ -11,22 +15,64 @@ import { parseMarkdownToDoc } from '../markdown-parse'
1115
const AGENT_STREAM_ORIGIN = Symbol('agent-stream')
1216

1317
/**
14-
* Apply a streamed markdown body into the editor's live collaborative Y.Doc as a minimal CRDT diff.
15-
*
16-
* Uses the running `ySyncPlugin` binding's {@link updateYFragment} — the same primitive TipTap runs on
17-
* every keystroke — so only the delta between the doc's current content and `body` is written, never a
18-
* full-document replace that would wipe collaborators. Each diff is a small Yjs op that renders locally
19-
* (via the binding's observer, the remote-edit render path) AND broadcasts to every peer, so the stream
20-
* is smooth here and on other clients alike. Runs under {@link AGENT_STREAM_ORIGIN} so the streamed ops
21-
* stay out of the user's undo stack. Returns `false` when the editor has no live ySync binding (e.g. a
22-
* non-collaborative editor); the caller gates seed-readiness separately via `collabReady`.
18+
* A private Yjs replica the agent stream reconciles against, so a stream writes into the live doc as a
19+
* TRUE peer: only the agent's own delta reaches the shared doc, never a whole-document reconcile that
20+
* would revert a collaborator's concurrent edit. Seeded from the live doc at stream start; it receives
21+
* ONLY agent reconciles (never peer updates), so `shadow → nextTarget` yields exactly the agent's change.
22+
*/
23+
export interface AgentStreamSession {
24+
shadow: Y.Doc
25+
fragment: Y.XmlFragment
26+
}
27+
28+
/**
29+
* Begin an agent stream by snapshotting the live doc into a private shadow replica. Returns `null` when
30+
* the editor has no live ySync binding (e.g. a non-collaborative editor).
31+
*/
32+
export function beginAgentStream(editor: Editor): AgentStreamSession | null {
33+
const binding = ySyncPluginKey.getState(editor.state)?.binding
34+
if (!binding) return null
35+
const shadow = new Y.Doc()
36+
Y.applyUpdate(shadow, Y.encodeStateAsUpdate(binding.doc))
37+
return { shadow, fragment: shadow.getXmlFragment(COLLAB_DOC_FIELD) }
38+
}
39+
40+
/**
41+
* Apply one streamed markdown body. Reconciles the shadow toward `body` with `updateYFragment` (the same
42+
* minimal-diff primitive TipTap runs per keystroke), captures ONLY the resulting agent delta, and relays
43+
* it into the live doc under {@link AGENT_STREAM_ORIGIN}. Because the shadow never sees peer updates, the
44+
* delta touches only what the agent changed — so concurrent peer edits elsewhere in the live doc survive,
45+
* the change renders locally (via the binding's observer, the remote-edit path), broadcasts to every
46+
* peer, and stays out of the user's undo stack. Returns `false` when the editor has no live ySync binding.
2347
*/
24-
export function applyStreamedMarkdownToLiveDoc(editor: Editor, body: string): boolean {
48+
export function applyAgentStreamFrame(
49+
editor: Editor,
50+
session: AgentStreamSession,
51+
body: string
52+
): boolean {
2553
const binding = ySyncPluginKey.getState(editor.state)?.binding
2654
if (!binding) return false
2755
const target = PMNode.fromJSON(editor.schema, parseMarkdownToDoc(body))
28-
binding.doc.transact(() => {
29-
updateYFragment(binding.doc, binding.type, target, binding)
30-
}, AGENT_STREAM_ORIGIN)
56+
let delta: Uint8Array | null = null
57+
const capture = (update: Uint8Array, origin: unknown) => {
58+
if (origin === AGENT_STREAM_ORIGIN) delta = update
59+
}
60+
session.shadow.on('update', capture)
61+
try {
62+
session.shadow.transact(() => {
63+
// `updateYFragment` diffs against the fragment's CURRENT content, so it needs the fragment↔PM
64+
// binding metadata; `initProseMirrorDoc` reconstructs it from the fragment's present state.
65+
const { meta } = initProseMirrorDoc(session.fragment, editor.schema)
66+
updateYFragment(session.shadow, session.fragment, target, meta)
67+
}, AGENT_STREAM_ORIGIN)
68+
} finally {
69+
session.shadow.off('update', capture)
70+
}
71+
if (delta) Y.applyUpdate(binding.doc, delta, AGENT_STREAM_ORIGIN)
3172
return true
3273
}
74+
75+
/** End an agent stream and free its shadow replica. */
76+
export function endAgentStream(session: AgentStreamSession): void {
77+
session.shadow.destroy()
78+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import type { SaveStatus } from '@/hooks/use-autosave'
2020
import { useFileContentSource } from '@/hooks/use-file-content-source'
2121
import { PreviewLoadingFrame } from '../preview-shared'
2222
import { useEditableFileContent } from '../use-editable-file-content'
23-
import { applyStreamedMarkdownToLiveDoc } from './collaboration/apply-streamed-markdown'
23+
import {
24+
type AgentStreamSession,
25+
applyAgentStreamFrame,
26+
beginAgentStream,
27+
endAgentStream,
28+
} from './collaboration/apply-streamed-markdown'
2429
import { useFileDocCollaboration } from './collaboration/use-file-doc-collaboration'
2530
import { createMarkdownEditorExtensions } from './editor-extensions'
2631
import { findHeadingPos } from './heading-anchors'
@@ -79,14 +84,21 @@ interface RichMarkdownEditorProps {
7984
* applied live; a rebuild is only revealed while it extends what's shown (see the streaming tick).
8085
*/
8186
streamIsIncremental?: boolean
87+
/**
88+
* The agent edit operation driving the stream, when known (`create`/`append`/`update`/`patch`). Used
89+
* only to relax the "must extend" gate for `patch` (which legitimately replaces a mid-document region):
90+
* every other operation's snapshot must extend what's shown, so a base-less `append` fragment can't
91+
* reconcile the live doc to a wipe.
92+
*/
93+
streamOperation?: string
8294
disableStreamingAutoScroll?: boolean
8395
previewContextKey?: string
8496
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
8597
disableTagging?: boolean
8698
/**
8799
* Opt this surface into live collaborative editing (Files page + the embedded chat file preview).
88100
* Collaboration can coexist with agent streaming: while streaming, the growing content is applied to
89-
* the shared Y.Doc as minimal CRDT diffs (see {@link applyStreamedMarkdownToLiveDoc}) rather than a
101+
* the shared Y.Doc as minimal CRDT diffs (see {@link applyAgentStreamFrame}) rather than a
90102
* full-document `setContent`, so the stream stays smooth and every peer sees it live.
91103
*/
92104
collaborative?: boolean
@@ -111,6 +123,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
111123
streamingContent,
112124
isAgentEditing,
113125
streamIsIncremental,
126+
streamOperation,
114127
disableStreamingAutoScroll = false,
115128
previewContextKey,
116129
disableTagging,
@@ -181,6 +194,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
181194
userName={userName}
182195
autoFocus={autoFocus}
183196
streamIsIncremental={streamIsIncremental}
197+
streamOperation={streamOperation}
184198
disableStreamingAutoScroll={disableStreamingAutoScroll}
185199
disableTagging={disableTagging}
186200
collaborative={collaborative}
@@ -206,6 +220,8 @@ interface LoadedRichMarkdownEditorProps {
206220
autoFocus?: boolean
207221
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
208222
streamIsIncremental?: boolean
223+
/** See {@link RichMarkdownEditorProps.streamOperation}. */
224+
streamOperation?: string
209225
disableStreamingAutoScroll?: boolean
210226
disableTagging?: boolean
211227
/** See {@link RichMarkdownEditorProps.collaborative}. */
@@ -239,6 +255,7 @@ export function LoadedRichMarkdownEditor({
239255
userName,
240256
autoFocus,
241257
streamIsIncremental,
258+
streamOperation,
242259
disableStreamingAutoScroll,
243260
disableTagging,
244261
collaborative = false,
@@ -356,6 +373,10 @@ export function LoadedRichMarkdownEditor({
356373
*/
357374
const streamIsIncrementalRef = useRef(streamIsIncremental)
358375
streamIsIncrementalRef.current = streamIsIncremental
376+
const streamOperationRef = useRef(streamOperation)
377+
streamOperationRef.current = streamOperation
378+
/** The live agent-stream shadow replica, held for the current stream and freed on settle/unmount. */
379+
const agentStreamSessionRef = useRef<AgentStreamSession | null>(null)
359380
const router = useRouter()
360381
const routerRef = useRef(router)
361382
routerRef.current = router
@@ -855,7 +876,11 @@ export function LoadedRichMarkdownEditor({
855876
}
856877
const shownBody = lastSyncedBodyRef.current
857878
const extendsShown = shownBody === null || pending.startsWith(shownBody)
858-
if (!streamIsIncrementalRef.current && !extendsShown) {
879+
// Every snapshot except a mid-document `patch` must EXTEND what's shown: a from-scratch rebuild
880+
// (`create`/`update`) is only revealed as it grows, and an `append` snapshot that doesn't extend
881+
// the base is a base-less fragment (the server emits one before the base loads) which would
882+
// reconcile the seeded doc down to a wipe. Only `patch` legitimately replaces a mid-region.
883+
if (!extendsShown && streamOperationRef.current !== 'patch') {
859884
streamRafRef.current = null
860885
return
861886
}
@@ -868,9 +893,11 @@ export function LoadedRichMarkdownEditor({
868893
}
869894
const el = containerRef.current
870895
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
896+
agentStreamSessionRef.current ??= beginAgentStream(editor)
897+
const session = agentStreamSessionRef.current
871898
// Defensive: a ready collab editor always has a ySync binding, so this applies; if one is
872899
// somehow absent, bail this frame without advancing rather than looping.
873-
if (!applyStreamedMarkdownToLiveDoc(editor, pending)) {
900+
if (!session || !applyAgentStreamFrame(editor, session, pending)) {
874901
streamRafRef.current = null
875902
return
876903
}
@@ -892,13 +919,15 @@ export function LoadedRichMarkdownEditor({
892919
if (wasStreamingRef.current && collabReady) {
893920
wasStreamingRef.current = false
894921
const finalBody = splitFrontmatter(content).body
895-
if (finalBody !== lastSyncedBodyRef.current) {
896-
runOffRender(() => {
897-
if (applyStreamedMarkdownToLiveDoc(editor, finalBody)) {
922+
const session = agentStreamSessionRef.current
923+
agentStreamSessionRef.current = null
924+
runOffRender(() => {
925+
if (session && finalBody !== lastSyncedBodyRef.current) {
926+
if (applyAgentStreamFrame(editor, session, finalBody))
898927
lastSyncedBodyRef.current = finalBody
899-
}
900-
})
901-
}
928+
}
929+
if (session) endAgentStream(session)
930+
})
902931
}
903932
return
904933
}
@@ -1002,6 +1031,10 @@ export function LoadedRichMarkdownEditor({
10021031
useEffect(
10031032
() => () => {
10041033
if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current)
1034+
if (agentStreamSessionRef.current) {
1035+
endAgentStream(agentStreamSessionRef.current)
1036+
agentStreamSessionRef.current = null
1037+
}
10051038
},
10061039
[]
10071040
)

0 commit comments

Comments
 (0)