Browse Source

Merge pull request #2814 from deepseek-harness/fix/composer-edit-range-attribution

fix(web): carry the composer edit range from the pre-edit selection
Yichen Jiang 3 weeks ago
parent
commit
a67b9a4d31

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md
+2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0
+2026-08-20-composer-edit-range-from-selection.zh.md: 56f7044ee7f60b72d32226451d663b3f18371c69

+ 47 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md

@@ -0,0 +1,47 @@
+# Agent Note: Composer edits carry the range they applied to
+
+Status: implemented
+
+English | [中文](2026-08-20-composer-edit-range-from-selection.zh.md)
+
+## Problem
+
+The input machine keeps its reference occurrences aligned by reconciling them against one edit range: entries before the range shift, entries after it hold, and an entry the range intersects loses its structured identity and stays behind as ordinary draft text. That last rule is the deliberate meaning of editing inside a reference.
+
+Ordinary typing supplied no range. A controlled textarea's change event carries only the resulting string, so the machine recovered the range by scanning the two drafts for a common prefix and suffix. That recovery is ambiguous whenever the inserted text repeats the text it lands against, and the greedy scan always resolves the ambiguity the same way: it slides the edit as late as the characters allow.
+
+A reference renders as `@` followed by its label, so typing `@` immediately before one produces exactly that collision. The user inserts at the reference's own offset; the scan reports an insertion one character later, inside the reference; reconcile applies the intersect rule and drops the occurrence. Deleting a character in front of such a reference slides the same way.
+
+The draft then still reads correctly to the eye while carrying no structured reference, and submission takes the occurrence-free path that sends the draft verbatim. The host receives the human-facing label instead of the owner's model form and resolves nothing. The serialization guard that exists to prevent exactly this downgrade never runs, because it only fires when an occurrence survives to be serialized.
+
+This became reachable when references [became literal inline text](../feature/2026-07-27-web-file-and-session-references.md). A reference previously occupied one `U+FFFC`, a character no keystroke produces, so the scan had nothing to collide with.
+
+## Decision
+
+`InputBar` records the textarea's selection and `inputType` during `beforeinput` and passes the resulting range to `setDraft`, which the machine already accepts and prefers over its own scan. A textarea exposes the edit no other way — `getTargetRanges()` is empty for form controls.
+
+An edit that replaces a selection reports that selection, and it is the range outright; the inserted length is whatever the draft grew by once the replaced range is accounted for. A caret delete replaces nothing and reports the bare caret, so its range comes from the direction `inputType` names and the number of characters the draft actually lost. The count is measured rather than assumed to be one, because a single caret gesture removes a multi-unit grapheme, a word, or a line just as readily. Chromium, WebKit, and Firefox all report the collapsed caret for `deleteContentBackward` and `deleteContentForward`, and all three derive the same range from it.
+
+Only the `insert` and `delete` families are recorded. A history replay reports wherever the caret happens to sit, which would survive every check while naming the wrong span; ignoring it leaves that path on the scan.
+
+The record is consumed once and cleared. A record whose draft length disagrees with the draft the change reports, a selection past the draft, a shrinking edit over a selection the caret cannot explain, or an undirected delete all yield no range, and the machine falls back to its scan. Paste and the boundary Backspace and Delete gestures already supplied their own ranges and are untouched.
+
+## Testing
+
+Component tests cover the trigger character typed in front of a reference, a caret Backspace, a caret Delete, a caret word delete, and a delete over a selection, asserting in each case that the occurrence survives at the shifted offset. The caret cases fail against the scan-recovered range, and the word case fails against a fixed one-character step.
+
+An assembled browser scenario drives the same gestures as real key presses against the shipped composition, which is the only place the range an engine reports for them can be observed; its golden projects the backdrop's segments, since the decoration layer is aria-hidden and the accessibility tree cannot see the chip. A real composition driven through the browser's IME path reports the composing segment as the selection at every intermediate state, and the reference survives each one.
+
+## Alternatives considered
+
+**Disambiguate the scan with the post-edit caret.** The caret pins which of the textually equivalent readings happened, and the change event already carries it. Rejected because it keeps a reconstruction where an exact fact is available, and it cannot separate the deleted and inserted halves of a replaced selection at all.
+
+**Give references a leading marker no keystroke produces.** A private-use character in place of the literal `@` removes the collision at the representation level, and the reject list for pasted text already names that range. Rejected because it re-adds a character that every serialization, selection, and accessibility path has to strip, to buy what an exact range buys directly, and it would leave ordinary typing reconstructing its range for every other reason.
+
+**Widen reconcile to keep an occurrence when the range only touches its edge.** Rejected because the misattributed offset lands strictly inside the reference, not on its boundary, so the rule change would not reach this defect while making the intersect rule vaguer.
+
+## Consequences
+
+Every native textarea edit that reaches `onChange` now names the range it applied to, so occurrence offsets follow the edit that actually happened rather than one merely consistent with the resulting characters. Draft writes that originate in the facade rather than the DOM — `insertText` and the command-token splices among them — still carry no range and keep the scan, as do the fallbacks above.
+
+The composer now depends on `beforeinput` preceding each value change, and on `inputType` naming the direction of a caret delete. Any future edit path that mutates the value without either silently returns to the scan rather than breaking, which keeps the failure mode the old behavior instead of a wrong range.

