input-machine.client.spec.ts 42 KB

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