submit-machine.client.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. /**
  2. * SubmitMachine behavior: enter routing, adjudication outcomes, the claimed
  3. * lifecycle and its integrity watch, settlement (commit-draft and claim
  4. * re-entry decisions), anti-backwash, and per-session isolation. Text-edit
  5. * semantics live in the editor (lexical-editor-core spec) — the machine only
  6. * observes drafts through event payloads.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import type { CommandClaim } from '../src/client/contract/input.ts'
  10. import type { InputEffect, SubmitAttempt } from '../src/client/contract/input.ts'
  11. import { SubmitMachine } from '../src/client/input/machine.ts'
  12. import { scanTextRefs } from '../src/client/input/decorations.ts'
  13. function claimOf(name: string, hint?: string): CommandClaim {
  14. return {
  15. name,
  16. token: `/${name} `,
  17. ...(hint !== undefined ? { hint } : {}),
  18. submit: async () => ({ kind: 'success' }),
  19. }
  20. }
  21. function effectAt<T extends InputEffect['type']>(
  22. effects: readonly InputEffect[], index: number, type: T,
  23. ): Extract<InputEffect, { type: T }> {
  24. const e = effects[index]
  25. expect(e?.type).toBe(type)
  26. return e as Extract<InputEffect, { type: T }>
  27. }
  28. /** Drive plain → adjudicating and hand back the minted attempt. */
  29. function enterAdjudicating(m: SubmitMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
  30. const fx = m.dispatch({ type: 'enter', mode, draft })
  31. return effectAt(fx, 0, 'adjudicate').attempt
  32. }
  33. /** Drive plain → claimed → submitting and hand back attempt + claim. */
  34. function enterSubmitting(m: SubmitMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
  35. const claim = claimOf(name)
  36. m.dispatch({ type: 'claim', claim })
  37. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: claim.token + args })
  38. return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
  39. }
  40. function staleAttempt(): SubmitAttempt {
  41. return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
  42. }
  43. describe('submit-machine: plain × enter', () => {
  44. it('empty and whitespace-only drafts produce nothing', () => {
  45. const m = new SubmitMachine()
  46. expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '' })).toEqual([])
  47. expect(m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n ' })).toEqual([])
  48. expect(m.state.phase).toBe('plain')
  49. })
  50. it('non-command text falls to the default sink with the draft and mode', () => {
  51. const m = new SubmitMachine()
  52. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
  53. const sink = effectAt(fx, 0, 'default-sink')
  54. expect(sink.draft).toBe('hello')
  55. expect(sink.mode).toBe('queue')
  56. expect(sink.attempt.draftSnapshot).toBe('hello')
  57. expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('hello')
  58. expect(m.state.phase).toBe('plain')
  59. })
  60. it('retains an explicit steer mode on the default sink effect', () => {
  61. const m = new SubmitMachine()
  62. const fx = m.dispatch({ type: 'enter', mode: 'steer', draft: 'go' })
  63. expect(effectAt(fx, 0, 'default-sink').mode).toBe('steer')
  64. })
  65. it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
  66. const m = new SubmitMachine()
  67. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal write tests' })
  68. const adjudicate = effectAt(fx, 0, 'adjudicate')
  69. expect(adjudicate.draft).toBe('/goal write tests')
  70. expect(adjudicate.attempt.draftSnapshot).toBe('/goal write tests')
  71. expect(adjudicate.attempt.signal.aborted).toBe(false)
  72. expect(m.state.phase).toBe('adjudicating')
  73. })
  74. it('leading is judged after trim including newlines', () => {
  75. const m = new SubmitMachine()
  76. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: ' \n /goal x' })
  77. expect(effectAt(fx, 0, 'adjudicate').draft).toBe(' \n /goal x')
  78. })
  79. it('a non-whitespace prefix before "/" is not leading — default sink', () => {
  80. const m = new SubmitMachine()
  81. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'see /goal' })
  82. expect(effectAt(fx, 0, 'default-sink').draft).toBe('see /goal')
  83. })
  84. })
  85. describe('submit-machine: adjudication outcomes', () => {
  86. it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
  87. const m = new SubmitMachine()
  88. const attempt = enterAdjudicating(m, '/goal write x\nand y')
  89. const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
  90. const begin = effectAt(fx, 0, 'begin-submit')
  91. expect(begin.args).toBe('write x\nand y')
  92. expect(m.state.phase).toBe('submitting')
  93. expect(m.state.claim?.token).toBe('/goal ')
  94. })
  95. it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
  96. const m = new SubmitMachine()
  97. const attempt = enterAdjudicating(m, '/goal')
  98. const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
  99. expect(effectAt(fx, 0, 'begin-submit').args).toBe('')
  100. const m2 = new SubmitMachine()
  101. const attempt2 = enterAdjudicating(m2, ' /goal args')
  102. const fx2 = m2.dispatch({ type: 'adjudicated', attempt: attempt2, outcome: { claim: claimOf('goal') } })
  103. expect(effectAt(fx2, 0, 'begin-submit').args).toBe('args')
  104. })
  105. it('undefined outcome falls back to the default sink with the snapshot', () => {
  106. const m = new SubmitMachine()
  107. const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
  108. const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })
  109. const sink = effectAt(fx, 0, 'default-sink')
  110. expect(sink.draft).toBe('/unknown thing')
  111. expect(sink.mode).toBe('steer')
  112. expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('/unknown thing')
  113. expect(m.state.phase).toBe('plain')
  114. })
  115. it("'handled' lands plain with zero effects (popup shell path)", () => {
  116. const m = new SubmitMachine()
  117. const attempt = enterAdjudicating(m, '/model')
  118. expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
  119. expect(m.state.phase).toBe('plain')
  120. })
  121. it('adjudication failure notices and keeps plain — no silent downgrade', () => {
  122. const m = new SubmitMachine()
  123. const attempt = enterAdjudicating(m, '/goal x')
  124. const fx = m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' })
  125. expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'warmup failed' })
  126. expect(m.state.phase).toBe('plain')
  127. })
  128. it('enter is a no-op while adjudicating (pending lock)', () => {
  129. const m = new SubmitMachine()
  130. enterAdjudicating(m, '/goal x')
  131. expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
  132. expect(m.state.phase).toBe('adjudicating')
  133. })
  134. it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
  135. const m = new SubmitMachine()
  136. enterAdjudicating(m, '/goal x')
  137. expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: undefined })).toEqual([])
  138. expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
  139. expect(m.state.phase).toBe('adjudicating')
  140. })
  141. it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
  142. const m = new SubmitMachine()
  143. const attempt = enterAdjudicating(m, '/goal x')
  144. m.dispatch({ type: 'release' })
  145. expect(attempt.signal.aborted).toBe(true)
  146. expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
  147. expect(m.state.phase).toBe('plain')
  148. })
  149. })
  150. describe('submit-machine: claimed lifecycle', () => {
  151. it('the claim event enters claimed and snapshots hint and images bits', () => {
  152. const m = new SubmitMachine()
  153. m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } })
  154. expect(m.state.phase).toBe('claimed')
  155. expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true })
  156. })
  157. it('claimed overwrites in place — no stack', () => {
  158. const m = new SubmitMachine()
  159. m.dispatch({ type: 'claim', claim: claimOf('goal') })
  160. m.dispatch({ type: 'claim', claim: claimOf('plan') })
  161. expect(m.state.claim?.token).toBe('/plan ')
  162. expect(m.state.phase).toBe('claimed')
  163. })
  164. it('submitting rejects the claim event (lock)', () => {
  165. const m = new SubmitMachine()
  166. enterSubmitting(m, 'goal', 'x')
  167. m.dispatch({ type: 'claim', claim: claimOf('plan') })
  168. expect(m.state.claim?.token).toBe('/goal ')
  169. expect(m.state.phase).toBe('submitting')
  170. })
  171. it('editing the command name releases back to plain', () => {
  172. const m = new SubmitMachine()
  173. m.dispatch({ type: 'claim', claim: claimOf('goal') })
  174. m.dispatch({ type: 'draft-changed', draft: '/goal args fine' })
  175. expect(m.state.phase).toBe('claimed')
  176. m.dispatch({ type: 'draft-changed', draft: '/goa' })
  177. expect(m.state.phase).toBe('plain')
  178. expect(m.state.claim).toBeUndefined()
  179. })
  180. it.each([
  181. ['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '],
  182. ['feedback', '/feedback '], ['feedback', '/反馈 '],
  183. ])('retains %s as %s without its separator and submits an empty argument', (name, token) => {
  184. const m = new SubmitMachine()
  185. m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } })
  186. for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) {
  187. m.dispatch({ type: 'draft-changed', draft })
  188. expect(m.state.phase).toBe('claimed')
  189. expect(m.state.claim?.name).toBe(name)
  190. }
  191. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() })
  192. const begin = effectAt(fx, 0, 'begin-submit')
  193. expect(begin.args).toBe('')
  194. m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() })
  195. expect(m.state.phase).toBe('claimed')
  196. expect(m.state.claim?.name).toBe(name)
  197. })
  198. it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => {
  199. const m = new SubmitMachine()
  200. m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } })
  201. m.dispatch({ type: 'draft-changed', draft })
  202. expect(m.state.phase).toBe('plain')
  203. expect(m.state.claim).toBeUndefined()
  204. })
  205. it('explicit release returns to plain when nothing is in flight', () => {
  206. const m = new SubmitMachine()
  207. m.dispatch({ type: 'claim', claim: claimOf('goal') })
  208. m.dispatch({ type: 'release' })
  209. expect(m.state.phase).toBe('plain')
  210. expect(m.state.claim).toBeUndefined()
  211. })
  212. it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
  213. const m = new SubmitMachine()
  214. m.dispatch({ type: 'claim', claim: claimOf('goal') })
  215. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal line one\nline two' })
  216. expect(effectAt(fx, 0, 'begin-submit').args).toBe('line one\nline two')
  217. })
  218. })
  219. describe('submit-machine: submitting transaction', () => {
  220. it('enter and claim are locked while submitting; draft-changed is recorded without leaving submitting', () => {
  221. const m = new SubmitMachine()
  222. enterSubmitting(m, 'goal', 'x')
  223. expect(m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })).toEqual([])
  224. m.dispatch({ type: 'draft-changed', draft: 'typed during flight' })
  225. expect(m.state.phase).toBe('submitting')
  226. })
  227. it('commit emits commit-draft with the snapshot, releases the claim, and relays the outcome text', () => {
  228. const m = new SubmitMachine()
  229. const { attempt } = enterSubmitting(m, 'goal', 'x')
  230. const fx = m.dispatch({
  231. type: 'submit-settled', attempt, ok: true, draft: '/goal x',
  232. outcome: { kind: 'success', text: 'goal saved' },
  233. })
  234. expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x')
  235. expect(effectAt(fx, 1, 'notice')).toMatchObject({ level: 'info', text: 'goal saved' })
  236. expect(m.state.phase).toBe('plain')
  237. expect(m.state.claim).toBeUndefined()
  238. })
  239. it('an error-kind outcome text relays as an error notice on success=false settles', () => {
  240. const m = new SubmitMachine()
  241. const { attempt } = enterSubmitting(m, 'goal', 'x')
  242. const fx = m.dispatch({
  243. type: 'submit-settled', attempt, ok: false, draft: 'deviated',
  244. outcome: { kind: 'error', text: 'rejected' },
  245. })
  246. expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'rejected' })
  247. expect(m.state.phase).toBe('plain')
  248. })
  249. it('rollback with an undeviated draft keeps the claim and re-enters claimed', () => {
  250. const m = new SubmitMachine()
  251. const { attempt } = enterSubmitting(m, 'goal', 'x')
  252. m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: '/goal x', message: 'transport' })
  253. expect(m.state.phase).toBe('claimed')
  254. expect(m.state.claim?.token).toBe('/goal ')
  255. })
  256. it('rollback with a deviated draft only notices — the newer input wins', () => {
  257. const m = new SubmitMachine()
  258. const { attempt } = enterSubmitting(m, 'goal', 'x')
  259. const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: 'rewritten', message: 'transport' })
  260. expect(effectAt(fx, 0, 'notice')).toMatchObject({ level: 'error', text: 'transport' })
  261. expect(m.state.phase).toBe('plain')
  262. expect(m.state.claim).toBeUndefined()
  263. })
  264. it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
  265. const m = new SubmitMachine()
  266. const attempt = enterAdjudicating(m, ' /goal x')
  267. m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
  268. m.dispatch({ type: 'submit-settled', attempt, ok: false, draft: ' /goal x', message: 'nope' })
  269. // The snapshot carries leading whitespace the token never had: plain, claim cleared.
  270. expect(m.state.phase).toBe('plain')
  271. expect(m.state.claim).toBeUndefined()
  272. })
  273. it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
  274. const m = new SubmitMachine()
  275. const { attempt: first } = enterSubmitting(m, 'goal', 'x')
  276. m.dispatch({ type: 'submit-settled', attempt: first, ok: false, draft: '/goal x', message: 'try again' })
  277. const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: '/goal x' })
  278. const second = effectAt(fx, 0, 'begin-submit').attempt
  279. expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true, draft: '/goal x' })).toEqual([])
  280. expect(m.state.phase).toBe('submitting')
  281. m.dispatch({ type: 'submit-settled', attempt: second, ok: true, draft: '/goal x' })
  282. expect(m.state.phase).toBe('plain')
  283. })
  284. it('release mid-flight aborts the attempt and later settles are dropped', () => {
  285. const m = new SubmitMachine()
  286. const { attempt } = enterSubmitting(m, 'goal', 'x')
  287. m.dispatch({ type: 'release' })
  288. expect(attempt.signal.aborted).toBe(true)
  289. expect(m.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '' })).toEqual([])
  290. expect(m.state.phase).toBe('plain')
  291. })
  292. it('send-committed clears unconditionally (image-only sends have no draft to retain)', () => {
  293. const m = new SubmitMachine()
  294. const fx = m.dispatch({ type: 'send-committed' })
  295. expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBeNull()
  296. const busy = new SubmitMachine()
  297. enterSubmitting(busy, 'goal', 'x')
  298. expect(busy.dispatch({ type: 'send-committed' })).toEqual([])
  299. })
  300. })
  301. describe('submit-machine: per-session isolation', () => {
  302. it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
  303. const a = new SubmitMachine()
  304. const b = new SubmitMachine()
  305. const { attempt } = enterSubmitting(a, 'goal', 'x')
  306. const fx = b.dispatch({ type: 'enter', mode: 'queue', draft: 'hello' })
  307. expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello')
  308. a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' })
  309. expect(a.state.phase).toBe('plain')
  310. expect(b.state.phase).toBe('plain')
  311. })
  312. })
  313. describe('decorations: scanTextRefs', () => {
  314. const lexicon: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
  315. ['/', ['commit-helper', 'goal'] as readonly string[]],
  316. ['@', ['research'] as readonly string[]],
  317. ])
  318. it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
  319. const out = scanTextRefs('/goal then @research and /commit-helper', lexicon)
  320. expect(out.map(r => [r.start, r.end, r.trigger])).toEqual([
  321. [0, 5, '/'], [11, 20, '@'], [25, 39, '/'],
  322. ])
  323. })
  324. it('a cold (empty) lexicon scans nothing lexicon-based', () => {
  325. expect(scanTextRefs('/goal x', new Map())).toEqual([])
  326. })
  327. it('recognizes directory paths independently of the dynamic lexicon', () => {
  328. const out = scanTextRefs('see @src/x/ now', new Map())
  329. expect(out).toEqual([{ start: 4, end: 11, trigger: '@' }])
  330. })
  331. it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
  332. expect(scanTextRefs('/research @goal', lexicon)).toEqual([])
  333. })
  334. it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => {
  335. expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([])
  336. })
  337. it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => {
  338. expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([])
  339. })
  340. it('word boundary: a trigger glued to text never matches', () => {
  341. expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([])
  342. })
  343. it('tokens never cross a newline; a token straight after one matches', () => {
  344. const out = scanTextRefs('a\n/goal', lexicon)
  345. expect(out).toEqual([{ start: 2, end: 7, trigger: '/' }])
  346. })
  347. })