| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927 |
- /**
- * InputMachine unit account: the submit
- * plane (adjudication, span CAS, drift
- * guard, anti-backwash), plus the occurrence table (shift / whole-chip
- * deletion / same-name independence), the self-managed undo log (typing
- * coalescing, paste two-stage undo, redo chain), consume-token guards, the
- * paste attempt lifecycle, projectClipboard, and the decoration projection.
- * Pure event sequences — no React, no DOM, no ambient clock.
- */
- import { describe, expect, it } from 'vitest'
- import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
- import type { InputEffect, SubmitAttempt } from '../src/client/contract/input.ts'
- import {
- InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText,
- } from '../src/client/input/machine.ts'
- import { deriveDecorations, scanTextRefs } from '../src/client/skeleton/decorations.ts'
- const LEGACY_PLACEHOLDER = PLACEHOLDER
- function claimOf(name: string, hint?: string): CommandClaim {
- return {
- token: `/${name} `,
- ...(hint !== undefined ? { hint } : {}),
- submit: async () => ({ kind: 'success' }),
- }
- }
- function refOf(name: string, source = 'skill'): ReferenceInsert {
- return { source, ref: name, label: name, clipboardText: `/${name}` }
- }
- function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
- return { start, end, draftRev: m.state.draftRev }
- }
- function effectAt<T extends InputEffect['type']>(
- effects: readonly InputEffect[], index: number, type: T,
- ): Extract<InputEffect, { type: T }> {
- const e = effects[index]
- expect(e?.type).toBe(type)
- return e as Extract<InputEffect, { type: T }>
- }
- /** Drive plain → adjudicating and hand back the minted attempt. */
- function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
- m.dispatch({ type: 'draft-changed', draft })
- const fx = m.dispatch({ type: 'enter', mode })
- return effectAt(fx, 0, 'adjudicate').attempt
- }
- /** Drive plain → claimed → submitting and hand back attempt + claim. */
- function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
- const claim = claimOf(name)
- m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
- m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
- m.dispatch({ type: 'draft-changed', draft: claim.token + args })
- const fx = m.dispatch({ type: 'enter', mode: 'queue' })
- return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
- }
- function staleAttempt(): SubmitAttempt {
- return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' }
- }
- describe('input-machine: plain × enter', () => {
- it('empty and whitespace-only drafts produce nothing', () => {
- const m = new InputMachine()
- expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
- m.dispatch({ type: 'draft-changed', draft: ' \n ' })
- expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
- expect(m.state.phase).toBe('plain')
- })
- it('non-command text falls to the default sink', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'hello world' })
- const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
- expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' })
- expect(effect.attempt.draftSnapshot).toBe('hello world')
- expect(m.state.phase).toBe('submitting')
- })
- it('retains an explicit steer mode on the default sink effect', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'steer now' })
- expect(effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink'))
- .toMatchObject({ draft: 'steer now', mode: 'steer' })
- })
- it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/goal x' })
- const fx = m.dispatch({ type: 'enter', mode: 'queue' })
- const eff = effectAt(fx, 0, 'adjudicate')
- expect(eff.draft).toBe('/goal x')
- expect(eff.attempt.draftSnapshot).toBe('/goal x')
- expect(eff.attempt.signal.aborted).toBe(false)
- expect(m.state.phase).toBe('adjudicating')
- })
- it('leading is judged after trim including newlines', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
- expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
- })
- it('a non-whitespace prefix before "/" is not leading — default sink', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
- expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink'))
- .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' })
- })
- })
- describe('input-machine: adjudication outcomes', () => {
- it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '/goal x\ny')
- const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
- const eff = effectAt(fx, 0, 'begin-submit')
- expect(eff.args).toBe('x\ny')
- expect(eff.attempt.seq).toBe(attempt.seq)
- expect(m.state.phase).toBe('submitting')
- expect(m.state.claim).toEqual({ token: '/goal ' })
- })
- it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
- const a = new InputMachine()
- const attemptA = enterAdjudicating(a, '/goal')
- expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
- const b = new InputMachine()
- const attemptB = enterAdjudicating(b, '\n\n/goal x')
- expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
- })
- it('undefined outcome falls back to the default sink', () => {
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
- expect(effectAt(
- m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }),
- 0,
- 'default-sink',
- )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' })
- expect(m.state.phase).toBe('submitting')
- })
- it("'handled' lands plain with zero effects (popup shell path)", () => {
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '/model')
- expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
- expect(m.state.phase).toBe('plain')
- expect(m.state.draft).toBe('/model')
- })
- it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '/goal x')
- expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
- .toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
- expect(m.state.phase).toBe('plain')
- expect(m.state.draft).toBe('/goal x')
- })
- it('enter is a no-op while adjudicating (pending lock)', () => {
- const m = new InputMachine()
- enterAdjudicating(m, '/goal x')
- expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
- expect(m.state.phase).toBe('adjudicating')
- })
- it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
- const m = new InputMachine()
- enterAdjudicating(m, '/goal x')
- expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
- expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
- expect(m.state.phase).toBe('adjudicating')
- })
- it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '/goal x')
- m.dispatch({ type: 'release' })
- expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
- expect(m.state.phase).toBe('plain')
- })
- })
- describe('input-machine: begin-command CAS', () => {
- it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- const before = m.state.draftRev
- const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
- expect(fx).toEqual([])
- expect(m.state.draftRev).toBeGreaterThan(before)
- expect(m.state.draft).toBe('/goal ')
- expect(m.state.phase).toBe('claimed')
- expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
- })
- it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
- expect(m.state.draft).toBe('/goal ')
- m.dispatch({ type: 'draft-changed', draft: '/goal x' })
- expect(m.state.phase).toBe('claimed')
- })
- it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- const span = spanOf(m, 0, 3)
- m.dispatch({ type: 'draft-changed', draft: '/goX' })
- const rev = m.state.draftRev
- expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
- expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
- })
- it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'x /go' })
- expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
- expect(m.state.phase).toBe('plain')
- })
- it('claimed overwrites in place — no stack', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
- expect(m.state.draft).toBe('/model ')
- expect(m.state.claim?.token).toBe('/model ')
- expect(m.state.phase).toBe('claimed')
- })
- it('submitting rejects begin-command (lock)', () => {
- const m = new InputMachine()
- enterSubmitting(m, 'goal', 'x')
- expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
- expect(m.state.claim?.token).toBe('/goal ')
- expect(m.state.phase).toBe('submitting')
- })
- it('undo reverts the claim transaction and the watch releases the claim', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- m.dispatch({ type: 'undo' })
- expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
- expect(m.state.claim).toBeUndefined()
- })
- })
- describe('input-machine: insert-ref and the occurrence table', () => {
- it('valid span becomes one inline display range + one occurrence with cached projections', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
- const reference = { ...refOf('worker-1', 'reference'), appearance: 'session' as const }
- const fx = m.dispatch({
- type: 'insert-ref',
- reference,
- span: spanOf(m, 4, 8),
- })
- expect(fx).toEqual([])
- const displayText = referenceDraftText(reference)
- expect(m.state.draft).toBe(`see ${displayText} now`)
- expect(m.state.occurrences).toEqual([{
- occurrenceId: 1, source: 'reference', ref: 'worker-1', offset: 4,
- length: displayText.length,
- label: 'worker-1', appearance: 'session', clipboardText: '/worker-1',
- }])
- expect(m.state.phase).toBe('plain')
- })
- it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/alp' })
- m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
- const displayText = referenceDraftText(refOf('alpha'))
- const secondDraft = `${displayText} and /alp`
- const secondStart = secondDraft.lastIndexOf('/alp')
- m.dispatch({
- type: 'draft-changed',
- draft: secondDraft,
- editRange: { start: displayText.length, end: displayText.length + 1, insertedLength: ' and /alp'.length },
- })
- m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, secondStart, secondStart + 4) })
- expect(m.state.draft).toBe(`${displayText} and ${displayText} `)
- expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
- // Delete the first reference range whole; the second survives with its own identity.
- m.dispatch({
- type: 'draft-changed',
- draft: ` and ${displayText} `,
- editRange: { start: 0, end: displayText.length, insertedLength: 0 },
- })
- expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
- })
- it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
- m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
- expect(m.state.draft).toBe(`/goal ask ${referenceDraftText(refOf('worker-1'))} `)
- expect(m.state.phase).toBe('claimed')
- expect(m.state.occurrences).toHaveLength(1)
- })
- it('a stale draftRev no-ops: no draft change, no occurrence', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
- const span = spanOf(m, 4, 8)
- m.dispatch({ type: 'draft-changed', draft: 'see @work' })
- expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
- expect(m.state.occurrences).toEqual([])
- })
- })
- describe('input-machine: occurrence reconciliation on draft edits', () => {
- /** Machine with one reference range at offset 4 inside `see @worker-1 now`. */
- function withChip(): InputMachine {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
- m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
- return m
- }
- it('an edit before the reference shifts the offset by the length delta (explicit editRange)', () => {
- const m = withChip()
- m.dispatch({ type: 'draft-changed', draft: `I ${m.state.draft}`, editRange: { start: 0, end: 0, insertedLength: 2 } })
- expect(m.state.occurrences[0]?.offset).toBe(6)
- m.dispatch({ type: 'draft-changed', draft: m.state.draft.slice(2), editRange: { start: 0, end: 2, insertedLength: 0 } })
- expect(m.state.occurrences[0]?.offset).toBe(4)
- })
- it('an edit after the reference leaves the offset alone', () => {
- const m = withChip()
- const oldDraft = m.state.draft
- const start = oldDraft.indexOf('now')
- m.dispatch({
- type: 'draft-changed',
- draft: oldDraft.replace('now', 'later'),
- editRange: { start, end: start + 3, insertedLength: 5 },
- })
- expect(m.state.occurrences[0]?.offset).toBe(4)
- })
- it('a deletion covering the reference removes the whole occurrence', () => {
- const m = withChip()
- const occurrence = m.state.occurrences[0]!
- m.dispatch({
- type: 'draft-changed',
- draft: m.state.draft.slice(0, occurrence.offset) + m.state.draft.slice(occurrence.offset + occurrence.length),
- editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 0 },
- })
- expect(m.state.occurrences).toEqual([])
- expect(m.state.draft).toBe('see now')
- })
- it('a replacement spanning the reference removes the occurrence and keeps the replacement text', () => {
- const m = withChip()
- const occurrence = m.state.occurrences[0]!
- m.dispatch({
- type: 'draft-changed',
- draft: 'see all of it now',
- editRange: { start: occurrence.offset, end: occurrence.offset + occurrence.length, insertedLength: 9 },
- })
- expect(m.state.occurrences).toEqual([])
- })
- it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
- const m = withChip()
- m.dispatch({ type: 'draft-changed', draft: m.state.draft.replace('see ', 'see there ') })
- expect(m.state.occurrences[0]?.offset).toBe(10)
- })
- it('without editRange the diff scan detects reference deletion', () => {
- const m = withChip()
- m.dispatch({ type: 'draft-changed', draft: 'see now' })
- expect(m.state.occurrences).toEqual([])
- })
- it('an identical draft is a no-op: no revision bump, no undo entry', () => {
- const m = withChip()
- const rev = m.state.draftRev
- expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
- expect(m.state.draftRev).toBe(rev)
- })
- })
- describe('input-machine: consume-token guards', () => {
- it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/model rest' })
- const before = m.state.draftRev
- m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
- expect(m.state.draftRev).toBeGreaterThan(before)
- expect(m.state.draft).toBe('rest')
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('/model rest')
- })
- it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/model' })
- const span = spanOf(m, 0, 6)
- m.dispatch({ type: 'draft-changed', draft: '/model x' })
- const rev = m.state.draftRev
- expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
- expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
- })
- it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: ' /model \n' })
- m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
- expect(m.state.draft).toBe('')
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe(' /model \n')
- m.dispatch({ type: 'draft-changed', draft: '/model extra' })
- const rev = m.state.draftRev
- expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
- expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
- })
- it('a chip elsewhere in the draft shifts across a span consume', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
- m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
- m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
- expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
- expect(m.state.occurrences[0]?.offset).toBe(0)
- })
- })
- describe('input-machine: undo / redo', () => {
- it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
- m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
- m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('')
- m.dispatch({ type: 'redo' })
- expect(m.state.draft).toBe('abc')
- })
- it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
- let t = 0
- const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
- m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
- t = 900
- m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
- t = 2500 // beyond the window from the previous char
- m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('ab')
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('')
- })
- it('non-contiguous or multi-char edits never merge into a typing run', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
- m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
- m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('ba')
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('a')
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('')
- })
- it('a new transaction cuts the redo chain', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
- m.dispatch({ type: 'undo' })
- m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
- expect(m.dispatch({ type: 'redo' })).toEqual([])
- expect(m.state.draft).toBe('z')
- })
- it('undo on an empty log and redo on an empty chain are no-ops', () => {
- const m = new InputMachine()
- expect(m.dispatch({ type: 'undo' })).toEqual([])
- expect(m.dispatch({ type: 'redo' })).toEqual([])
- })
- it('the log ring caps at 100 transactions', () => {
- let t = 0
- const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
- let draft = ''
- for (let i = 0; i < 110; i += 1) {
- draft += 'x'
- m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
- }
- for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('x'.repeat(10))
- expect(m.dispatch({ type: 'undo' })).toEqual([])
- expect(m.state.draft).toBe('x'.repeat(10))
- })
- it('undo restores the occurrence table with the draft (chip resurrection)', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '@wor' })
- m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
- m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: m.state.draft.length, insertedLength: 0 } })
- expect(m.state.occurrences).toEqual([])
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe(`${referenceDraftText(refOf('w'))} `)
- expect(m.state.occurrences).toHaveLength(1)
- })
- it('a committed submit clears the log: undo cannot resurrect sent content', () => {
- const m = new InputMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- m.dispatch({ type: 'submit-settled', attempt, ok: true })
- expect(m.state.draft).toBe('')
- expect(m.dispatch({ type: 'undo' })).toEqual([])
- expect(m.state.draft).toBe('')
- })
- it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'hello' })
- const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
- m.dispatch({ type: 'draft-changed', draft: 'hello world' })
- m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true })
- expect(m.state.draft).toBe(' world')
- const n = new InputMachine()
- n.dispatch({ type: 'draft-changed', draft: 'hello' })
- const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
- n.dispatch({ type: 'draft-changed', draft: 'hXello' })
- n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true })
- expect(n.state.draft).toBe('')
- })
- })
- describe('input-machine: paste plane', () => {
- it('paste replaces the selection as one transaction and opens a match attempt', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'abc' })
- m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
- expect(m.state.draft).toBe('aXYc')
- expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('abc')
- })
- it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: `x${LEGACY_PLACEHOLDER}y`, selection: { start: 0, end: 0 } })
- expect(m.state.draft).toBe('xy')
- expect(m.state.occurrences).toEqual([])
- })
- it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'hi ' })
- m.dispatch({
- type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
- components: [{ start: 0, end: 6, reference: refOf('alpha') }],
- })
- expect(m.state.draft).toBe(`hi ${referenceDraftText(refOf('alpha'))} x`)
- expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
- expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: m.state.draft.length })
- m.dispatch({ type: 'undo' })
- expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
- })
- it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
- expect(m.state.paste?.attemptId).toBe(1)
- m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
- expect(m.state.draft).toBe(`${referenceDraftText(refOf('alpha'))} rest`)
- expect(m.state.occurrences).toHaveLength(1)
- m.dispatch({ type: 'undo' })
- expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
- m.dispatch({ type: 'undo' })
- expect(m.state.draft).toBe('')
- })
- it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
- m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
- const alpha = referenceDraftText(refOf('alpha'))
- expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: alpha.length + 6 })
- const betaStart = m.state.draft.indexOf('/beta')
- m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, betaStart, betaStart + 5), reference: refOf('beta') })
- expect(m.state.draft).toBe(`${alpha} ${referenceDraftText(refOf('beta'))} `)
- expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
- expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: m.state.draft.length })
- })
- it('a stale span CAS drops one upgrade without ending the attempt', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
- const preSpan = spanOf(m, 7, 12)
- m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
- expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
- expect(m.state.occurrences).toHaveLength(1)
- expect(m.state.paste).toBeDefined()
- })
- it('any new input transaction ends the attempt; late upgrades drop whole', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
- m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
- expect(m.state.paste).toBeUndefined()
- expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
- expect(m.state.occurrences).toEqual([])
- })
- it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
- const a = new InputMachine()
- a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
- a.dispatch({ type: 'invalidate-paste' })
- expect(a.state.paste).toBeUndefined()
- const b = new InputMachine()
- b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
- b.dispatch({ type: 'enter', mode: 'queue' })
- expect(b.state.paste).toBeUndefined()
- })
- it('a mismatched attemptId is dropped (superseded paste)', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
- m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
- expect(m.state.paste?.attemptId).toBe(2)
- expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
- expect(m.state.occurrences).toEqual([])
- })
- })
- describe('input-machine: set-invalid styling bits', () => {
- it('flags exactly the listed occurrences without a transaction', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/alp' })
- m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
- const alpha = referenceDraftText(refOf('alpha'))
- m.dispatch({
- type: 'draft-changed',
- draft: `${alpha} /bet`,
- editRange: { start: alpha.length + 1, end: alpha.length + 1, insertedLength: 5 },
- })
- m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, alpha.length + 1, alpha.length + 5) })
- const rev = m.state.draftRev
- m.dispatch({ type: 'set-invalid', invalidIds: [1] })
- expect(m.state.draftRev).toBe(rev)
- expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
- // Recovery: the same source/ref resolving again clears the bit.
- m.dispatch({ type: 'set-invalid', invalidIds: [] })
- expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
- })
- it('a no-change call keeps the table reference (no spurious publish)', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/alp' })
- m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
- const table = m.state.occurrences
- expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
- expect(m.state.occurrences).toBe(table)
- })
- })
- describe('input-machine: projectClipboard', () => {
- it('expands each reference range to its occurrence clipboardText in draft order', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
- m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
- const alpha = referenceDraftText(refOf('alpha'))
- const secondDraft = `use ${alpha} then /bet`
- const secondStart = secondDraft.lastIndexOf('/bet')
- m.dispatch({
- type: 'draft-changed',
- draft: secondDraft,
- editRange: { start: 4 + alpha.length + 1, end: 4 + alpha.length + 1, insertedLength: 'then /bet'.length },
- })
- m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, secondStart, secondStart + 4) })
- expect(m.state.draft).toBe(`use ${alpha} then ${referenceDraftText(refOf('beta'))} `)
- expect(projectClipboard(m.state)).toBe('use /alpha then /beta ')
- })
- it('is the identity on a chip-free draft', () => {
- expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
- })
- })
- describe('decorations: scanTextRefs', () => {
- const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
- ['/', ['commit-helper', 'fixture-demo']],
- ['@', ['worker-1']],
- ])
- it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
- expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
- { start: 0, end: 14, trigger: '/' },
- { start: 20, end: 29, trigger: '@' },
- ])
- })
- it('a cold (empty) lexicon scans nothing', () => {
- expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
- })
- it('recognizes directory paths independently of the dynamic lexicon', () => {
- expect(scanTextRefs('open @src/components/ or @"docs/design notes/', new Map())).toEqual([
- { start: 5, end: 21, trigger: '@', appearance: 'folder' },
- { start: 25, end: 45, trigger: '@', appearance: 'folder' },
- ])
- })
- it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
- expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
- })
- it('word boundary: a trigger glued to text never matches', () => {
- expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
- expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
- })
- it('tokens never cross a newline; a token straight after one matches', () => {
- expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
- { start: 5, end: 19, trigger: '/' },
- ])
- })
- it('deriveDecorations threads the lexicon through as textRefs', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
- expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
- { start: 4, end: 18, trigger: '/' },
- ])
- })
- })
- describe('input-machine: decorations', () => {
- it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/alp' })
- const reference = { ...refOf('alpha'), appearance: 'file' as const }
- m.dispatch({
- type: 'insert-ref',
- reference,
- span: spanOf(m, 0, 4),
- })
- m.dispatch({ type: 'set-invalid', invalidIds: [1] })
- expect(deriveDecorations(m.state)).toEqual({
- token: null,
- chips: [{
- occurrenceId: 1,
- offset: 0,
- length: referenceDraftText(reference).length,
- text: referenceDraftText(reference),
- label: 'alpha',
- appearance: 'file',
- invalid: true,
- }],
- textRefs: [],
- hint: null,
- })
- })
- it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
- expect(deriveDecorations(m.state)).toEqual({
- token: { start: 0, end: 6 },
- chips: [],
- textRefs: [],
- hint: 'objective',
- })
- m.dispatch({ type: 'draft-changed', draft: '/goal x' })
- expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
- })
- it('the token range persists through submitting; a hintless claim never ghosts', () => {
- const m = new InputMachine()
- enterSubmitting(m, 'goal', '')
- expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
- })
- })
- describe('input-machine: claimed lifecycle', () => {
- it('breaking startsWith(token) auto-releases back to plain', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- m.dispatch({ type: 'draft-changed', draft: '/goal make' })
- expect(m.state.phase).toBe('claimed')
- m.dispatch({ type: 'draft-changed', draft: '/goa make' })
- expect(m.state.phase).toBe('plain')
- expect(m.state.claim).toBeUndefined()
- expect(m.state.draft).toBe('/goa make')
- })
- it('explicit release returns to plain when nothing is in flight', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '/go' })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- expect(m.dispatch({ type: 'release' })).toEqual([])
- expect(m.state.phase).toBe('plain')
- expect(m.state.claim).toBeUndefined()
- })
- it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
- const m = new InputMachine()
- const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
- expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
- m.dispatch({ type: 'submit-settled', attempt, ok: true })
- expect(m.state.draft).toBe('')
- expect(claim.token).toBe('/goal ')
- })
- })
- describe('input-machine: submitting transaction', () => {
- it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
- const m = new InputMachine()
- enterSubmitting(m, 'goal', 'x')
- expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
- expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
- expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
- })
- it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
- const m = new InputMachine()
- m.dispatch({ type: 'draft-changed', draft: '@wor' })
- m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
- const refLength = referenceDraftText(refOf('worker-1')).length
- m.dispatch({
- type: 'draft-changed',
- draft: `${referenceDraftText(refOf('worker-1'))}/go`,
- editRange: { start: refLength + 1, end: refLength + 1, insertedLength: 3 },
- })
- m.dispatch({
- type: 'draft-changed',
- draft: '/go',
- editRange: { start: 0, end: refLength + 1, insertedLength: 0 },
- })
- m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
- m.dispatch({ type: 'draft-changed', draft: '/goal go' })
- const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
- const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
- expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
- expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
- expect(m.state.claim).toBeUndefined()
- })
- it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
- const m = new InputMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
- expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
- expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
- expect(m.state.claim?.token).toBe('/goal ')
- })
- it('rollback with a deviated draft only notices — the newer input wins', () => {
- const m = new InputMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
- const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
- expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
- expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
- expect(m.state.claim).toBeUndefined()
- })
- it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
- // '\n\n/goal x' round-trips through adjudication; the whitespace prefix
- // would instantly break the claimed watch, so rollback lands plain.
- const m = new InputMachine()
- const attempt = enterAdjudicating(m, '\n\n/goal x')
- m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
- const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
- expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
- expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
- })
- it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
- const m = new InputMachine()
- const { attempt: first } = enterSubmitting(m, 'goal', 'x')
- m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
- const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
- expect(second.seq).not.toBe(first.seq)
- expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
- expect(m.state.phase).toBe('submitting')
- m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
- expect(m.state.draft).toBe('')
- })
- it('release mid-flight aborts the attempt and later settles are dropped', () => {
- const m = new InputMachine()
- const { attempt } = enterSubmitting(m, 'goal', 'x')
- expect(m.dispatch({ type: 'release' })).toEqual([])
- expect(attempt.signal.aborted).toBe(true)
- expect(m.state.phase).toBe('plain')
- expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
- expect(m.state.draft).toBe('/goal x')
- })
- })
- describe('input-machine: per-session isolation', () => {
- it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
- const a = new InputMachine()
- const b = new InputMachine()
- const { attempt } = enterSubmitting(a, 'goal', 'from A')
- // B stays fully live while A holds its lock.
- b.dispatch({ type: 'draft-changed', draft: '/mo' })
- b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
- expect(b.state.phase).toBe('claimed')
- expect(a.state.phase).toBe('submitting')
- // A's commit falls back to A alone.
- a.dispatch({ type: 'submit-settled', attempt, ok: true })
- expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
- expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
- })
- })
|