input-machine.spec.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  1. /**
  2. * InputMachine unit account (design §9.1, eng. plan §3.9-3.12): the submit
  3. * plane carried over from the InputCore era (adjudication, span CAS, drift
  4. * guard, anti-backwash), plus the occurrence table (shift / whole-chip
  5. * deletion / same-name independence), the self-managed undo log (typing
  6. * coalescing, paste two-stage undo, redo chain), consume-token guards, the
  7. * paste attempt lifecycle, projectClipboard, and the decoration projection.
  8. * Pure event sequences — no React, no DOM, no ambient clock.
  9. */
  10. import { describe, expect, it } from 'vitest'
  11. import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
  12. import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
  13. import { InputMachine, PLACEHOLDER, projectClipboard } from '../src/client/input/machine.ts'
  14. import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
  15. const P = PLACEHOLDER
  16. function claimOf(name: string, hint?: string): CommandClaim {
  17. return {
  18. token: `/${name} `,
  19. ...(hint !== undefined ? { hint } : {}),
  20. submit: async () => ({ kind: 'success' }),
  21. }
  22. }
  23. function refOf(name: string, source = 'skill'): ReferenceInsert {
  24. return { source, ref: name, label: name, clipboardText: `/${name}` }
  25. }
  26. function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
  27. return { start, end, draftRev: m.state.draftRev }
  28. }
  29. function effectAt<T extends InputEffect['type']>(
  30. effects: readonly InputEffect[], index: number, type: T,
  31. ): Extract<InputEffect, { type: T }> {
  32. const e = effects[index]
  33. expect(e?.type).toBe(type)
  34. return e as Extract<InputEffect, { type: T }>
  35. }
  36. /** Drive plain → adjudicating and hand back the minted attempt. */
  37. function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
  38. m.dispatch({ type: 'draft-changed', draft })
  39. const fx = m.dispatch({ type: 'enter', mode })
  40. return effectAt(fx, 0, 'adjudicate').attempt
  41. }
  42. /** Drive plain → claimed → submitting and hand back attempt + claim. */
  43. function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
  44. const claim = claimOf(name)
  45. m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
  46. m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
  47. m.dispatch({ type: 'draft-changed', draft: claim.token + args })
  48. const fx = m.dispatch({ type: 'enter', mode: 'queue' })
  49. return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
  50. }
  51. function staleAttempt(): SubmitAttempt {
  52. return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
  53. }
  54. describe('input-machine: plain × enter', () => {
  55. it('empty and whitespace-only drafts produce nothing', () => {
  56. const m = new InputMachine()
  57. expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
  58. m.dispatch({ type: 'draft-changed', draft: ' \n ' })
  59. expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
  60. expect(m.state.phase).toBe('plain')
  61. })
  62. it('non-command text falls to the default sink with the given mode', () => {
  63. const m = new InputMachine()
  64. m.dispatch({ type: 'draft-changed', draft: 'hello world' })
  65. expect(m.dispatch({ type: 'enter', mode: 'steer' }))
  66. .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
  67. expect(m.state.phase).toBe('plain')
  68. })
  69. it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
  70. const m = new InputMachine()
  71. m.dispatch({ type: 'draft-changed', draft: '/goal x' })
  72. const fx = m.dispatch({ type: 'enter', mode: 'queue' })
  73. const eff = effectAt(fx, 0, 'adjudicate')
  74. expect(eff.draft).toBe('/goal x')
  75. expect(eff.attempt.draftSnapshot).toBe('/goal x')
  76. expect(eff.attempt.signal.aborted).toBe(false)
  77. expect(m.state.phase).toBe('adjudicating')
  78. })
  79. it('leading is judged after trim including newlines', () => {
  80. const m = new InputMachine()
  81. m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
  82. expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
  83. })
  84. it('a non-whitespace prefix before "/" is not leading — default sink', () => {
  85. const m = new InputMachine()
  86. m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
  87. expect(m.dispatch({ type: 'enter', mode: 'queue' }))
  88. .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
  89. })
  90. })
  91. describe('input-machine: adjudication outcomes', () => {
  92. it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
  93. const m = new InputMachine()
  94. const attempt = enterAdjudicating(m, '/goal x\ny')
  95. const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
  96. const eff = effectAt(fx, 0, 'begin-submit')
  97. expect(eff.args).toBe('x\ny')
  98. expect(eff.attempt.seq).toBe(attempt.seq)
  99. expect(m.state.phase).toBe('submitting')
  100. expect(m.state.claim).toEqual({ token: '/goal ' })
  101. })
  102. it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
  103. const a = new InputMachine()
  104. const attemptA = enterAdjudicating(a, '/goal')
  105. expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
  106. const b = new InputMachine()
  107. const attemptB = enterAdjudicating(b, '\n\n/goal x')
  108. expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
  109. })
  110. it('undefined outcome falls back to the default sink preserving the enter mode', () => {
  111. const m = new InputMachine()
  112. const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
  113. expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
  114. .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
  115. expect(m.state.phase).toBe('plain')
  116. })
  117. it("'handled' lands plain with zero effects (popup shell path)", () => {
  118. const m = new InputMachine()
  119. const attempt = enterAdjudicating(m, '/model')
  120. expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
  121. expect(m.state.phase).toBe('plain')
  122. expect(m.state.draft).toBe('/model')
  123. })
  124. it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
  125. const m = new InputMachine()
  126. const attempt = enterAdjudicating(m, '/goal x')
  127. expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
  128. .toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
  129. expect(m.state.phase).toBe('plain')
  130. expect(m.state.draft).toBe('/goal x')
  131. })
  132. it('enter is a no-op while adjudicating (pending lock)', () => {
  133. const m = new InputMachine()
  134. enterAdjudicating(m, '/goal x')
  135. expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
  136. expect(m.state.phase).toBe('adjudicating')
  137. })
  138. it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
  139. const m = new InputMachine()
  140. enterAdjudicating(m, '/goal x')
  141. expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
  142. expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
  143. expect(m.state.phase).toBe('adjudicating')
  144. })
  145. it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
  146. const m = new InputMachine()
  147. const attempt = enterAdjudicating(m, '/goal x')
  148. m.dispatch({ type: 'release' })
  149. expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
  150. expect(m.state.phase).toBe('plain')
  151. })
  152. })
  153. describe('input-machine: begin-command CAS', () => {
  154. it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
  155. const m = new InputMachine()
  156. m.dispatch({ type: 'draft-changed', draft: '/go' })
  157. const before = m.state.draftRev
  158. const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
  159. expect(fx).toEqual([])
  160. expect(m.state.draftRev).toBeGreaterThan(before)
  161. expect(m.state.draft).toBe('/goal ')
  162. expect(m.state.phase).toBe('claimed')
  163. expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
  164. })
  165. it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
  166. const m = new InputMachine()
  167. m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
  168. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
  169. expect(m.state.draft).toBe('/goal ')
  170. m.dispatch({ type: 'draft-changed', draft: '/goal x' })
  171. expect(m.state.phase).toBe('claimed')
  172. })
  173. it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
  174. const m = new InputMachine()
  175. m.dispatch({ type: 'draft-changed', draft: '/go' })
  176. const span = spanOf(m, 0, 3)
  177. m.dispatch({ type: 'draft-changed', draft: '/goX' })
  178. const rev = m.state.draftRev
  179. expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
  180. expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
  181. })
  182. it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
  183. const m = new InputMachine()
  184. m.dispatch({ type: 'draft-changed', draft: 'x /go' })
  185. expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
  186. expect(m.state.phase).toBe('plain')
  187. })
  188. it('claimed overwrites in place — no stack', () => {
  189. const m = new InputMachine()
  190. m.dispatch({ type: 'draft-changed', draft: '/go' })
  191. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  192. m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
  193. expect(m.state.draft).toBe('/model ')
  194. expect(m.state.claim?.token).toBe('/model ')
  195. expect(m.state.phase).toBe('claimed')
  196. })
  197. it('submitting rejects begin-command (lock)', () => {
  198. const m = new InputMachine()
  199. enterSubmitting(m, 'goal', 'x')
  200. expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
  201. expect(m.state.claim?.token).toBe('/goal ')
  202. expect(m.state.phase).toBe('submitting')
  203. })
  204. it('undo reverts the claim transaction and the watch releases the claim', () => {
  205. const m = new InputMachine()
  206. m.dispatch({ type: 'draft-changed', draft: '/go' })
  207. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  208. m.dispatch({ type: 'undo' })
  209. expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
  210. expect(m.state.claim).toBeUndefined()
  211. })
  212. })
  213. describe('input-machine: insert-ref and the occurrence table', () => {
  214. it('valid span becomes one placeholder + one occurrence with cached projections', () => {
  215. const m = new InputMachine()
  216. m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
  217. const fx = m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
  218. expect(fx).toEqual([])
  219. expect(m.state.draft).toBe(`see ${P} now`)
  220. expect(m.state.occurrences).toEqual([{
  221. occurrenceId: 1, source: 'subagent', ref: 'worker-1', offset: 4,
  222. label: 'worker-1', clipboardText: '/worker-1',
  223. }])
  224. expect(m.state.phase).toBe('plain')
  225. })
  226. it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
  227. const m = new InputMachine()
  228. m.dispatch({ type: 'draft-changed', draft: '/alp' })
  229. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
  230. m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
  231. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
  232. expect(m.state.draft).toBe(`${P} and ${P}`)
  233. expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
  234. // Delete the first chip whole; the second survives with its own identity.
  235. m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
  236. expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
  237. })
  238. it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
  239. const m = new InputMachine()
  240. m.dispatch({ type: 'draft-changed', draft: '/go' })
  241. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  242. m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
  243. m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
  244. expect(m.state.draft).toBe(`/goal ask ${P}`)
  245. expect(m.state.phase).toBe('claimed')
  246. expect(m.state.occurrences).toHaveLength(1)
  247. })
  248. it('a stale draftRev no-ops: no draft change, no occurrence', () => {
  249. const m = new InputMachine()
  250. m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
  251. const span = spanOf(m, 4, 8)
  252. m.dispatch({ type: 'draft-changed', draft: 'see @work' })
  253. expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
  254. expect(m.state.occurrences).toEqual([])
  255. })
  256. })
  257. describe('input-machine: occurrence reconciliation on draft edits', () => {
  258. /** Machine with one chip at offset 4 inside `see ${P} now`. */
  259. function withChip(): InputMachine {
  260. const m = new InputMachine()
  261. m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
  262. m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
  263. return m
  264. }
  265. it('an edit before the placeholder shifts the offset by the length delta (explicit editRange)', () => {
  266. const m = withChip()
  267. m.dispatch({ type: 'draft-changed', draft: `I see ${P} now`, editRange: { start: 0, end: 0, insertedLength: 2 } })
  268. expect(m.state.occurrences[0]?.offset).toBe(6)
  269. m.dispatch({ type: 'draft-changed', draft: `see ${P} now`, editRange: { start: 0, end: 2, insertedLength: 0 } })
  270. expect(m.state.occurrences[0]?.offset).toBe(4)
  271. })
  272. it('an edit after the placeholder leaves the offset alone', () => {
  273. const m = withChip()
  274. m.dispatch({ type: 'draft-changed', draft: `see ${P} later`, editRange: { start: 6, end: 9, insertedLength: 5 } })
  275. expect(m.state.occurrences[0]?.offset).toBe(4)
  276. })
  277. it('a deletion covering the placeholder removes the whole occurrence', () => {
  278. const m = withChip()
  279. m.dispatch({ type: 'draft-changed', draft: 'see now', editRange: { start: 4, end: 5, insertedLength: 0 } })
  280. expect(m.state.occurrences).toEqual([])
  281. expect(m.state.draft).toBe('see now')
  282. })
  283. it('a replacement spanning the placeholder removes the occurrence and keeps the replacement text', () => {
  284. const m = withChip()
  285. m.dispatch({ type: 'draft-changed', draft: 'see all of it now', editRange: { start: 4, end: 5, insertedLength: 9 } })
  286. expect(m.state.occurrences).toEqual([])
  287. })
  288. it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
  289. const m = withChip()
  290. m.dispatch({ type: 'draft-changed', draft: `see there ${P} now` })
  291. expect(m.state.occurrences[0]?.offset).toBe(10)
  292. })
  293. it('without editRange the diff scan detects placeholder deletion', () => {
  294. const m = withChip()
  295. m.dispatch({ type: 'draft-changed', draft: 'see now' })
  296. expect(m.state.occurrences).toEqual([])
  297. })
  298. it('an identical draft is a no-op: no revision bump, no undo entry', () => {
  299. const m = withChip()
  300. const rev = m.state.draftRev
  301. expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
  302. expect(m.state.draftRev).toBe(rev)
  303. })
  304. })
  305. describe('input-machine: newline transaction (F1)', () => {
  306. it('inserts \\n at the caret and shifts trailing occurrences', () => {
  307. const m = new InputMachine()
  308. m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
  309. m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
  310. m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
  311. expect(m.state.draft).toBe(`ab\n ${P}`)
  312. expect(m.state.occurrences[0]?.offset).toBe(4)
  313. m.dispatch({ type: 'undo' })
  314. expect(m.state.draft).toBe(`ab ${P}`)
  315. })
  316. it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
  317. const m = new InputMachine()
  318. m.dispatch({ type: 'draft-changed', draft: '/go' })
  319. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  320. expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
  321. expect(m.state.phase).toBe('claimed')
  322. m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
  323. expect(m.state.draft).toBe('\n/goal ')
  324. expect(m.state.phase).toBe('plain')
  325. expect(m.state.claim).toBeUndefined()
  326. })
  327. })
  328. describe('input-machine: consume-token guards', () => {
  329. it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
  330. const m = new InputMachine()
  331. m.dispatch({ type: 'draft-changed', draft: '/model rest' })
  332. const before = m.state.draftRev
  333. m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
  334. expect(m.state.draftRev).toBeGreaterThan(before)
  335. expect(m.state.draft).toBe('rest')
  336. m.dispatch({ type: 'undo' })
  337. expect(m.state.draft).toBe('/model rest')
  338. })
  339. it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
  340. const m = new InputMachine()
  341. m.dispatch({ type: 'draft-changed', draft: '/model' })
  342. const span = spanOf(m, 0, 6)
  343. m.dispatch({ type: 'draft-changed', draft: '/model x' })
  344. const rev = m.state.draftRev
  345. expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
  346. expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
  347. })
  348. it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
  349. const m = new InputMachine()
  350. m.dispatch({ type: 'draft-changed', draft: ' /model \n' })
  351. m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
  352. expect(m.state.draft).toBe('')
  353. m.dispatch({ type: 'undo' })
  354. expect(m.state.draft).toBe(' /model \n')
  355. m.dispatch({ type: 'draft-changed', draft: '/model extra' })
  356. const rev = m.state.draftRev
  357. expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
  358. expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
  359. })
  360. it('a chip elsewhere in the draft shifts across a span consume', () => {
  361. const m = new InputMachine()
  362. m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
  363. m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
  364. m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
  365. expect(m.state.draft).toBe(P)
  366. expect(m.state.occurrences[0]?.offset).toBe(0)
  367. })
  368. })
  369. describe('input-machine: undo / redo', () => {
  370. it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
  371. const m = new InputMachine()
  372. m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
  373. m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
  374. m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
  375. m.dispatch({ type: 'undo' })
  376. expect(m.state.draft).toBe('')
  377. m.dispatch({ type: 'redo' })
  378. expect(m.state.draft).toBe('abc')
  379. })
  380. it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
  381. let t = 0
  382. const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
  383. m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
  384. t = 900
  385. m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
  386. t = 2500 // beyond the window from the previous char
  387. m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
  388. m.dispatch({ type: 'undo' })
  389. expect(m.state.draft).toBe('ab')
  390. m.dispatch({ type: 'undo' })
  391. expect(m.state.draft).toBe('')
  392. })
  393. it('non-contiguous or multi-char edits never merge into a typing run', () => {
  394. const m = new InputMachine()
  395. m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
  396. m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
  397. m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
  398. m.dispatch({ type: 'undo' })
  399. expect(m.state.draft).toBe('ba')
  400. m.dispatch({ type: 'undo' })
  401. expect(m.state.draft).toBe('a')
  402. m.dispatch({ type: 'undo' })
  403. expect(m.state.draft).toBe('')
  404. })
  405. it('a new transaction cuts the redo chain', () => {
  406. const m = new InputMachine()
  407. m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
  408. m.dispatch({ type: 'undo' })
  409. m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
  410. expect(m.dispatch({ type: 'redo' })).toEqual([])
  411. expect(m.state.draft).toBe('z')
  412. })
  413. it('undo on an empty log and redo on an empty chain are no-ops', () => {
  414. const m = new InputMachine()
  415. expect(m.dispatch({ type: 'undo' })).toEqual([])
  416. expect(m.dispatch({ type: 'redo' })).toEqual([])
  417. })
  418. it('the log ring caps at 100 transactions', () => {
  419. let t = 0
  420. const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
  421. let draft = ''
  422. for (let i = 0; i < 110; i += 1) {
  423. draft += 'x'
  424. m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
  425. }
  426. for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
  427. expect(m.state.draft).toBe('x'.repeat(10))
  428. expect(m.dispatch({ type: 'undo' })).toEqual([])
  429. expect(m.state.draft).toBe('x'.repeat(10))
  430. })
  431. it('undo restores the occurrence table with the draft (chip resurrection)', () => {
  432. const m = new InputMachine()
  433. m.dispatch({ type: 'draft-changed', draft: '@wor' })
  434. m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
  435. m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
  436. expect(m.state.occurrences).toEqual([])
  437. m.dispatch({ type: 'undo' })
  438. expect(m.state.draft).toBe(P)
  439. expect(m.state.occurrences).toHaveLength(1)
  440. })
  441. it('a committed submit clears the log: undo cannot resurrect sent content', () => {
  442. const m = new InputMachine()
  443. const { attempt } = enterSubmitting(m, 'goal', 'x')
  444. m.dispatch({ type: 'submit-settled', attempt, ok: true })
  445. expect(m.state.draft).toBe('')
  446. expect(m.dispatch({ type: 'undo' })).toEqual([])
  447. expect(m.state.draft).toBe('')
  448. })
  449. })
  450. describe('input-machine: paste plane', () => {
  451. it('paste replaces the selection as one transaction and opens a match attempt', () => {
  452. const m = new InputMachine()
  453. m.dispatch({ type: 'draft-changed', draft: 'abc' })
  454. m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
  455. expect(m.state.draft).toBe('aXYc')
  456. expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
  457. m.dispatch({ type: 'undo' })
  458. expect(m.state.draft).toBe('abc')
  459. })
  460. it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
  461. const m = new InputMachine()
  462. m.dispatch({ type: 'paste-begin', text: `x${P}y`, selection: { start: 0, end: 0 } })
  463. expect(m.state.draft).toBe('xy')
  464. expect(m.state.occurrences).toEqual([])
  465. })
  466. it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
  467. const m = new InputMachine()
  468. m.dispatch({ type: 'draft-changed', draft: 'hi ' })
  469. m.dispatch({
  470. type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
  471. components: [{ start: 0, end: 6, reference: refOf('alpha') }],
  472. })
  473. expect(m.state.draft).toBe(`hi ${P} x`)
  474. expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
  475. expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: 6 })
  476. m.dispatch({ type: 'undo' })
  477. expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
  478. })
  479. it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
  480. const m = new InputMachine()
  481. m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
  482. expect(m.state.paste?.attemptId).toBe(1)
  483. m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
  484. expect(m.state.draft).toBe(`${P} rest`)
  485. expect(m.state.occurrences).toHaveLength(1)
  486. m.dispatch({ type: 'undo' })
  487. expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
  488. m.dispatch({ type: 'undo' })
  489. expect(m.state.draft).toBe('')
  490. })
  491. it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
  492. const m = new InputMachine()
  493. m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
  494. m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
  495. expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
  496. m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
  497. expect(m.state.draft).toBe(`${P} ${P}`)
  498. expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
  499. expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
  500. })
  501. it('a stale span CAS drops one upgrade without ending the attempt', () => {
  502. const m = new InputMachine()
  503. m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
  504. const preSpan = spanOf(m, 7, 12)
  505. m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
  506. expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
  507. expect(m.state.occurrences).toHaveLength(1)
  508. expect(m.state.paste).toBeDefined()
  509. })
  510. it('any new input transaction ends the attempt; late upgrades drop whole', () => {
  511. const m = new InputMachine()
  512. m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
  513. m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
  514. expect(m.state.paste).toBeUndefined()
  515. expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
  516. expect(m.state.occurrences).toEqual([])
  517. })
  518. it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
  519. const a = new InputMachine()
  520. a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
  521. a.dispatch({ type: 'invalidate-paste' })
  522. expect(a.state.paste).toBeUndefined()
  523. const b = new InputMachine()
  524. b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
  525. b.dispatch({ type: 'enter', mode: 'queue' })
  526. expect(b.state.paste).toBeUndefined()
  527. })
  528. it('a mismatched attemptId is dropped (superseded paste)', () => {
  529. const m = new InputMachine()
  530. m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
  531. m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
  532. expect(m.state.paste?.attemptId).toBe(2)
  533. expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
  534. expect(m.state.occurrences).toEqual([])
  535. })
  536. })
  537. describe('input-machine: set-invalid styling bits', () => {
  538. it('flags exactly the listed occurrences without a transaction', () => {
  539. const m = new InputMachine()
  540. m.dispatch({ type: 'draft-changed', draft: '/alp' })
  541. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
  542. m.dispatch({ type: 'draft-changed', draft: `${P} /bet`, editRange: { start: 1, end: 1, insertedLength: 5 } })
  543. m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 2, 6) })
  544. const rev = m.state.draftRev
  545. m.dispatch({ type: 'set-invalid', invalidIds: [1] })
  546. expect(m.state.draftRev).toBe(rev)
  547. expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
  548. // Recovery: the same source/ref resolving again clears the bit.
  549. m.dispatch({ type: 'set-invalid', invalidIds: [] })
  550. expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
  551. })
  552. it('a no-change call keeps the table reference (no spurious publish)', () => {
  553. const m = new InputMachine()
  554. m.dispatch({ type: 'draft-changed', draft: '/alp' })
  555. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
  556. const table = m.state.occurrences
  557. expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
  558. expect(m.state.occurrences).toBe(table)
  559. })
  560. })
  561. describe('input-machine: projectClipboard', () => {
  562. it('expands each placeholder to its occurrence clipboardText in draft order', () => {
  563. const m = new InputMachine()
  564. m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
  565. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
  566. m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
  567. m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
  568. expect(m.state.draft).toBe(`use ${P} then ${P}`)
  569. expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
  570. })
  571. it('is the identity on a chip-free draft', () => {
  572. expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
  573. })
  574. })
  575. describe('decorations: scanTextRefs (decision 21)', () => {
  576. const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
  577. ['/', ['commit-helper', 'fixture-demo']],
  578. ['@', ['worker-1']],
  579. ])
  580. it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
  581. expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
  582. { start: 0, end: 14, trigger: '/' },
  583. { start: 20, end: 29, trigger: '@' },
  584. ])
  585. })
  586. it('a cold (empty) lexicon scans nothing', () => {
  587. expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
  588. })
  589. it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
  590. expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
  591. })
  592. it('word boundary: a trigger glued to text never matches', () => {
  593. expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
  594. expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
  595. })
  596. it('tokens never cross a newline; a token straight after one matches', () => {
  597. expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
  598. { start: 5, end: 19, trigger: '/' },
  599. ])
  600. })
  601. it('deriveDecorations threads the lexicon through as textRefs', () => {
  602. const m = new InputMachine()
  603. m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
  604. expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
  605. { start: 4, end: 18, trigger: '/' },
  606. ])
  607. })
  608. })
  609. describe('input-machine: decorations', () => {
  610. it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
  611. const m = new InputMachine()
  612. m.dispatch({ type: 'draft-changed', draft: '/alp' })
  613. m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
  614. m.dispatch({ type: 'set-invalid', invalidIds: [1] })
  615. expect(deriveDecorations(m.state)).toEqual({
  616. token: null,
  617. chips: [{ occurrenceId: 1, offset: 0, label: 'alpha', invalid: true }],
  618. textRefs: [],
  619. hint: null,
  620. })
  621. })
  622. it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
  623. const m = new InputMachine()
  624. m.dispatch({ type: 'draft-changed', draft: '/go' })
  625. m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
  626. expect(deriveDecorations(m.state)).toEqual({
  627. token: { start: 0, end: 6 },
  628. chips: [],
  629. textRefs: [],
  630. hint: 'objective',
  631. })
  632. m.dispatch({ type: 'draft-changed', draft: '/goal x' })
  633. expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
  634. })
  635. it('the token range persists through submitting; a hintless claim never ghosts', () => {
  636. const m = new InputMachine()
  637. enterSubmitting(m, 'goal', '')
  638. expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
  639. })
  640. })
  641. describe('input-machine: claimed lifecycle', () => {
  642. it('breaking startsWith(token) auto-releases back to plain', () => {
  643. const m = new InputMachine()
  644. m.dispatch({ type: 'draft-changed', draft: '/go' })
  645. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  646. m.dispatch({ type: 'draft-changed', draft: '/goal make' })
  647. expect(m.state.phase).toBe('claimed')
  648. m.dispatch({ type: 'draft-changed', draft: '/goa make' })
  649. expect(m.state.phase).toBe('plain')
  650. expect(m.state.claim).toBeUndefined()
  651. expect(m.state.draft).toBe('/goa make')
  652. })
  653. it('explicit release returns to plain when nothing is in flight', () => {
  654. const m = new InputMachine()
  655. m.dispatch({ type: 'draft-changed', draft: '/go' })
  656. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  657. expect(m.dispatch({ type: 'release' })).toEqual([])
  658. expect(m.state.phase).toBe('plain')
  659. expect(m.state.claim).toBeUndefined()
  660. })
  661. it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
  662. const m = new InputMachine()
  663. const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
  664. expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
  665. m.dispatch({ type: 'submit-settled', attempt, ok: true })
  666. expect(m.state.draft).toBe('')
  667. expect(claim.token).toBe('/goal ')
  668. })
  669. })
  670. describe('input-machine: submitting transaction', () => {
  671. it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
  672. const m = new InputMachine()
  673. enterSubmitting(m, 'goal', 'x')
  674. expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
  675. expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
  676. expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
  677. })
  678. it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
  679. const m = new InputMachine()
  680. m.dispatch({ type: 'draft-changed', draft: '@wor' })
  681. m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
  682. m.dispatch({ type: 'draft-changed', draft: `${P}/go`, editRange: { start: 1, end: 1, insertedLength: 3 } })
  683. m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
  684. m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
  685. m.dispatch({ type: 'draft-changed', draft: '/goal go' })
  686. const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
  687. const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
  688. expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
  689. expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
  690. expect(m.state.claim).toBeUndefined()
  691. })
  692. it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
  693. const m = new InputMachine()
  694. const { attempt } = enterSubmitting(m, 'goal', 'x')
  695. const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
  696. expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
  697. expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
  698. expect(m.state.claim?.token).toBe('/goal ')
  699. })
  700. it('rollback with a deviated draft only notices — the newer input wins', () => {
  701. const m = new InputMachine()
  702. const { attempt } = enterSubmitting(m, 'goal', 'x')
  703. m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
  704. const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
  705. expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
  706. expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
  707. expect(m.state.claim).toBeUndefined()
  708. })
  709. it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
  710. // '\n\n/goal x' round-trips through adjudication; the whitespace prefix
  711. // would instantly break the claimed watch, so rollback lands plain.
  712. const m = new InputMachine()
  713. const attempt = enterAdjudicating(m, '\n\n/goal x')
  714. m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
  715. const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
  716. expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
  717. expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
  718. })
  719. it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
  720. const m = new InputMachine()
  721. const { attempt: first } = enterSubmitting(m, 'goal', 'x')
  722. m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
  723. const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
  724. expect(second.seq).not.toBe(first.seq)
  725. expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
  726. expect(m.state.phase).toBe('submitting')
  727. m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
  728. expect(m.state.draft).toBe('')
  729. })
  730. it('release mid-flight aborts the attempt and later settles are dropped', () => {
  731. const m = new InputMachine()
  732. const { attempt } = enterSubmitting(m, 'goal', 'x')
  733. expect(m.dispatch({ type: 'release' })).toEqual([])
  734. expect(attempt.signal.aborted).toBe(true)
  735. expect(m.state.phase).toBe('plain')
  736. expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
  737. expect(m.state.draft).toBe('/goal x')
  738. })
  739. })
  740. describe('input-machine: per-session isolation', () => {
  741. it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
  742. const a = new InputMachine()
  743. const b = new InputMachine()
  744. const { attempt } = enterSubmitting(a, 'goal', 'from A')
  745. // B stays fully live while A holds its lock.
  746. b.dispatch({ type: 'draft-changed', draft: '/mo' })
  747. b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
  748. expect(b.state.phase).toBe('claimed')
  749. expect(a.state.phase).toBe('submitting')
  750. // A's commit falls back to A alone.
  751. a.dispatch({ type: 'submit-settled', attempt, ok: true })
  752. expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
  753. expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
  754. })
  755. })