input-machine.spec.ts 38 KB

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