backstep 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,432 @@
1
+ import { expect, mock, test } from 'claude-code/testing'
2
+ import type { On } from 'claude-code'
3
+
4
+ const BAND = {
5
+ plugin: 'backstep',
6
+ component: 'AbovePrompt',
7
+ props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 100, scroll: { offset: 0, bodyRows: 10 }, view: {} },
8
+ } as const
9
+
10
+ const CLI = '/opt/backstep/cli.js'
11
+
12
+ // The engine beneath the mod: a repo at /repo, and a Backstep CLI whose
13
+ // `undo` answers with `undo`. Every argv the mod runs is recorded.
14
+ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
15
+ const runs: string[][] = []
16
+ const prompts: { text: string; context?: readonly string[] }[] = []
17
+ on('session.cwd', () => ({ value: '/repo' }))
18
+ on('session.id', () => ({ value: 's1' }))
19
+ on('process.run', (_, e) => {
20
+ runs.push([...e.argv])
21
+ const r = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? { exitCode: 0, stdout: JSON.stringify(shell) } : { exitCode: 0, stdout: '' }
22
+ return { value: { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr ?? '', isStdoutTruncated: false, isStderrTruncated: false } }
23
+ })
24
+ on('tool.call', () => ({ result: {} }))
25
+ on('prompt.submit', (_, e) => {
26
+ prompts.push({ text: e.text, context: e.context })
27
+ return { text: e.text, context: e.context }
28
+ })
29
+ on('ui.render', ($, e) => {
30
+ const { Text } = $.ui.resolve(e)
31
+ return <Text>engine</Text>
32
+ })
33
+ return { runs, prompts }
34
+ }
35
+
36
+ test('an edit is snapshotted and the band offers to undo the turn', { options: { cli: CLI } }, async ($, on) => {
37
+ mock.clock(on, { now: 5000 })
38
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
39
+
40
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
41
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
42
+ await $.tool.call({ tool: 'Bash', command: 'ls' })
43
+
44
+ expect(runs).toEqual([
45
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'snap', '--session', 's1', '--tool', 'Edit', '--cwd', '/repo', '--', '/repo/src/a.ts'],
46
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'shell', '--session', 's1', '--cwd', '/repo', '--', 'ls'],
47
+ ])
48
+
49
+ for (const surface of ['terminal', 'desktop'] as const) {
50
+ const ui = await $.ui.mount({ ...BAND, surface })
51
+ expect((await ui.find({ type: 'Text', text: /Last turn/ }))?.text).toContain('1 edit to 1 file')
52
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
53
+ await ui.unmount()
54
+ }
55
+ })
56
+
57
+ test('pressing undo rewinds the turn and tells Claude on the next prompt', { options: { cli: CLI } }, async ($, on) => {
58
+ const clock = mock.clock(on, { now: 5000 })
59
+ const { runs, prompts } = engine(on, {
60
+ exitCode: 0,
61
+ stdout: JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }]),
62
+ })
63
+
64
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
65
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
66
+
67
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
68
+ const pressed = ui.press({ key: 'undo' })
69
+ await clock.settle()
70
+ expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
71
+ expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--json', '--via-claude-code'])
72
+
73
+ await clock.advance(1000)
74
+ await pressed
75
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn.')
76
+ expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
77
+
78
+ // The band folds away, and with the turn undone nothing is left to offer.
79
+ await clock.advance(5000)
80
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeFalsy()
81
+ await ui.unmount()
82
+
83
+ await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
84
+ const note = prompts.at(-1)?.context?.at(-1) ?? ''
85
+ expect(note).toContain('These changes were reverted:\n- src/a.ts restored')
86
+
87
+ // The note rides along once.
88
+ await $.prompt.submit({ text: 'and again', wait: false, origin: { kind: 'composer' } })
89
+ expect(prompts.at(-1)?.context ?? []).toEqual([])
90
+ })
91
+
92
+ test('a change that cannot be undone stops the rewind and says so', { options: { cli: CLI } }, async ($, on) => {
93
+ const clock = mock.clock(on, { now: 5000 })
94
+ const { prompts } = engine(on, {
95
+ exitCode: 1,
96
+ stdout: JSON.stringify([
97
+ { id: 'c2', tool: 'mcp__db__query', ok: true, detail: 'restored row 4' },
98
+ { id: 'c1', tool: 'mcp__db__query', ok: false, detail: 'row changed since' },
99
+ ]),
100
+ })
101
+
102
+ await $.prompt.submit({ text: 'migrate', wait: false, origin: { kind: 'composer' } })
103
+ // A server wrapped by backstep; its name isn't among this machine's connected tools.
104
+ await $.tool.call({ tool: 'mcp__db__query', sql: 'update t set x = 1' } as never)
105
+
106
+ const ui = await $.ui.mount({ ...BAND, surface: 'desktop' })
107
+ const pressed = ui.press({ key: 'undo' })
108
+ await clock.advance(1000)
109
+ await pressed
110
+ expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
111
+ expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
112
+
113
+ // Still undoable once the band folds, since the turn was not fully rewound.
114
+ await clock.advance(10000)
115
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
116
+ await ui.unmount()
117
+ expect(prompts).toHaveLength(1)
118
+ })
119
+
120
+ test('with nothing to undo the band leaves the space to the engine', async ($, on) => {
121
+ engine(on, { exitCode: 0, stdout: '[]' })
122
+
123
+ await $.prompt.submit({ text: 'hi', wait: false, origin: { kind: 'composer' } })
124
+ await $.tool.call({ tool: 'Bash', command: 'ls' })
125
+
126
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
127
+ expect((await ui.find({ type: 'Text' }))?.text).toBe('engine')
128
+ await ui.unmount()
129
+ })
130
+
131
+ test('a shell command is snapshotted and counts toward the turn', { options: { cli: CLI } }, async ($, on) => {
132
+ mock.clock(on, { now: 5000 })
133
+ engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', id: 'a1', undoable: true })
134
+
135
+ await $.prompt.submit({ text: 'clean up', wait: false, origin: { kind: 'composer' } })
136
+ await $.tool.call({ tool: 'Bash', command: 'rm -rf build' })
137
+
138
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
139
+ expect((await ui.find({ type: 'Text', text: /Last turn/ }))?.text).toContain('1 shell command')
140
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
141
+ await ui.unmount()
142
+ })
143
+
144
+ test('a command that reaches outside runs right away', { options: { cli: CLI } }, async ($, on) => {
145
+ mock.clock(on, { now: 5000 })
146
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'outbound', reason: 'pushes to a remote', id: 'a2', undoable: true })
147
+
148
+ await $.prompt.submit({ text: 'ship', wait: false, origin: { kind: 'composer' } })
149
+ const result = await $.tool.call({ tool: 'Bash', command: 'git push' })
150
+ expect(result.deny).toBeUndefined()
151
+ expect(runs.some((r) => r.includes('cancel'))).toBe(false)
152
+ })
153
+
154
+ test('servers added since setup are wrapped as a session starts', { options: { cli: CLI } }, async ($, on) => {
155
+ const runs: string[][] = []
156
+ const toasts: string[] = []
157
+ on('process.run', (_, e) => {
158
+ runs.push([...e.argv])
159
+ const stdout = e.argv.includes('connect')
160
+ ? JSON.stringify([{ service: 'supabase', state: 'covered', detail: 'already connected' }])
161
+ : e.argv.includes('status')
162
+ ? JSON.stringify({ items: [{ name: 'Supabase', level: 'connect', detail: 'not connected' }, { name: 'Notion', level: 'recorded', detail: 'x' }], suggest: [] })
163
+ : JSON.stringify([{ name: 'linear', done: true }, { name: 'youtube', done: false }])
164
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
165
+ })
166
+ on('ui.toast', (_, e) => {
167
+ toasts.push(e.text)
168
+ return { value: undefined }
169
+ })
170
+ const tools: string[] = []
171
+ on('tool.register', (_, e) => {
172
+ tools.push(e.name)
173
+ return { value: { tool: `mcp__backstep__${e.name}` } }
174
+ })
175
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
176
+
177
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
178
+ await new Promise((r) => setTimeout(r, 10))
179
+ expect(tools).toEqual(['connect'])
180
+ expect(runs).toEqual([
181
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
182
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
183
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
184
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
185
+ ])
186
+ expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
187
+ // Supabase gets its own question, and only in a project that uses it.
188
+ expect(toasts.find((t) => t.includes('Supabase'))).toBeFalsy()
189
+ })
190
+
191
+ // A claude.ai connector: Backstep can't sit in front of it, so the mod reports
192
+ // the call and, on undo, runs the rule's opposite call through Claude Code.
193
+ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean }) {
194
+ const runs: { argv: string[]; stdin?: string }[] = []
195
+ const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
196
+ const ran: Record<string, unknown>[] = []
197
+ const toasts: string[] = []
198
+ on('ui.toast', (_, e) => {
199
+ toasts.push(e.text)
200
+ return { value: undefined }
201
+ })
202
+ on('session.cwd', () => ({ value: '/repo' }))
203
+ on('session.id', () => ({ value: 's1' }))
204
+ on('process.run', (_, e) => {
205
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
206
+ const out = e.argv.includes('watch-start')
207
+ ? opts.start
208
+ : e.argv.includes('watch-end')
209
+ ? { undoable: opts.undoable }
210
+ : e.argv.includes('undo')
211
+ ? [{ id: 'w1', tool: 'create-page', ok: true, detail: 'runs archive-page', run: { server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } } }]
212
+ : null
213
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
214
+ })
215
+ on('mcp.call', (_, e) => {
216
+ calls.push({ server: e.server, tool: e.tool, args: e.args })
217
+ return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
218
+ })
219
+ on('tool.call', (_, e) => {
220
+ ran.push({ ...e })
221
+ return { result: { content: [{ type: 'text', text: '{"id":"p1"}' }] }, text: '{"id":"p1"}' }
222
+ })
223
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
224
+ on('ui.render', ($, e) => {
225
+ const { Text } = $.ui.resolve(e)
226
+ return <Text>engine</Text>
227
+ })
228
+ return { runs, calls, ran, toasts }
229
+ }
230
+
231
+ test('a connector call with an undo rule is undone through Claude Code', { options: { cli: CLI } }, async ($, on) => {
232
+ const clock = mock.clock(on, { now: 5000 })
233
+ const { runs, calls } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true })
234
+
235
+ await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
236
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan', tool_use_id: 'tu1' } as never)
237
+
238
+ expect(runs[0].argv.slice(3)).toEqual(['watch-start', '--session', 's1', '--cwd', '/repo', '--', 'mcp__claude_ai_Notion__create-page'])
239
+ expect(JSON.parse(runs[0].stdin ?? '')).toEqual({ title: 'Plan' })
240
+ expect(runs[1].argv.slice(3)).toEqual(['watch-end', 'w1'])
241
+
242
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
243
+ expect((await ui.find({ type: 'Text', text: /Last turn/ }))?.text).toContain('1 tool action')
244
+ const pressed = ui.press({ key: 'undo' })
245
+ await clock.advance(1000)
246
+ await pressed
247
+ expect(runs.find((r) => r.argv.includes('undo'))?.argv).toContain('--via-claude-code')
248
+ expect(calls).toEqual([{ server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } }])
249
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
250
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change')
251
+ await ui.unmount()
252
+ })
253
+
254
+ test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
255
+ const clock = mock.clock(on, { now: 5000 })
256
+ const { runs } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true, mcpFails: true })
257
+
258
+ await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
259
+ await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan' } as never)
260
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
261
+ const pressed = ui.press({ key: 'undo' })
262
+ await clock.advance(1000)
263
+ await pressed
264
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1', '--failed', 'no such page'])
265
+ expect((await ui.find({ type: 'Text', text: /no such page/ }))).toBeTruthy()
266
+ await ui.unmount()
267
+ })
268
+
269
+ test('a connector delete runs right away and is recorded', { options: { cli: CLI } }, async ($, on) => {
270
+ mock.clock(on, { now: 5000 })
271
+ const { runs } = connector(on, { start: { kind: 'irreversible', id: 'w2', reason: "deletes something Backstep can't bring back" }, undoable: false })
272
+
273
+ await $.prompt.submit({ text: 'tidy', wait: false, origin: { kind: 'composer' } })
274
+ const result = await $.tool.call({ tool: 'mcp__claude_ai_Notion__delete-page', id: 'p1' } as never)
275
+ expect(result.deny).toBeUndefined()
276
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['watch-end', 'w2'])
277
+ })
278
+
279
+ test('a Supabase drop runs as Backstep rewrote it', { options: { cli: CLI } }, async ($, on) => {
280
+ mock.clock(on, { now: 5000 })
281
+ const moved = 'ALTER TABLE "public"."customers" RENAME TO "w3_0_customers"'
282
+ const { ran } = connector(on, { start: { kind: 'reversible', id: 'w3', args: { query: moved } }, undoable: true })
283
+
284
+ await $.prompt.submit({ text: 'clean up', wait: false, origin: { kind: 'composer' } })
285
+ await $.tool.call({ tool: 'mcp__claude_ai_Supabase__execute_sql', project_id: 'abc', query: 'DROP TABLE customers' } as never)
286
+ expect(ran[0]).toMatchObject({ project_id: 'abc', query: moved })
287
+ })
288
+
289
+ test('an unconnected Supabase change says how to fix it, once', { options: { cli: CLI } }, async ($, on) => {
290
+ mock.clock(on, { now: 5000 })
291
+ const { toasts } = connector(on, { start: { kind: 'write', id: 'w4', hint: 'supabase' }, undoable: false })
292
+
293
+ await $.prompt.submit({ text: 'clean up', wait: false, origin: { kind: 'composer' } })
294
+ await $.tool.call({ tool: 'mcp__claude_ai_Supabase__execute_sql', project_id: 'abc', query: 'DELETE FROM t' } as never)
295
+ await $.tool.call({ tool: 'mcp__claude_ai_Supabase__execute_sql', project_id: 'abc', query: 'DELETE FROM u' } as never)
296
+ expect(toasts).toHaveLength(1)
297
+ expect(toasts[0]).toContain('Ask Claude to connect Backstep to Supabase')
298
+ })
299
+
300
+ test('asking Claude to connect runs backstep connect, with the connection string on stdin', { options: { cli: CLI } }, async ($, on) => {
301
+ const runs: { argv: string[]; stdin?: string }[] = []
302
+ const toasts: string[] = []
303
+ on('session.cwd', () => ({ value: '/repo' }))
304
+ on('ui.toast', (_, e) => {
305
+ toasts.push(e.text)
306
+ return { value: undefined }
307
+ })
308
+ on('process.run', (_, e) => {
309
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
310
+ const stdout = JSON.stringify([{ service: 'supabase', state: 'connected', detail: 'connected project abc' }])
311
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
312
+ })
313
+
314
+ const url = 'postgresql://postgres:secret@db.abcdefghijklmnopqrst.supabase.co:5432/postgres'
315
+ const r = await $.tool.call({ tool: 'mcp__backstep__connect', service: 'supabase', connection_string: url } as never)
316
+ expect(runs).toHaveLength(1)
317
+ expect(runs[0].argv.slice(3)).toEqual(['connect', 'supabase', '--dir', '/repo', '--json', '--stdin'])
318
+ expect(runs[0].argv.join(' ')).not.toContain('secret')
319
+ expect(runs[0].stdin).toBe(url)
320
+ expect(r.result).toBe('Supabase (connected): connected project abc')
321
+ expect(toasts).toHaveLength(1)
322
+ })
323
+
324
+ // A project that uses Supabase, not connected: Backstep asks, and only the answer connects it.
325
+ function supabaseProject(on: On, answer: string, connectResult: object) {
326
+ const runs: string[][] = []
327
+ const toasts: string[] = []
328
+ const asked: string[] = []
329
+ on('process.run', (_, e) => {
330
+ runs.push([...e.argv])
331
+ const stdout = e.argv.includes('--auto')
332
+ ? JSON.stringify([{ service: 'supabase', state: 'ask', detail: "this project uses Supabase, but Backstep isn't connected to it" }])
333
+ : e.argv.includes('status')
334
+ ? JSON.stringify({ items: [], suggest: [] })
335
+ : e.argv.includes('connect') && e.argv.includes('--json')
336
+ ? JSON.stringify([connectResult])
337
+ : '[]'
338
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
339
+ })
340
+ on('ui.toast', (_, e) => {
341
+ toasts.push(e.text)
342
+ return { value: undefined }
343
+ })
344
+ on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
345
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
346
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
347
+ asked.push(questions[0].question)
348
+ return { result: { questions, answers: { [questions[0].question]: answer } } }
349
+ })
350
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
351
+ return { runs, toasts, asked }
352
+ }
353
+
354
+ const settle = () => new Promise((r) => setTimeout(r, 20))
355
+
356
+ test('a Supabase project that is not connected is asked about, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
357
+ const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
358
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
359
+ await settle()
360
+ expect(asked).toHaveLength(1)
361
+ expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --json')).toBe(true)
362
+ expect(toasts.find((t) => t.includes('connected project abc'))).toBeTruthy()
363
+ })
364
+
365
+ test('Not now connects nothing', { options: { cli: CLI } }, async ($, on) => {
366
+ const { runs } = supabaseProject(on, 'Not now', {})
367
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
368
+ await settle()
369
+ expect(runs.filter((r) => r.includes('connect') && !r.includes('--auto'))).toHaveLength(0)
370
+ })
371
+
372
+ test("Don't ask for this project remembers it", { options: { cli: CLI } }, async ($, on) => {
373
+ const { runs } = supabaseProject(on, "Don't ask for this project", {})
374
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
375
+ await settle()
376
+ expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --dismiss')).toBe(true)
377
+ })
378
+
379
+ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { options: { cli: CLI } }, async ($, on) => {
380
+ const clock = mock.clock(on, { now: 5000 })
381
+ const push = { remote: 'origin', branch: 'main', before: 'aaa', after: 'bbb', commits: 2, host: 'GitHub' }
382
+ const runs: { argv: string[]; stdin?: string }[] = []
383
+ const toasts: string[] = []
384
+ const asked: string[] = []
385
+ on('session.cwd', () => ({ value: '/repo' }))
386
+ on('session.id', () => ({ value: 's1' }))
387
+ on('ui.toast', (_, e) => {
388
+ toasts.push(e.text)
389
+ return { value: undefined }
390
+ })
391
+ on('process.run', (_, e) => {
392
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
393
+ const out = e.argv.includes('shell')
394
+ ? { kind: 'outbound', reason: 'pushes to a remote', id: 'p1', undoable: true }
395
+ : e.argv.includes('undo')
396
+ ? [{ id: 'p1', tool: 'Bash', ok: true, detail: 'restored main', pushed: { root: '/repo', pushes: [push] } }]
397
+ : e.argv.includes('revert-push')
398
+ ? { ok: true, detail: "pushed a commit to origin/main that undoes Claude's 2 commits" }
399
+ : null
400
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
401
+ })
402
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
403
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
404
+ asked.push(questions[0].question)
405
+ return { result: { questions, answers: { [questions[0].question]: 'Push the undo' } } }
406
+ })
407
+ on('tool.call', () => ({ result: {} }))
408
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
409
+ on('ui.render', ($, e) => {
410
+ const { Text } = $.ui.resolve(e)
411
+ return <Text>engine</Text>
412
+ })
413
+
414
+ await $.prompt.submit({ text: 'ship it', wait: false, origin: { kind: 'composer' } })
415
+ await $.tool.call({ tool: 'Bash', command: 'git push' })
416
+ // The push is noted once it has run.
417
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1'])
418
+
419
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
420
+ const pressed = ui.press({ key: 'undo' })
421
+ await clock.advance(1000)
422
+ await pressed
423
+ await clock.settle()
424
+ expect(asked[0]).toBe('Claude pushed 2 commits to main on GitHub. Undo them there too?')
425
+ const revert = runs.find((r) => r.argv.includes('revert-push'))
426
+ expect(revert?.argv.slice(3)).toEqual(['revert-push', '--root', '/repo'])
427
+ expect(JSON.parse(revert?.stdin ?? '{}')).toEqual(push)
428
+ expect(toasts.find((t) => t.includes('undoes Claude'))).toBeTruthy()
429
+ // The band doesn't call the push lost while it's being offered back.
430
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).not.toContain("couldn't be undone")
431
+ await ui.unmount()
432
+ })