+ 47 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md

@@ -0,0 +1,47 @@
+# Agent Note: 输入框的编辑自带它所作用的范围
+
+Status: implemented
+
+[English](2026-08-20-composer-edit-range-from-selection.md) | 中文
+
+## 问题
+
+输入机器靠一个编辑范围来对齐引用 occurrence:范围之前的条目右移,之后的条目不动,被范围相交的条目失去结构化身份、以普通草稿文本留在原地。最后一条是"在引用内部编辑"的刻意含义。
+
+普通打字不提供范围。受控 textarea 的 change 事件只带结果字符串,于是机器靠扫描两份草稿的公共前后缀来还原范围。只要插入的文本与它落点处的文本重复,这个还原就是有歧义的,而贪心扫描永远以同一种方式消解歧义:把编辑尽量往后滑。
+
+引用渲染为 `@` 加标签,所以紧挨着引用前面打一个 `@` 恰好构成这种撞车。用户在引用自身的偏移处插入;扫描报告的插入位置晚一个字符,落在引用内部;reconcile 执行相交规则,删掉这个 occurrence。删除这类引用前面的一个字符会以同样方式滑动。
+
+此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。
+
+这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。
+
+## 决策
+
+`InputBar` 在 `beforeinput` 期间记录 textarea 的 selection 与 `inputType`,并把由此得到的范围传给 `setDraft`;机器本就接受该参数,并优先于自身的扫描。textarea 没有别的途径暴露这次编辑——`getTargetRanges()` 对表单控件返回空。
+
+替换一段选区的编辑会报告那段 selection,它直接就是范围;插入长度是扣除被替换范围后草稿增长的量。折叠光标的删除不替换任何东西、只报告光标本身,因此它的范围来自 `inputType` 指明的方向加上草稿实际减少的字符数。这个数量是**测量**得到而非假定为 1,因为一次折叠手势同样可能删掉一个多码元字形、一个词或一整行。Chromium、WebKit 与 Firefox 对 `deleteContentBackward` 和 `deleteContentForward` 都报告折叠光标,三者由此推导出相同的范围。
+
+只有 `insert` 与 `delete` 两族会被记录。历史回放报告的是光标当时碰巧所在的位置,那会通过全部校验却指向错误区间;忽略它即让该路径留在扫描上。
+
+记录只消费一次即清空。记录的草稿长度与 change 报告的草稿不符、selection 越过草稿末尾、选区之上出现光标无法解释的收缩、以及方向不明的删除,这几种情况都不产出范围,机器回落到扫描。粘贴以及边界处的 Backspace 与 Delete 手势本就自带范围,未受影响。
+
+## 测试
+
+组件测试覆盖在引用正前方输入触发字符、折叠 Backspace、折叠 Delete、折叠整词删除,以及选区替换式删除,每种都断言 occurrence 在右移后的偏移处存活。折叠类用例在扫描还原的范围下失败,整词用例在固定一字符步长的实现下失败。
+
+组装层浏览器场景以真实按键对已发布组合驱动同样的手势——那是唯一能观察到引擎为这些手势报告何种范围的地方;其 golden 投影的是 backdrop 的分段,因为装饰层是 aria-hidden 的,无障碍树看不到 chip。经浏览器 IME 路径驱动的真实组合在每个中间态都把组合段报告为 selection,引用在每一步都存活。
+
+## 备选方案
+
+**用编辑后的光标位置消解扫描的歧义。** 光标能钉住若干文本等价读法中真正发生的那一种,而 change 事件本就携带它。拒绝:在已有精确事实可用时仍保留一次重建,而且它根本无法把"替换一段选区"拆成删除与插入两半。
+
+**给引用一个按键打不出的前导标记。** 用私有区字符取代字面 `@`,在表示层消除撞车,而粘贴文本的剔除名单本就点名了那个区段。拒绝:为了换取一个精确范围本就能直接换到的东西,却重新引入一个必须在所有序列化、选择和无障碍路径上剥离的字符,而且普通打字仍会因为其他原因继续重建自己的范围。
+
+**放宽 reconcile,让范围只触及边缘时保留 occurrence。** 拒绝:误判出的偏移严格落在引用内部而非边界上,规则放宽够不到这个缺陷,只会让相交规则本身更含糊。
+
+## 后果
+
+每一次经 `onChange` 到达的原生 textarea 编辑现在都指明它作用的范围,因此 occurrence 偏移跟随的是真实发生的编辑,而不是某个仅仅与结果字符一致的编辑。源自 facade 而非 DOM 的草稿写入——`insertText` 和命令 token 的替换等——仍不带范围并保留扫描,上述各类回落同样如此。
+
+输入框由此依赖 `beforeinput` 先于每次取值变更发生,并依赖 `inputType` 指明折叠删除的方向。未来任何绕过其中之一改写取值的编辑路径会静默回落到扫描而不是出错,也就是说失效模式退回旧行为,而不是一个错误的范围。

