Skip to content

Commit 6b23e8a

Browse files
committed
fix(files): agent stream frames skip the relay's durable persist
Cursor round 1 (High): client-applied stream frames broadcast over the sync channel, so the relay stamped a socket origin and ran schedulePersist — durably writing partial agent content mid-stream, attributed to the watching user (the old server-merge applied with no origin and never did). Restore that behavior: - new FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST wire tag; the provider tags AGENT_STREAM_ORIGIN updates with it (normal user edits stay SYNC) - the relay applies it under an AgentSyncOrigin (carries the socket id for broadcast exclusion, but is not a plain string) so originSocketId() is null → no edited/schedulePersist/lastEditorUserId; excludeSocketId() still excludes the sender, and the update still publishes to the stream so peers converge - the copilot's final edit_content write remains the authoritative durable persist - tests: relay applies+fans-out but never persists a SYNC_NO_PERSIST frame (verified it fails if applied as a socket edit); provider tags agent edits
1 parent 7b2bc18 commit 6b23e8a

6 files changed

Lines changed: 102 additions & 5 deletions

File tree

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,36 @@ describe('setupWorkspaceFileDocHandlers', () => {
299299
expect(mockFetchFileDocPersist).toHaveBeenCalled()
300300
})
301301

302+
it('applies + fans out an agent-streamed frame (SYNC_NO_PERSIST) but never persists it', async () => {
303+
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
304+
const { io, sent } = createIo()
305+
const { handlers } = setup('socket-1', io)
306+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
307+
await flushMicrotasks()
308+
309+
const before = sent.length
310+
const edit = new Y.Doc()
311+
edit.getText(FILE_DOC_FIELD).insert(0, 'agent streamed this')
312+
handlers[FILE_DOC_EVENTS.MESSAGE](
313+
frame(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST, (e) =>
314+
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
315+
)
316+
)
317+
await flushMicrotasks()
318+
319+
// It fans out to the room excluding the sender, so a collaborator sees the stream live...
320+
const fanout = sent
321+
.slice(before)
322+
.filter((m) => m.event === FILE_DOC_EVENTS.MESSAGE && m.except === 'socket-1')
323+
expect(fanout.length).toBeGreaterThan(0)
324+
325+
// ...but it must NOT mark the doc dirty: a last-disconnect flush never persists agent content (the
326+
// copilot's final edit_content write is the authoritative durable persist).
327+
cleanupFileDocForSocket('socket-1', io, true)
328+
await flushMicrotasks()
329+
expect(mockFetchFileDocPersist).not.toHaveBeenCalled()
330+
})
331+
302332
it('stops on a persist conflict without clobbering (single attempt, durable left authoritative)', async () => {
303333
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
304334
// A persist reports an out-of-band change (If-Match conflict). The relay must NOT re-persist against

apps/realtime/src/handlers/file-doc.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,25 @@ function originSocketId(origin: unknown): string | null {
177177
return typeof origin === 'string' ? origin : null
178178
}
179179

180+
/**
181+
* The transaction origin stamped on an agent-streamed frame (a {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}
182+
* apply). It carries the emitting socket id for broadcast exclusion, but is deliberately NOT a plain
183+
* string — so `originSocketId` returns `null` for it and the update never triggers `edited`/`schedulePersist`
184+
* (the copilot's final `edit_content` write is the durable persist).
185+
*/
186+
interface AgentSyncOrigin {
187+
readonly agentSocketId: string
188+
}
189+
190+
function isAgentSyncOrigin(origin: unknown): origin is AgentSyncOrigin {
191+
return typeof origin === 'object' && origin !== null && 'agentSocketId' in origin
192+
}
193+
194+
/** The socket id to exclude when relaying an update — a client socket edit OR an agent-streamed frame. */
195+
function excludeSocketId(origin: unknown): string | null {
196+
return originSocketId(origin) ?? (isAgentSyncOrigin(origin) ? origin.agentSocketId : null)
197+
}
198+
180199
/**
181200
* Broadcast an AWARENESS frame to the room ACROSS tasks via the Socket.IO Redis adapter. Awareness
182201
* (cursors/selection) is ephemeral and needs no convergence or replay, so the adapter's cross-task
@@ -684,9 +703,10 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
684703
const encoder = encoding.createEncoder()
685704
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
686705
syncProtocol.writeUpdate(encoder, update)
687-
// Fan out to THIS task's clients only (excluding the origin socket if local). Cross-task delivery
688-
// rides the shared stream — every task's tailer applies + runs its own local fan-out.
689-
broadcastLocal(io, name, encoding.toUint8Array(encoder), originSocketId(origin))
706+
// Fan out to THIS task's clients only (excluding the origin socket if local — a user edit OR an
707+
// agent-streamed frame). Cross-task delivery rides the shared stream — every task's tailer applies +
708+
// runs its own local fan-out.
709+
broadcastLocal(io, name, encoding.toUint8Array(encoder), excludeSocketId(origin))
690710
// Share every locally-originated update to the stream so peers converge. Skip updates that already
691711
// came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN) and SEED_ORIGIN — the seed is published
692712
// EXPLICITLY and AWAITED under the seed lock (so it lands before the lock releases), which a
@@ -781,6 +801,22 @@ function handleMessage(socket: AuthenticatedSocket, data: unknown) {
781801
}
782802
break
783803
}
804+
case FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST: {
805+
// An agent-streamed frame: apply + fan out to peers (so a collaborator sees the stream live) but
806+
// do NOT treat it as a durable user edit. Unlike SYNC, we do NOT set `lastEditorUserId`, and the
807+
// apply uses an {@link AgentSyncOrigin} (not the bare socket id) so `originSocketId` is `null` in
808+
// `doc.on('update')` — skipping `edited`/`schedulePersist`. `excludeSocketId` still reads the
809+
// carried socket id, so the sender is excluded from the relay fan-out. The copilot's final
810+
// `edit_content` write remains the authoritative durable persist.
811+
const encoder = encoding.createEncoder()
812+
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
813+
const agentOrigin: AgentSyncOrigin = { agentSocketId: socket.id }
814+
syncProtocol.readSyncMessage(decoder, encoder, room.doc, agentOrigin)
815+
if (encoding.length(encoder) > 1) {
816+
socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder))
817+
}
818+
break
819+
}
784820
case FILE_DOC_MESSAGE_TYPE.AWARENESS: {
785821
const update = decoding.readVarUint8Array(decoder)
786822
// Enforce presence ownership: a socket may only publish/remove awareness

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const COLLAB_DOC_FIELD = 'default'
1212
* the `ySyncPluginKey` origin that local user edits use, so the Collaboration UndoManager — which
1313
* tracks only `ySyncPluginKey` — excludes streamed ops from the user's undo stack.
1414
*/
15-
const AGENT_STREAM_ORIGIN = Symbol('agent-stream')
15+
export const AGENT_STREAM_ORIGIN = Symbol('agent-stream')
1616

1717
/**
1818
* A private Yjs replica the agent stream reconciles against, so a stream writes into the live doc as a

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
1212
import * as awarenessProtocol from 'y-protocols/awareness'
1313
import * as syncProtocol from 'y-protocols/sync'
1414
import * as Y from 'yjs'
15+
import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown'
1516
import { FileDocProvider } from './file-doc-provider'
1617

1718
/** A minimal fake Socket.IO client whose server→client events can be fired in tests. */
@@ -125,6 +126,19 @@ describe('FileDocProvider', () => {
125126
expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC)
126127
})
127128

129+
it('tags agent-streamed edits as SYNC_NO_PERSIST so the relay skips the durable persist', () => {
130+
const { doc, emit } = createProvider(true)
131+
emit.mockClear()
132+
133+
// An agent-streamed frame is applied under AGENT_STREAM_ORIGIN; it must still reach the server (peers
134+
// see it live) but as SYNC_NO_PERSIST, so the relay fans it out without treating it as a user edit.
135+
doc.transact(() => doc.getText('default').insert(0, 'agent'), AGENT_STREAM_ORIGIN)
136+
137+
const messages = emittedMessages(emit)
138+
expect(messages.length).toBe(1)
139+
expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST)
140+
})
141+
128142
it('does not echo updates it applied from the server', () => {
129143
const { provider, emit, fire } = createProvider(true)
130144
fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId: 'file-1' })

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Socket } from 'socket.io-client'
1414
import * as awarenessProtocol from 'y-protocols/awareness'
1515
import * as syncProtocol from 'y-protocols/sync'
1616
import type * as Y from 'yjs'
17+
import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown'
1718

