backstep 0.1.9 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,14 @@ const CLI = '/opt/backstep/cli.js'
13
13
  // `undo` answers with `undo`. Every argv the mod runs is recorded.
14
14
  function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
15
15
  const runs: string[][] = []
16
+ const logs: string[] = []
16
17
  const prompts: { text: string; context?: readonly string[] }[] = []
17
18
  on('session.cwd', () => ({ value: '/repo' }))
18
19
  on('session.id', () => ({ value: 's1' }))
20
+ on('ui.log', (_, e) => {
21
+ logs.push(e.text)
22
+ return { value: undefined }
23
+ })
19
24
  on('process.run', (_, e) => {
20
25
  runs.push([...e.argv])
21
26
  const r = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? { exitCode: 0, stdout: JSON.stringify(shell) } : { exitCode: 0, stdout: '' }
@@ -30,7 +35,7 @@ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: strin
30
35
  const { Text } = $.ui.resolve(e)
31
36
  return <Text>engine</Text>
32
37
  })
33
- return { runs, prompts }
38
+ return { runs, prompts, logs }
34
39
  }
35
40
 
36
41
  test('an edit is snapshotted and the band offers to undo the turn', { options: { cli: CLI } }, async ($, on) => {
@@ -74,15 +79,18 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
74
79
 
75
80
  await clock.advance(1000)
76
81
  await pressed
82
+ await clock.advance(500)
77
83
  expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn.')
78
84
  expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
79
85
 
80
- // The band folds away, and with the turn undone nothing is left to offer.
81
- await clock.advance(5000)
82
- expect(await ui.find({ type: 'Text', text: /nothing to undo yet/ })).toBeTruthy()
83
- await ui.unmount()
86
+ // The list stays up, however long, until the next message.
87
+ await clock.advance(60_000)
88
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))).toBeTruthy()
84
89
 
85
90
  await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
91
+ // Then the band goes back to the undo line; with the turn undone, nothing is left to offer.
92
+ expect(await ui.find({ type: 'Text', text: /Rewound/ })).toBeFalsy()
93
+ await ui.unmount()
86
94
  const note = prompts.at(-1)?.context?.at(-1) ?? ''
87
95
  expect(note).toContain('These changes were reverted:\n- src/a.ts restored')
88
96
 
@@ -110,14 +118,14 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
110
118
  const pressed = ui.press({ key: 'undo' })
111
119
  await clock.advance(1000)
112
120
  await pressed
121
+ await clock.advance(500)
113
122
  expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
114
123
  expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
115
124
 
116
- // Still undoable once the band folds, since the turn was not fully rewound.
117
- await clock.advance(10000)
125
+ // Still undoable after the next message, since the turn was not fully rewound.
126
+ await $.prompt.submit({ text: 'hmm', wait: false, origin: { kind: 'composer' } })
118
127
  expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
119
128
  await ui.unmount()
120
- expect(prompts).toHaveLength(1)
121
129
  })
122
130
 
123
131
  test('with nothing to undo the band still holds the key, and a press says there is nothing to undo', async ($, on) => {
@@ -219,6 +227,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
219
227
  toasts.push(e.text)
220
228
  return { value: undefined }
221
229
  })
230
+ on('ui.log', () => ({ value: undefined }))
222
231
  on('session.cwd', () => ({ value: '/repo' }))
223
232
  on('session.id', () => ({ value: 's1' }))
