Repository navigation
[lexical-website][lexical-y] Documentation Update: Check an agent's edit on the current state, then apply it there - #9688
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
cff0be5 to
17995be
Compare
17995be to
c5bc426
Compare
c5bc426 to
4a85590
Compare
…dit on the current state, then apply it there ## Description y.md's "Edits made by code" told integrators to apply an agent's edit to the state it was computed from and merge the result into the live document. That always converges, but for an edit addressed by text or by block id it converges to text nobody meant whenever the document changed under the edit. Measured on `@lexical/y` with `seedYState`, `editYState` and `readYState` (15 cases: a person's or a second agent's edit lands between the agent's read and its edit): | Case | Merging the edit made on the read | Check, then apply to the current state | | --- | --- | --- | | a collaborator replaced the same words | `The slowswift red fox` | refused (words changed) | | a collaborator made the same change | `swiftswift` | refused | | two agents, different words | `at risblocked` | refused | | two agents, the same edit | `4.2.2%` | refused | | a collaborator typed elsewhere in the block | right | right | | the words now also appear in another block | right | right (checked in the read's block) | | the line was moved | new text where the line was; the moved line keeps the old text | right (block found by id) | | words cut and pasted elsewhere | `The swift redfox jumps.` | refused | | a block rewrite as a new node while a collaborator types in it | their text moved to the start | refused | | a block delete while a collaborator types in it | their sentence glued to the next line | refused | | a count outside the edited words | wrong | wrong (only the agent can catch it) | Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2 edits that would have merged well. This is how text-matching edit tools behave (Anthropic's `str_replace` and OpenAI's `apply_patch` match the current text and fail on a miss; Claude Code refuses a write to a file changed since its read; the Google Docs API's `requiredRevisionId` and TipTap's server toolkit refuse a stale write so the caller re-reads). Now: - y.md, "Edits made by code": **Check what an edit names, then apply it to the current state**: a replacement checks its own words in the block where it read them, found there by id (still there, exactly once); a rewrite or a delete checks the whole block (where it is, and its type, text, formats, style, NodeState and children, compared serialized, not only its text, as the checking schema declares them: run the check on a schema that still declares every field stored versions may hold, or a change to a dropped field goes unnoticed); a format or a comment on words the edit names checks those words like a replacement and is applied where they are found on the current state, never by offsets from the read; an insert checks its anchor block. Every edit of a batch is checked before any is applied, then again, in order, as it is applied on the state the earlier ones leave: an edit an earlier one changes (its words changed or now appear twice, its block deleted or rewritten, a block named twice) refuses the whole batch, a conservative rule. On a refusal, apply none, and return every failure (which edit, the reason, and the block's content in the latest stored state, not in the discarded edit; edits after an in-order failure are still checked) and a new read. On a pass, apply to the current state (blocks found by id there) and store with a compare-and-set; on a lost race, drop the state and the update computed on the older state and run the whole check and edit again on the newer one (bounded), or refuse back to the agent with a read of the state that won the last race: an update computed on an older state is never stored or sent. **Merge from the read only where there is nothing to check** (an append, a suggestion a person accepts, an edit by position): the fork-and-merge recipe stays for those cases. - y-server.md, "Edits by agents and tools" and "Edits by block id": the sketches check on the latest stored state and run `editYState` there, through `applyChecked` (the check; the edit, which checks each part again as it applies it and can refuse; the compare-and-set; up to 3 attempts, then a fresh load), instead of editing `seen` and merging. The word sketch takes a batch of replacements. `$findWords` finds a replacement's words by the block's id and requires them exactly once in that block's text, in one plain text node; `$blockAt` compares a target as a whole (its path and its serialized subtree); an answer by id is checked op by op and applied whole or not at all. "One edit at a time" says a checked edit starts over on a lost race instead of merging. `ServerDocsAgentEdits.test.ts` runs the sketches as written (they live in `__tests__/utils/agentEditSketch.ts`, and the first test checks that the page holds the same code), including targets changed since the read, a batch with one failing op, a batch whose first edit changes what the second (and third) names, refusals that report the stored content, a format on named words applied where they are now, and a store that loses the race. - block-ids.md, "Handles and collaboration": the handle is resolved and the whole block checked against what the model read on the stored state, and the edit is applied there by id with `applyChecked`; `BlockHandles.test.ts` follows it (a block the person typed in now goes back as `changed`). - DECISIONS.md: D94 (decided); D71 says D94 replaced which state the edit runs on. Also, from the reviews of #9685: - y.md, Servers: the per-client order paragraph now opens "Causal order includes each client's own order", and the json-joy comparison is dropped (y.md and D92). - D93 and y.md name the orders: one order when the comment ends at the rewritten word, both when it continues past it. - `regressions/UndoInvisibleSteps.test.ts` (from #9630) pins D81: the 3 cells that assert a press also taking the next step are `test.fails`. They fail on main with gc off and on. ## Test plan ### Before The new cells against weaker sketches: no uniqueness check, a target compared by its path and text only, and an update computed before a lost race merged into the newer state (the docs' previous recipe): ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts × the sketches are the same in the docs and the tests 13ms × a replacement of words refuses words that appear twice in the block or across nodes, and finds the block read by id 10ms × step 3 refuses the whole answer when a target is gone, moved, replaced, typed in, formatted or styled since the read 42ms × a lost race starts over on the newer state: applied when the check still holds there, refused when not, never the older edit 20ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected '// Your storage of one document: its …' to be '// Your storage of one document: its …' // Object.is equality - Expected + Received AssertionError: expected undefined to deeply equal [ { …(3) } ] - Expected: + Received: AssertionError: formatted: expected [] to deeply equal [ [ 1, 'the block changed' ] ] - Expected + Received ``` An independent review of an earlier head of this branch composed the word check per edit into a batch: each check passed on the stored state, the first edit invalidated the second, and the batch was stored half done; after 3 lost races the read returned was the one that had lost: ``` FAIL > dependent word batch refuses as a whole when an earlier op invalidates a later target - "refused": true, "sent": 0, "stores": 0, "text": "red fox\n\nspacer" + "refused": false, "sent": 1, "stores": 1, "text": "blue fox\n\nspacer" FAIL > dependency that creates another occurrence cannot silently skip the second op - "text": "red fox and hen\n\nspacer" + "text": "red fox and red\n\nspacer" FAIL > retry exhaustion supplies a fresh read after the final known lost race AssertionError: expected 'red fox\n\nxxspacer' to be 'red fox\n\nxxxspacer' ``` A second review found the refusal of such a batch reporting the block's text from the discarded edit (`blue fox and hen` where storage held `red fox and hen`), and only the first of two invalidated edits. Those cases are cells of `ServerDocsAgentEdits.test.ts` now, as is a third review's case, pinned as the documented scope: a field the server's schema dropped is not compared, so a rewrite passes over a collaborator's change to it. `UndoInvisibleSteps.test.ts` unpinned, on main: ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts × undoing a format on text a collaborator deleted takes the step before it too (1,2) × undoing a format on text a collaborator deleted takes the step before it too (2,1) × undoing a comment removal on deleted text takes the format change before it too AssertionError: expected 'one three X{0}[]' to be 'one three{0}[]' Tests 3 failed (3) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/ServerDocsAgentEdits.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-y/src/__tests__/unit/regressions/UndoInvisibleSteps.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Tests 30 passed | 3 expected fail (33) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit (same files) Tests 30 passed | 3 expected fail (33) $ LEXICAL_Y_GC=1 pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts Test Files 225 passed | 2 skipped (227) Tests 18549 passed | 306 expected fail | 130 skipped (18985) $ pnpm run ci-check Found 0 errors ``` With gc off, the same lexical-y run had one failure, `DisposeMemory.test.ts` ("40 editors bound, edited and disposed in a synchronous loop hold no document"), under 24 parallel workers; it passes 5 of 5 alone, and this change touches no code it runs (docs and tests only).
4a85590 to
05d8b69
Compare
There was a problem hiding this comment.
Thanks, this looks good. The checked recipe reads correctly, the sketches on the server page match agentEditSketch.ts (the first test enforces it), and the changed tests pass with gc on and off. Everything below is optional and non-blocking.
- Discarded updates move the agent's writer to a new client id (
packages/lexical-website/docs/collaboration/y-server.md:445and:451).editYStatecommits the writer's position when it returns. So onceapplyCheckeddrops an update (an in-order refusal or a lost race), the next edit on the stored state writes under a new client id and raises awriter-new-client-idwarning, whose text asks callers to store every update it returns. A refused dependent batch followed by a passing edit takes the document from 2 to 3 client ids. For the in-order pass, throw from inside$editand catch it aroundeditYState: the writer stays on its id. For a lost race, "Give the agent its writer" (y-server.md:375-379) could name the cost; D45 inpackages/lexical-y/DECISIONS.mdis the rule about one client id per writer. - Updates already in flight still merge (
y-server.md:565-567, and y.md's "Check what an edit names"). The compare-and-set covers stored states only. A collaborator's update made before the agent's reached them is merged afterwards: onThe quick fox., the agent's checkedquick→slowis stored, then theirquick→swiftarrives and the line readsslowswift. The previous page said this ("one already on its way"); one sentence would keep it. - Overlapping replacements in one batch pass (
y-server.md:477). The in-order recheck only asks whether the words still occur once. So onred fox,[red fox→fox red, red→blue]storesfox blue, and the second edit lands on the first edit's new word. Refusing edits whose ranges overlap in one block during the initial check would match the rule aty-server.md:339-341and D94. - Block IDs sketch (
packages/lexical-website/docs/extensions/block-ids.md:429-438and:450).readholds only the root's children whileshownholds every block's handle, so a list item's handle getschangedon an unchanged document every time. Also,[result.status]drops thecandidatesandreasonthat the status table says to send back. Buildingreadover every id inshownand returning[result]fixes both. - D94 cites a 15-case measurement that exists only in the PR description.
packages/lexical-y/AGENTS.mdasks for a repro or measurement before aDECIDEDrow is reopened, so committing those cases as a test (the merge result beside the check result) would let the row be re-checked later.
| }, | ||
| {writer: agent}, | ||
| ); | ||
| if (failed.length > 0) { |
There was a problem hiding this comment.
Optional: by the time editYState returns, it has committed this update to the writer. Dropping it here (and at the lost race below) makes the next edit write under a new client id, with a writer-new-client-id warning. To keep the writer on its id, throw from inside $edit on an in-order failure and catch it around editYState. The lost-race cost could be named in "Give the agent its writer".
| if (from === '' || at === -1) { | ||
| return 'the words changed'; | ||
| } | ||
| if (all.indexOf(from, at + 1) !== -1) { |
There was a problem hiding this comment.
Optional: this only asks whether the words still occur once. An earlier edit of the batch that rewrites them into text that still contains them once (red fox→fox red, then red→blue) lets the later edit land on the new word (fox blue). Refusing overlapping ranges in the initial check would match the rule at lines 339-341.
| schema that still declares every field the stored versions may hold. Store | ||
| the result with `applyChecked` (above): a compare-and-set on the version | ||
| the check read, and on a lost race the check and the edit again on the | ||
| newer state, so that no change lands between the check and the edit. |
There was a problem hiding this comment.
Optional: the compare-and-set covers stored states only. A collaborator's update made before the agent's reached them still merges afterwards: on The quick fox., the agent's quick→slow is stored, then their quick→swift gives slowswift. The previous page said so ("one already on its way"); one sentence would keep that.
| // shown with it. | ||
| // shown with it, and each block it read as a whole. | ||
| const shown = readYState(seen, config, () => $getBlockHandles()); | ||
| const read = new Map( |
There was a problem hiding this comment.
Optional: read holds only the root's children, while shown has every block's handle, so a list item's handle gets changed on an unchanged document every time. Build read from every id in shown, and return [result] at line 450 so ambiguous keeps its candidates; that fixes both.
…ome guards, D95 moot, gc-on #9623 cells pinned, D51 back to an accepted limit ## Description Rebased on `main` 064b18e (#9688, #9691). Without #9679's rules: - The #9690 cells (`FormatRuleTrade.test.ts`, `FormatRuleTradeSessions.test.ts`) pass: they become plain tests, guards against bringing those rules back as they were. D95 is marked moot (`REVERTED`). - `UndoRestoredRunOverDeletedText.test.ts`: with gc on, the sequential order of six shapes spreads again (the undo cannot find items gc collected): `it.fails` with gc on, titled "(#9623 with gc on)". The #9689 cells are unchanged. - `UndoInvisibleSteps.test.ts` (#9688): the cell that needed a delete to keep the comment removal's null passes again with the collapse; it becomes a plain test, with its comment updated. - D51 goes back to `ACCEPTED LIMIT` (its status before #9679), keeping the D86 sentence and the revert note. ## Test plan ### Before On 064b18e with the revert alone, `FormatRuleTrade*` and one `UndoInvisibleSteps` cell failed as unexpected passes, and twelve gc-on cells of `UndoRestoredRunOverDeletedText` failed. ### After ``` $ LEXICAL_Y_GC=0 vitest --run --project unit packages/lexical-y/src/__tests__/unit/regressions/{UndoInvisibleSteps,UndoRestoredRunOverDeletedText,FormatRuleTrade,FormatRuleTradeSessions}.test.ts Tests 35 passed | 46 expected fail (81) $ LEXICAL_Y_GC=1 (same files) Tests 35 passed | 46 expected fail (81) ```
Description
y.md's "Edits made by code" told integrators to apply an agent's edit to
the state it was computed from and merge the result into the live
document. That always converges, but for an edit addressed by text or by
block id it converges to text nobody meant whenever the document changed
under the edit. Measured on
@lexical/ywithseedYState,editYStateand
readYState(15 cases: a person's or a second agent's edit landsbetween the agent's read and its edit):
The slowswift red foxswiftswiftat risblocked4.2.2%The swift redfox jumps.Merging was wrong in 10 of 15; the check was wrong in 1 and refused 2
edits that would have merged well. This is how text-matching edit tools
behave (Anthropic's
str_replaceand OpenAI'sapply_patchmatch thecurrent text and fail on a miss; Claude Code refuses a write to a file
changed since its read; the Google Docs API's
requiredRevisionIdandTipTap's server toolkit refuse a stale write so the caller re-reads).
Now:
the current state: a replacement checks its own words in the block
where it read them, found there by id (still there, exactly once); a
rewrite or a delete checks the whole block (where it is, and its type,
text, formats, style, NodeState and children, compared serialized, not
only its text, as the checking schema declares them: run the check on a
schema that still declares every field stored versions may hold, or a
change to a dropped field goes unnoticed); a format or a comment on words
the edit names checks those words like a replacement and is applied where
they are found on the current state, never by offsets from the read; an
insert checks its anchor block. Every edit of a batch
is checked before any is applied, then again, in order, as it is applied
on the state the earlier ones leave: an edit an earlier one changes (its
words changed or now appear twice, its block deleted or rewritten, a block
named twice) refuses the whole batch, a conservative rule. On a refusal,
apply none, and return every failure (which edit, the reason, and the
block's content in the latest stored state, not in the discarded edit;
edits after an in-order failure are still checked) and a new read.
On a pass, apply to the current state (blocks found by id there) and
store with a compare-and-set; on a lost race, drop the state and the
update computed on the older state and run the whole check and edit
again on the newer one (bounded), or refuse back to the agent with a read
of the state that won the last race: an update computed on an older state
is never stored or sent. Merge from the read
only where there is nothing to check (an append, a suggestion a person
accepts, an edit by position): the fork-and-merge recipe stays for those
cases.
sketches check on the latest stored state and run
editYStatethere,through
applyChecked(the check; the edit, which checks each part againas it applies it and can refuse; the compare-and-set; up to 3 attempts,
then a fresh load), instead of editing
seenand merging. The wordsketch takes a batch of replacements.
$findWordsfinds areplacement's words by the block's id and requires them exactly once in
that block's text, in one plain text node;
$blockAtcompares a targetas a whole (its path and its serialized subtree); an answer by id is
checked op by op and applied whole or not at all. "One edit at a time"
says a checked edit starts over on a lost race instead of merging.
ServerDocsAgentEdits.test.tsruns the sketches as written (they live in__tests__/utils/agentEditSketch.ts, and the first test checks that thepage holds the same code), including targets changed since the read, a
batch with one failing op, a batch whose first edit changes what the
second (and third) names, refusals that report the stored content, a
format on named words applied where they are now, and a store that loses
the race.
whole block checked against what the model read on the stored state, and
the edit is applied there by id with
applyChecked;BlockHandles.test.tsfollows it (a block the person typed in now goes back as
changed).runs on.
Also, from the reviews of #9685:
includes each client's own order", and the json-joy comparison is
dropped (y.md and D92).
rewritten word, both when it continues past it.
regressions/UndoInvisibleSteps.test.ts(from [lexical-y][lexical-website] Bug Fix: Undo behaviour change: every undo or redo press changes what the document shows, or empties its stack #9630) pins D81: the 3cells that assert a press also taking the next step are
test.fails.They fail on main with gc off and on.
Test plan
Before
The new cells against weaker sketches: no uniqueness check, a target
compared by its path and text only, and an update computed before a lost
race merged into the newer state (the docs' previous recipe):
An independent review of an earlier head of this branch composed the
word check per edit into a batch: each check passed on the stored state,
the first edit invalidated the second, and the batch was stored half done;
after 3 lost races the read returned was the one that had lost:
A second review found the refusal of such a batch reporting the block's
text from the discarded edit (
blue fox and henwhere storage heldred fox and hen), and only the first of two invalidated edits. Those casesare cells of
ServerDocsAgentEdits.test.tsnow, as is a third review'scase, pinned as the documented scope: a field the server's schema dropped
is not compared, so a rewrite passes over a collaborator's change to it.
UndoInvisibleSteps.test.tsunpinned, on main:After
With gc off, the same lexical-y run had one failure,
DisposeMemory.test.ts("40 editors bound, edited and disposed in a synchronous loop hold no
document"), under 24 parallel workers; it passes 5 of 5 alone, and this
change touches no code it runs (docs and tests only).