+ 61 - 1
apps/web/tests/reference-composer.e2e.ts

@@ -30,6 +30,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
 const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md')
+const CARET_EXPECTED = join(SNAPSHOT_DIR, 'caret-edits.expected.md')
 const MODE = webSnapshotMode()
 const SOURCE_SESSION_ID = 'reference-source-session'
 const TARGET_SESSION_ID = 'reference-order-target-session'
@@ -110,6 +111,29 @@ function targetSessionFixture(): string {
   ].join('\n')
 }
 
+/**
+ * Project the composer backdrop into one stable block: the draft it paints and
+ * each segment in draft order, with the decoration a segment carries.
+ * @param page - the assembled app page.
+ * @returns the golden text for the composer's decoration layer.
+ */
+async function composerSegments(page: Page): Promise<string> {
+  return page.evaluate(() => {
+    const backdrop = document.querySelector('[data-input-backdrop]')
+    const textarea = document.querySelector('textarea')
+    if (backdrop === null || textarea === null) return 'composer absent'
+    const rows = [...backdrop.childNodes].map((node) => {
+      if (!(node instanceof HTMLElement)) return `plain    ${JSON.stringify(node.textContent ?? '')}`
+      const decoration = node.dataset['decoration'] ?? 'unknown'
+      const appearance = node.dataset['referenceAppearance']
+      const icons = node.querySelectorAll('svg').length
+      return `${decoration.padEnd(8)} ${JSON.stringify(node.textContent ?? '')}`
+        + `${appearance === undefined ? '' : ` appearance=${appearance}`} icons=${icons}`
+    })
+    return [`draft ${JSON.stringify(textarea.value)}`, ...rows].join('\n')
+  })
+}
+
 describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -168,6 +192,42 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(tripwire.warnings).toEqual([])
   })
 
+  it('keeps a structured reference across caret edits in front of it', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-caret-edits'))
+    const input = page.locator('textarea').first()
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    const sessionReference = page.locator('[data-reference-appearance="session"]')
+
+    await input.fill('@Research')
+    await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click()
+    await expect.poll(() => input.inputValue()).toBe('@Research notes ')
+
+    // Only the caret is placed programmatically; both edits below are real key
+    // presses, which is the whole point — the range a textarea reports for them
+    // is what the composer has to read, and no synthetic event can stand in.
+    await input.evaluate((el: HTMLTextAreaElement) => { el.focus(); el.setSelectionRange(0, 0) })
+    await input.press('@')
+    await expect.poll(() => input.inputValue()).toBe('@@Research notes ')
+    await expect.poll(() => sessionReference.count()).toBe(1)
+    await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
+    await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
+
+    // The decoration layer is aria-hidden, so the accessibility tree cannot see
+    // the chip; the golden projects the backdrop's own segments instead, which
+    // is where the surviving reference is observable at all.
+    await compareOrRefreshGolden(CARET_EXPECTED, await composerSegments(page), MODE)
+
+    // The caret sits after the typed trigger; this Backspace removes it with a
+    // collapsed selection, the gesture the reported range cannot describe alone.
+    await input.press('Backspace')
+    await expect.poll(() => input.inputValue()).toBe('@Research notes ')
+    await expect.poll(() => sessionReference.count()).toBe(1)
+    await expect.poll(() => sessionReference.textContent()).toBe('@Research notes')
+
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
   it('renders the durable direct-message then recall order', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
     const group = page.getByRole('treeitem', { name: /Ungrouped/ })
@@ -184,6 +244,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['caret-edits.expected.md', 'menu.expected.md', 'order.expected.md'])
   })
 })