224
233
  on('process.run', (_, e) => {
@@ -531,6 +540,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
531
540
 
532
541
  await ui.press({ key: 'undo' })
533
542
  expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
543
+ expect((await ui.find({ type: 'Text', text: /"fix it" · 1 edit to 1 file/ }))).toBeTruthy()
534
544
  expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
535
545
  await clock.advance(6000)
536
546
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
@@ -606,3 +616,157 @@ test('about to change a Supabase project that is not connected, Backstep asks fi
606
616
  await $.tool.call({ tool: 'Bash', command })
607
617
  expect(asked).toHaveLength(1)
608
618
  })
619
+
620
+ // An engine where each shell command's plan is given by a test, and undo answers as told.
621
+ function shellEngine(on: On, plans: (command: string) => object, undo: object[]) {
622
+ const runs: string[][] = []
623
+ const logs: string[] = []
624
+ on('session.cwd', () => ({ value: '/repo' }))
625
+ on('session.id', () => ({ value: 's1' }))
626
+ on('ui.toast', () => ({ value: undefined }))
627
+ on('ui.log', (_, e) => {
628
+ logs.push(e.text)
629
+ return { value: undefined }
630
+ })
631
+ on('process.run', (_, e) => {
632
+ runs.push([...e.argv])
633
+ const command = e.argv.at(-1) ?? ''
634
+ const out = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? plans(command) : e.argv.includes('db-check') ? { ask: null } : null
635
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
636
+ })
637
+ on('tool.call', { tool: 'AskUserQuestion' }, () => ({ result: { questions: [], answers: {} } }))
638
+ on('tool.call', () => ({ result: {} }))
639
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
640
+ on('ui.render', ($, e) => {
641
+ const { Text } = $.ui.resolve(e)
642
+ return <Text>engine</Text>
643
+ })
644
+ return { runs, logs }
645
+ }
646
+
647
+ test('when the last turn only committed and pushed, undo offers to take back the work it saved too', { options: { cli: CLI } }, async ($, on) => {
648
+ const clock = mock.clock(on, { now: 5000 })
649
+ const { runs } = shellEngine(on, () => ({ kind: 'outbound', reason: 'pushes to a remote', id: 'p1', undoable: true }), [])
650
+
651
+ await $.prompt.submit({ text: 'add the kanban board', wait: false, origin: { kind: 'composer' } })
652
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/board.tsx', old_string: 'a', new_string: 'b' })
653
+ await clock.advance(1000)
654
+ await $.prompt.submit({ text: 'commit and push it', wait: false, origin: { kind: 'composer' } })
655
+ await $.tool.call({ tool: 'Bash', command: 'git add -A && git commit -m "board" && git push' })
656
+
657
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
658
+ expect((await ui.find({ type: 'Text', text: /Last turn: "commit and push it"/ }))).toBeTruthy()
659
+ await ui.press({ key: 'undo' })
660
+ expect((await ui.find({ type: 'Text', text: /only saved and pushed to git/ }))).toBeTruthy()
661
+ expect((await ui.find({ type: 'Text', text: /Undo it and the turn before it "add the kanban board"\?/ }))).toBeTruthy()
662
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo both')
663
+ expect(await ui.find({ type: 'Button', key: 'just' })).toBeTruthy()
664
+
665
+ const pressed = ui.press({ key: 'undo' })
666
+ await clock.advance(1000)
667
+ await pressed
668
+ // Both turns: everything since the first one started.
669
+ expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
670
+ await ui.unmount()
671
+ })
672
+
673
+ test('after an undo, everything undone and everything that was not is listed, in the band and the conversation', { options: { cli: CLI } }, async ($, on) => {
674
+ const clock = mock.clock(on, { now: 5000 })
675
+ const { logs } = shellEngine(
676
+ on,
677
+ (command) => (command.startsWith('curl') ? { kind: 'outbound', reason: 'sends a request', id: 'r1', undoable: true } : { kind: 'write', id: 'w1', undoable: true }),
678
+ [{ id: 'w1', tool: 'Bash', ok: true, detail: 'restored /repo/src/a.ts\nremoved /repo/src/b.ts' }],
679
+ )
680
+
681
+ await $.prompt.submit({ text: 'tidy and notify', wait: false, origin: { kind: 'composer' } })
682
+ await $.tool.call({ tool: 'Bash', command: 'rm src/a.ts' })
683
+ await $.tool.call({ tool: 'Bash', command: 'curl -X POST https://hooks.example.com/notify -d done' })
684
+
685
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
686
+ await ui.press({ key: 'undo' })
687
+ const pressed = ui.press({ key: 'undo' })
688
+ await clock.advance(1000)
689
+ await pressed
690
+ await clock.advance(500)
691
+ expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
692
+ expect((await ui.find({ type: 'Text', text: /Not undone/ }))).toBeTruthy()
693
+ expect((await ui.find({ type: 'Text', text: /curl -X POST https:\/\/hooks\.example\.com\/notify -d done: sends a request/ }))).toBeTruthy()
694
+
695
+ const report = logs.find((l) => l.includes('Backstep undid'))!
696
+ expect(report).toContain('undid 2 changes from the last turn ("tidy and notify")')
697
+ expect(report).toContain('◀ src/b.ts removed')
698
+ expect(report).toContain('Not undone:\n ✗ curl -X POST')
699
+ await ui.unmount()
700
+ })
701
+
702
+ test('a GitHub repository Claude created is offered for deletion after undo, and its push needs no undo of its own', { options: { cli: CLI } }, async ($, on) => {
703
+ const clock = mock.clock(on, { now: 5000 })
704
+ const repo = { kind: 'repo', repo: 'bzells11/taskflow', url: 'https://github.com/bzells11/taskflow' }
705
+ const runs: { argv: string[]; stdin?: string }[] = []
706
+ const asked: string[] = []
707
+ const logs: string[] = []
708
+ on('session.cwd', () => ({ value: '/repo' }))
709
+ on('session.id', () => ({ value: 's1' }))
710
+ on('ui.toast', () => ({ value: undefined }))
711
+ on('ui.log', (_, e) => {
712
+ logs.push(e.text)
713
+ return { value: undefined }
714
+ })
715
+ on('process.run', (_, e) => {
716
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
717
+ const out = e.argv.includes('shell')
718
+ ? { kind: 'outbound', reason: 'changes GitHub', id: 'g1', undoable: true }
719
+ : e.argv.includes('undo')
720
+ ? [{ id: 'g1', tool: 'Bash', ok: true, detail: 'restored main', github: [repo], pushed: { root: '/repo', pushes: [{ remote: 'origin', branch: 'main', before: null, after: 'b', commits: 1, host: 'GitHub' }] } }]
721
+ : e.argv.includes('gh-undo')
722
+ ? // The first try finds gh's sign-in can't delete repositories yet.
723
+ runs.filter((r) => r.argv.includes('gh-undo')).length === 1
724
+ ? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
725
+ : { ok: true, detail: 'deleted the repository bzells11/taskflow' }
726
+ : e.argv.includes('gh-auth')
727
+ ? { ok: true, detail: 'GitHub approved it' }
728
+ : e.argv.includes('db-check')
729
+ ? { ask: null }
730
+ : null
731
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
732
+ })
733
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
734
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
735
+ asked.push(questions[0].question)
736
+ return { result: { questions, answers: { [questions[0].question]: 'Delete it' } } }
737
+ })
738
+ on('tool.call', () => ({ result: {}, text: '✓ Created repository bzells11/taskflow on GitHub' }))
739
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
740
+ on('ui.render', ($, e) => {
741
+ const { Text } = $.ui.resolve(e)
742
+ return <Text>engine</Text>
743
+ })
744
+
745
+ await $.prompt.submit({ text: 'make a github repo', wait: false, origin: { kind: 'composer' } })
746
+ await $.tool.call({ tool: 'Bash', command: 'gh repo create taskflow --public --source . --push' })
747
+ // What gh printed is noted once it has run.
748
+ const end = runs.find((r) => r.argv.includes('shell-end'))
749
+ expect(end?.stdin).toContain('Created repository bzells11/taskflow')
750
+
751
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
752
+ await ui.press({ key: 'undo' })
753
+ const pressed = ui.press({ key: 'undo' })
754
+ await clock.advance(1000)
755
+ await pressed
756
+ await clock.settle()
757
+ expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
758
+ expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
759
+ // Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
760
+ expect(runs.filter((r) => r.argv.includes('gh-undo') || r.argv.includes('gh-auth')).map((r) => r.argv.slice(3).join(' '))).toEqual([
761
+ 'gh-undo',
762
+ 'gh-auth delete_repo',
763
+ 'gh-undo',
764
+ ])
765
+ expect(logs.find((l) => l.includes('opened the approval page and copied the code'))).toBeTruthy()
766
+ expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
767
+ // The repository is gone, so its push isn't asked about.
768
+ expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
769
+ // And "changes GitHub" isn't listed as not undone, since it was offered.
770
+ expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
771
+ await ui.unmount()
772
+ })