Skip to content

Commit af3876a

Browse files
committed
Rewind the conversation on /undo, staged so a redo can lift it (part 3 of #944)
/undo reverted files while the model kept its memory of the turn, so the agent would look at the disk, not find the file it believed it had just written, and write it again - the undo undid itself. A turn now records where it started in both memories, and an undo stages a cut at that point: nothing is deleted, the model's history is slice-copied on its way into createRunConfig (the one place a run is built), and /redo lifts the mark. A turn that runs afterwards absorbs the cut and clears it. The picker half - hiding the reverted tail on screen, prefilling the composer, and choosing code-only vs code-and-conversation - is the next PR.
1 parent 1e0d3ac commit af3876a

6 files changed

Lines changed: 466 additions & 5 deletions

File tree

‎cli/src/app.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ export const App = ({
213213
closeUndoHistory()
214214
try {
215215
const message =
216-
(await undoToRecord(getCurrentChatId(), projectRoot, recordId)) ??
216+
(await undoToRecord(getCurrentChatId(), projectRoot, recordId, {
217+
// Both halves by default: reverting the files while the model keeps
218+
// remembering the turn is what made it write them back again.
219+
conversation: true,
220+
})) ??
217221
'Could not undo — the snapshot store is unavailable.'
218222
useChatStore.getState().setMessages((prev) => [
219223
...prev,

‎cli/src/hooks/use-send-message.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
setCurrentChatId,
99
} from '../project-files'
1010
import { beginUndoTurn, isLatestUndoTurn } from '../state/undo-guards'
11-
import { recordUndoEntry } from '../state/undo-store'
11+
import { applyRewind, lastUserMessageIndex } from '../state/undo-rewind'
12+
import {
13+
clearRewindBoundary,
14+
getRewindBoundary,
15+
recordUndoEntry,
16+
} from '../state/undo-store'
17+
18+
import type { RewindAnchor } from '../state/undo-rewind'
1219
import { createStreamController } from './stream-state'
1320
import { useChatStore } from '../state/chat-store'
1421
import {
@@ -589,6 +596,9 @@ export const useSendMessage = ({
589596
// can run after a newer turn has already started (Esc releases the chain
590597
// lock first), and by then this turn's diff is no longer only its own.
591598
let undoTurnStamp: number | null = null
599+
// Where the conversation stood when this turn started, recorded with the
600+
// entry so an undo can rewind the model as well as the worktree.
601+
let undoTurnAnchor: RewindAnchor | null = null
592602

593603
// Checkpoint the turn to disk immediately so that killing the process
594604
// (closed terminal, crash) can't lose the user's prompt, then keep the
@@ -666,6 +676,11 @@ export const useSendMessage = ({
666676
priorByok.revision === selectedByok.revision,
667677
)
668678
: !byok
679+
// A staged conversation rewind is applied here, at the one place a run
680+
// is built: the model's history is what this argument becomes. Nothing
681+
// was deleted to stage it, so /redo can still lift it, and a chat that
682+
// never rewound passes the very same state through.
683+
const rewindBoundary = getRewindBoundary(runChatId)
669684
const runConfig = createRunConfig({
670685
logger,
671686
agent: resolvedAgent,
@@ -674,7 +689,10 @@ export const useSendMessage = ({
674689
// A persisted run has a non-secret source pin. Never resume its
675690
// transcript after switching to Freebuff or another BYOK revision.
676691
previousRunState: canResumePreviousRun
677-
? previousRunStateRef.current
692+
? applyRewind(
693+
previousRunStateRef.current,
694+
rewindBoundary?.historyLength ?? null,
695+
)
678696
: null,
679697
agentDefinitions,
680698
eventHandlerState,
@@ -758,6 +776,19 @@ export const useSendMessage = ({
758776
undoSnapshotHash = await trackSnapshot(getProjectRoot())
759777
if (undoSnapshotHash) {
760778
undoTurnStamp = beginUndoTurn(runChatId)
779+
// Read here, as the turn starts and before its own run can append
780+
// anything, so "the last prompt" is this turn's.
781+
const transcriptIndex = lastUserMessageIndex(
782+
useChatStore.getState().messages,
783+
)
784+
if (transcriptIndex >= 0) {
785+
undoTurnAnchor = {
786+
historyLength:
787+
previousRunStateRef.current?.sessionState?.mainAgentState
788+
.messageHistory.length ?? 0,
789+
transcriptIndex,
790+
}
791+
}
761792
}
762793
}
763794
} catch (error) {
@@ -796,6 +827,11 @@ export const useSendMessage = ({
796827
// and JSON.stringify of the (unbounded) transcript through proxy
797828
// traps is several times slower.
798829
saveChatState(runState, useChatStore.getState().messages, runChatDir)
830+
831+
// This run was built from the cut history, so the cut is baked into
832+
// the state just persisted and the staged mark has done its job. An
833+
// interrupted or failed run keeps it: nothing absorbed the cut.
834+
if (rewindBoundary) clearRewindBoundary(runChatId)
799835
}
800836
handleRunCompletion({
801837
runState,
@@ -910,6 +946,7 @@ export const useSendMessage = ({
910946
hashBefore: undoSnapshotHash,
911947
files: undoFiles,
912948
message: content,
949+
...(undoTurnAnchor ? { anchor: undoTurnAnchor } : {}),
913950
})
914951
}
915952
} catch (error) {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { anchorOf, applyRewind, lastUserMessageIndex } from '../undo-rewind'
4+
5+
import type { ChatMessage } from '../../types/chat'
6+
import type { Message } from '@codebuff/common/types/messages/codebuff-message'
7+
import type { RunState } from '@codebuff/sdk'
8+
9+
const message = (variant: 'user' | 'ai', content = 'x'): ChatMessage =>
10+
({
11+
id: `${variant}-${content}`,
12+
variant,
13+
content,
14+
timestamp: '2026-01-01T00:00:00.000Z',
15+
}) as ChatMessage
16+
17+
/** A message-shaped stand-in: only its identity matters to these helpers. */
18+
const fakeMessage = (id: string): Message => ({ id }) as unknown as Message
19+
20+
/** Only the part of a run state these helpers read is real. */
21+
const runStateWithHistory = (ids: string[]): RunState =>
22+
({
23+
output: { type: 'allMessages', value: ids },
24+
traceSessionId: 'trace-1',
25+
sessionState: {
26+
mainAgentState: { messageHistory: ids.map(fakeMessage) },
27+
},
28+
}) as unknown as RunState
29+
30+
describe('lastUserMessageIndex', () => {
31+
test('finds the prompt a turn started from, not the newest ai reply', () => {
32+
const messages = [
33+
message('user', 'one'),
34+
message('ai', 'reply'),
35+
message('user', 'two'),
36+
message('ai', 'reply'),
37+
message('ai', 'still streaming'),
38+
]
39+
expect(lastUserMessageIndex(messages)).toBe(2)
40+
})
41+
42+
test('is -1 when the transcript holds no prompt yet', () => {
43+
expect(lastUserMessageIndex([])).toBe(-1)
44+
expect(lastUserMessageIndex([message('ai', 'hello')])).toBe(-1)
45+
})
46+
})
47+
48+
describe('anchorOf', () => {
49+
test('reads an anchor a record carries', () => {
50+
expect(anchorOf({ anchor: { historyLength: 4, transcriptIndex: 2 } })).toEqual(
51+
{ historyLength: 4, transcriptIndex: 2 },
52+
)
53+
})
54+
55+
test('an entry recorded before the anchor existed has none', () => {
56+
expect(anchorOf({})).toBeNull()
57+
})
58+
59+
test('refuses a nonsense anchor instead of cutting at a guess', () => {
60+
expect(anchorOf({ anchor: { historyLength: -1, transcriptIndex: 0 } })).toBeNull()
61+
expect(anchorOf({ anchor: { historyLength: 1.5, transcriptIndex: 0 } })).toBeNull()
62+
expect(anchorOf({ anchor: { historyLength: 3, transcriptIndex: -2 } })).toBeNull()
63+
expect(
64+
anchorOf({
65+
anchor: { historyLength: 3, transcriptIndex: Number.NaN },
66+
}),
67+
).toBeNull()
68+
})
69+
})
70+
71+
describe('applyRewind', () => {
72+
const original = runStateWithHistory(['m1', 'm2', 'm3', 'm4'])
73+
74+
test('cuts the model history back to where the turn started', () => {
75+
const rewound = applyRewind(original, 2)
76+
expect(rewound?.sessionState?.mainAgentState.messageHistory).toEqual([
77+
fakeMessage('m1'),
78+
fakeMessage('m2'),
79+
])
80+
})
81+
82+
test('leaves the caller the full history it still persists', () => {
83+
// The full state lives in a ref and is written to disk; a cut that mutated
84+
// it would make /redo impossible even though nothing was ever deleted.
85+
applyRewind(original, 1)
86+
expect(original.sessionState?.mainAgentState.messageHistory).toHaveLength(4)
87+
})
88+
89+
test('is a no-op, same reference, when there is nothing to cut', () => {
90+
expect(applyRewind(original, null)).toBe(original)
91+
expect(applyRewind(original, 4)).toBe(original)
92+
expect(applyRewind(original, 9)).toBe(original)
93+
expect(applyRewind(null, 2)).toBeNull()
94+
})
95+
96+
test('an unstarted chat cuts to an empty history rather than throwing', () => {
97+
// historyLength 0 is the first turn of a chat: everything the model had is
98+
// that turn, so the cut leaves it with nothing to remember.
99+
expect(
100+
applyRewind(original, 0)?.sessionState?.mainAgentState.messageHistory,
101+
).toEqual([])
102+
})
103+
104+
test('leaves a state with no history alone', () => {
105+
const bare = { output: { type: 'allMessages', value: [] } } as unknown as RunState
106+
expect(applyRewind(bare, 0)).toBe(bare)
107+
})
108+
})

‎cli/src/state/__tests__/undo-store.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
trackSnapshot,
2323
} from '../../utils/undo-snapshot'
2424
import {
25+
clearRewindBoundary,
26+
getRewindBoundary,
2527
loadUndoState,
2628
peekRedo,
2729
peekUndo,
@@ -30,10 +32,14 @@ import {
3032
pushRedo,
3133
pushUndo,
3234
recordUndoEntry,
35+
redoToRecord,
36+
setRewindBoundary,
3337
sweepAnchors,
3438
undoToRecord,
3539
} from '../undo-store'
3640

41+
import type { RewindAnchor } from '../undo-rewind'
42+
3743
const CHAT_ID = 'undo-store-test-chat'
3844

3945
let projectDir: string
@@ -262,6 +268,128 @@ describe('snapshot anchors', () => {
262268
})
263269
})
264270

271+
describe('the conversation rewind', () => {
272+
/**
273+
* The journal as persisted. Read raw on purpose: a staged cut is a promise
274+
* about what is on disk, and a test that only asked the getter could pass
275+
* while the write silently dropped it.
276+
*/
277+
const readJournal = (): {
278+
rewind?: {
279+
recordId: string
280+
historyLength: number
281+
transcriptIndex: number
282+
createdAt: string
283+
}
284+
} =>
285+
JSON.parse(
286+
readFileSync(
287+
path.join(getProjectDataDir(), 'chats', CHAT_ID, 'undo.json'),
288+
'utf8',
289+
),
290+
)
291+
292+
/** A turn that rewrites a file, recorded with the anchor it captured. */
293+
const recordTurn = async (name: string, anchor?: RewindAnchor) => {
294+
const file = path.join(projectDir, name)
295+
writeFileSync(file, 'the user wrote this\n')
296+
const hash = await trackSnapshot(projectDir)
297+
writeFileSync(file, 'the agent changed it\n')
298+
recordUndoEntry(CHAT_ID, {
299+
hashBefore: hash!,
300+
files: [name],
301+
message: 'a turn worth rewinding',
302+
...(anchor ? { anchor } : {}),
303+
})
304+
return { file, id: peekUndo(CHAT_ID)!.id }
305+
}
306+
307+
test('an undo takes the turn out of the model conversation too', async () => {
308+
const { file, id } = await recordTurn('rewritten.txt', {
309+
historyLength: 3,
310+
transcriptIndex: 1,
311+
})
312+
313+
const message = await undoToRecord(CHAT_ID, projectDir, id, {
314+
conversation: true,
315+
})
316+
317+
// Both halves: the file is back, and the model no longer remembers writing
318+
// it — which is what stops it from writing it a second time.
319+
expect(readFileSync(file, 'utf8')).toBe('the user wrote this\n')
320+
expect(message).toContain('↶')
321+
expect(readJournal().rewind).toEqual({
322+
recordId: id,
323+
historyLength: 3,
324+
transcriptIndex: 1,
325+
createdAt: expect.any(String),
326+
})
327+
})
328+
329+
test('a file-only undo stages no conversation cut', async () => {
330+
const { id } = await recordTurn('files-only.txt', {
331+
historyLength: 2,
332+
transcriptIndex: 0,
333+
})
334+
335+
const message = await undoToRecord(CHAT_ID, projectDir, id)
336+
337+
expect(message).not.toContain('↶')
338+
expect(readJournal().rewind).toBeUndefined()
339+
expect(getRewindBoundary(CHAT_ID)).toBeNull()
340+
})
341+
342+
test('an entry with no anchor reverts its files and does not cut', async () => {
343+
const { file, id } = await recordTurn('unanchored.txt')
344+
345+
const message = await undoToRecord(CHAT_ID, projectDir, id, {
346+
conversation: true,
347+
})
348+
349+
// A guess is worse than no cut: the file half still did its job.
350+
expect(readFileSync(file, 'utf8')).toBe('the user wrote this\n')
351+
expect(message).not.toContain('↶')
352+
expect(getRewindBoundary(CHAT_ID)).toBeNull()
353+
})
354+
355+
test('a turn recorded while a cut is staged does not lift it', async () => {
356+
setRewindBoundary(CHAT_ID, {
357+
recordId: 'r1',
358+
historyLength: 4,
359+
transcriptIndex: 2,
360+
createdAt: new Date().toISOString(),
361+
})
362+
363+
recordUndoEntry(CHAT_ID, {
364+
hashBefore: 'later',
365+
files: ['later.txt'],
366+
message: 'a newer turn',
367+
anchor: { historyLength: 6, transcriptIndex: 4 },
368+
})
369+
370+
expect(getRewindBoundary(CHAT_ID)?.historyLength).toBe(4)
371+
})
372+
373+
test('a redo lifts the staged cut', async () => {
374+
const { id } = await recordTurn('redone.txt', {
375+
historyLength: 1,
376+
transcriptIndex: 0,
377+
})
378+
await undoToRecord(CHAT_ID, projectDir, id, { conversation: true })
379+
expect(getRewindBoundary(CHAT_ID)).not.toBeNull()
380+
381+
await redoToRecord(CHAT_ID, projectDir, peekRedo(CHAT_ID)!.id)
382+
383+
expect(getRewindBoundary(CHAT_ID)).toBeNull()
384+
})
385+
386+
test('lifting a cut that is not there is safe', () => {
387+
expect(getRewindBoundary(CHAT_ID)).toBeNull()
388+
clearRewindBoundary(CHAT_ID)
389+
expect(getRewindBoundary(CHAT_ID)).toBeNull()
390+
})
391+
})
392+
265393
describe('corrupt file handling', () => {
266394
test('loads an empty state for a nonexistent chat', () => {
267395
expect(loadUndoState('never-existed').undoStack).toEqual([])

0 commit comments

Comments
 (0)