+ 4 - 0
apps/web/tests/snapshots/reference-composer/caret-edits.expected.md

@@ -0,0 +1,4 @@
+draft "@@Research notes "
+plain    "@"
+chip     "@Research notes" appearance=session icons=1
+plain    " "

+ 71 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -24,6 +24,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { deriveDecorations } from '../input/decorations.ts'
 import type { DraftDecorations } from '../input/decorations.ts'
+import type { EditRange } from '../input/contract.ts'
 import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
 import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
 import { ContextMeter } from './ContextMeter.tsx'
@@ -34,6 +35,45 @@ import css from './InputBar.module.css'
 /** Decoration product of the no-session state (no machine, empty draft). */
 const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
 
+/** The selection and edit family a `beforeinput` recorded, with the draft length it applied to. */
+interface PendingEdit {
+  readonly start: number
+  readonly end: number
+  readonly draftLength: number
+  readonly inputType: string
+}
+
+/**
+ * Resolve one edit's range from the record taken before it applied.
+ * A selection the edit replaces is the range outright. A caret delete replaces
+ * nothing and reports the bare caret, so the removed span is whatever the draft
+ * lost, on the side `inputType` names — measured, because one caret gesture can
+ * remove a multi-unit grapheme, a word, or a line.
+ * @param pending - record taken at `beforeinput`, null when none was seen.
+ * @param prevLength - length of the draft the edit applied to.
+ * @param nextLength - length of the resulting draft.
+ * @returns the exact range, or undefined when the record cannot describe this
+ * edit and the machine's diff scan has to recover it.
+ */
+function editRangeOf(pending: PendingEdit | null, prevLength: number, nextLength: number): EditRange | undefined {
+  if (pending === null || pending.draftLength !== prevLength) return undefined
+  const { start, end, inputType } = pending
+  // A DOM selection cannot invert; the check keeps that a precondition of the
+  // math below rather than an assumption about the element.
+  if (start > end || end > prevLength) return undefined
+  const insertedLength = nextLength - prevLength + (end - start)
+  if (insertedLength >= 0) return { start, end, insertedLength }
+  if (start !== end) return undefined
+  const removed = prevLength - nextLength
+  if (inputType.endsWith('Backward')) {
+    return removed <= start ? { start: start - removed, end: start, insertedLength: 0 } : undefined
+  }
+  if (inputType.endsWith('Forward')) {
+    return start + removed <= prevLength ? { start, end: start + removed, insertedLength: 0 } : undefined
+  }
+  return undefined
+}
+
 export type InputBarProps = ComposerBarProps
 
 export function InputBar({
@@ -277,6 +317,34 @@ export function InputBar({
   })
   /* oxlint-enable typescript/no-unnecessary-condition */
 
+  // The machine's occurrence math needs the edit's real range, and a controlled
+  // textarea's change event carries only the resulting string. `beforeinput`
+  // fires while the element still holds the pre-edit selection, which is
+  // exactly the range about to be replaced; a textarea exposes it no other way
+  // (`getTargetRanges()` is empty for form controls). Recovering the range by
+  // diffing the two drafts instead is ambiguous whenever the typed text repeats
+  // what it lands against — typing the trigger char before a reference reads as
+  // landing inside that reference, which drops it. One lifetime, like the wheel
+  // listener above: the textarea is never unmounted.
+  const pendingEditRef = useRef<PendingEdit | null>(null)
+  useEffect(() => {
+    const el = inputRef.current
+    if (el === null) return
+    const onBeforeInput = (e: InputEvent): void => {
+      // Only the families whose reported selection describes the edit. A
+      // history replay reports wherever the caret happens to sit, which would
+      // survive every check in editRangeOf while naming the wrong span.
+      if (!e.inputType.startsWith('insert') && !e.inputType.startsWith('delete')) {
+        pendingEditRef.current = null
+        return
+      }
+      const { start, end } = selectionOf(el)
+      pendingEditRef.current = { start, end, draftLength: el.value.length, inputType: e.inputType }
+    }
+    el.addEventListener('beforeinput', onBeforeInput)
+    return () => { el.removeEventListener('beforeinput', onBeforeInput) }
+  }, [])
+
   const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
     if (workspaceTrigger) {
       if (e.key === 'Enter' || e.key === ' ') {
@@ -371,8 +439,10 @@ export function InputBar({
     if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
     if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
     const next = e.target.value
+    const pending = pendingEditRef.current
+    pendingEditRef.current = null
     safariNativeShrinkRef.current = safari && next.length < draft.length
-    keyboard.setDraft(next)
+    keyboard.setDraft(next, editRangeOf(pending, draft.length, next.length))
     // selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
     // oxlint-disable-next-line typescript/no-unnecessary-condition
     keyboard.track(next, e.target.selectionStart ?? next.length)

+ 110 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -214,6 +214,17 @@ function bench(over?: BenchOptions) {
   }
 }
 
+/**
+ * Dispatch the native `beforeinput` the composer reads the pre-edit selection
+ * from. The DOM event carries no range for a textarea (`getTargetRanges()` is
+ * empty there), so the element's own selection plus `inputType` is the signal.
+ * The selection each gesture leaves is the engine-observed one: a delete over a
+ * selection reports that selection, a caret delete reports the bare caret.
+ */
+function beforeInput(el: HTMLTextAreaElement, inputType = 'insertText'): void {
+  el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType }))
+}
+
 function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
   for (let i = slotCalls.length - 1; i >= 0; i -= 1) {
     const call = slotCalls[i]
@@ -1226,6 +1237,105 @@ describe('decorations', () => {
     expect(forwardDelete.shell.snapshot).toMatchObject({ draft: '前  后', occurrences: [] })
   })
 
+  it('typing the trigger char immediately before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 0, end: 3, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    // The inserted char equals the reference's own leading trigger, so the two
+    // drafts alone cannot say whether it landed before or after that trigger.
+    textarea.setSelectionRange(0, 0)
+    act(() => {
+      beforeInput(textarea)
+      fireEvent.change(textarea, { target: { value: '@@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 1, length: 4 })
+  })
+
+  it('a selection-replacing delete before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    textarea.setSelectionRange(0, 1)
+    act(() => {
+      beforeInput(textarea, 'deleteContentBackward')
+      fireEvent.change(textarea, { target: { value: '@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
+  })
+
+  it('a caret Backspace before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('@@会话一 ')
+    // A caret delete reports the bare caret, never the character it removes.
+    textarea.setSelectionRange(1, 1)
+    act(() => {
+      beforeInput(textarea, 'deleteContentBackward')
+      fireEvent.change(textarea, { target: { value: '@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
+  })
+
+  it('a caret Delete before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('@@w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 1, end: 4, draftRev: shell.snapshot.draftRev })
+    })
+    textarea.setSelectionRange(0, 0)
+    act(() => {
+      beforeInput(textarea, 'deleteContentForward')
+      fireEvent.change(textarea, { target: { value: '@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
+  })
+
+  it('a caret word delete before a reference keeps it structured', () => {
+    const { shell, textarea } = bench()
+    act(() => {
+      shell.setDraft('word @w1')
+      shell.insertReference({
+        source: 'reference', ref: 'w1', label: '会话一', appearance: 'session', clipboardText: '@w1',
+      }, { start: 5, end: 8, draftRev: shell.snapshot.draftRev })
+    })
+    expect(shell.snapshot.draft).toBe('word @会话一 ')
+    // One caret gesture can remove more than one character; the deleted span
+    // is whatever the draft lost, never a fixed step.
+    textarea.setSelectionRange(5, 5)
+    act(() => {
+      beforeInput(textarea, 'deleteWordBackward')
+      fireEvent.change(textarea, { target: { value: '@会话一 ' } })
+    })
+    expect(shell.snapshot.draft).toBe('@会话一 ')
+    expect(shell.snapshot.occurrences).toHaveLength(1)
+    expect(shell.snapshot.occurrences[0]).toMatchObject({ offset: 0, length: 4 })
+  })
+
   it('copy and cut expand a partial reference selection to its structured range', () => {
     const { shell, textarea } = bench()
     act(() => {