1819
/**
1920
* Events emitted by {@link FileDocProvider}.
@@ -276,8 +277,15 @@ export class FileDocProvider extends ObservableV2<FileDocProviderEvents> {
276277
if (this.fatal) return
277278
// Updates we applied from the server carry `this` as origin — don't echo them.
278279
if (origin === this) return
280+
// Agent-streamed frames must reach peers (so a collaborator sees the stream live) but must NOT be
281+
// treated by the server as a durable user edit — the copilot's final `edit_content` write is the
282+
// authoritative persist. Tag them so the relay applies + fans out but skips persist bookkeeping.
283+
const messageType =
284+
origin === AGENT_STREAM_ORIGIN
285+
? FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST
286+
: FILE_DOC_MESSAGE_TYPE.SYNC
279287
const encoder = encoding.createEncoder()
280-
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
288+
encoding.writeVarUint(encoder, messageType)
281289
syncProtocol.writeUpdate(encoder, update)
282290
this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder))
283291
}

packages/realtime-protocol/src/file-doc.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export const FILE_DOC_EVENTS = {
4242
export const FILE_DOC_MESSAGE_TYPE = {
4343
SYNC: 0,
4444
AWARENESS: 1,
45+
/**
46+
* Client → server: a Yjs sync UPDATE (same framing as {@link FILE_DOC_MESSAGE_TYPE.SYNC}) that the
47+
* server must apply and fan out to peers WITHOUT treating it as a durable user edit — no
48+
* `schedulePersist`, no `edited`/`lastEditorUserId` bookkeeping. Used for agent-streamed frames: the
49+
* copilot's final `edit_content` write is the authoritative durable persist, so the live stream must
50+
* not also durably write partial content (attributed to the watching user). The server never sends
51+
* this type; replies always use {@link FILE_DOC_MESSAGE_TYPE.SYNC}.
52+
*/
53+
SYNC_NO_PERSIST: 2,
4554
} as const
4655

4756
export type FileDocMessageType = (typeof FILE_DOC_MESSAGE_TYPE)[keyof typeof FILE_DOC_MESSAGE_TYPE]

0 commit comments

Comments
 (0)