read_changes: the blocks that moved, not the whole document again (#87) - #112
Merged
Merged
Conversation
`document.changed` carries a doc id and an actor, so an agent polling a long document pulled the full markdown into context every time to find one edit. `read_changes(doc_id, cursor)` returns the blocks that changed since its cursor, each with its anchor and its markdown as they are now, plus the ids of blocks that left. The cursor is its own sequence, not the events one. `doc_changed` is digested to one event per 30 seconds while blocks change on every keystroke, so a block change has no event seq of its own to borrow, and sharing the namespace would silently drop every change that landed between digests. `DocumentAgent` keeps a `block_changes` table appended by the fragment observer. The observer now maintains a set of the fragment's block ids ahead of every guard it has, for two reasons: a deleted block's own attributes are no longer readable from the Yjs event, so the set is the only place its id survives; and a system write like a restore fires no event, which must not read to a poller as the document holding still. Rows are written only while an agent is on the roster, capped at 5,000, and pruned with the document. A caller with no cursor, or one older than the rows still kept, is told `truncated` and reads the whole document: there is no baseline to diff against. Blocks in documents written before block ids have no id to track and never appear. `app/shared/block-changes.ts` holds the pure part, which is what the unit tests exercise; the observer, the table, and the RPC are covered by the DocumentAgent integration tests, including the deleted-block and system-write cases that drove the design. Closes #87 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #87.
document.changedcarries a doc id and an actor and nothing else, so an agent polling a long document pulled the full markdown into context every time to find one edit.read_changes(doc_id, cursor)returns the blocks that changed since its cursor, each with its anchor and its markdown as they are now, plus the ids of blocks that left.Its cursor is its own
The issue asked for the events cursor. It cannot be:
doc_changedis digested to one event per 30 seconds while blocks change on every keystroke, so a block change has no event seq to borrow. Sharing the namespace would silently drop every change that landed between digests, which is the exact failure the tool exists to fix. The tool's description says the cursor is its own.What the observer had to learn
DocumentAgentkeeps ablock_changestable appended by the fragment observer. The observer now maintains a set of the fragment's block ids ahead of every guard it has, for two reasons that only came out of running it:event.changes.deletedgives the item, but its attributes are no longer readable, so the id is gone by the time the observer sees it. The id set is the only place it survives. The first attempt read the id off the deleted item and the integration test failed withexpected [] to deeply equal ['nyboimy7']."agent"origin and deliberately fire no event. If block tracking sat behind that guard, an agent polling across a version restore would be told the document had not moved. There is a test for it.Limits, stated in the tool
truncatedand a cursor to start from. There is no baseline to diff against, so the honest answer is "read the document".truncated.eventsalready behaves, and are dropped with the document at expiry.Cost
The id set is refreshed on every observer firing, including for documents with no agents, where the old code did one roster query and returned. That is
frag.toArray()plus one attribute lookup per top-level block. It is far below the mention scan that already runs per touched block (each of which serialises the whole document throughgetBlocks), but it is new work on the agentless path, and I have not measured it.Tests
app/shared/block-changes.tsholds the pure part: 11 unit tests over collapsing, ordering, added-versus-changed, deleted-and-re-added, and both truncation cases. The observer, the table, and the RPC are covered by seven newDocumentAgentintegration tests, including the deleted-block and system-write cases above.The SQL fake in the integration harness gained autoincrement for
block_changesand</>in DELETE, which the trim uses.953 tests run, 937 pass. The 16 failures are the three localStorage suites on Node 25, which fail identically on
mainand are recorded inCLAUDE.mdas an environment issue. Lint and typecheck clean.Not verified: no agent has driven this over a live MCP connection. It is exercised through the DO's own RPC, not through the transport.
🤖 Generated with Claude Code