mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): expose conversation-only rewind (#1273)
This commit is contained in:
@@ -6867,6 +6867,209 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rewinds a failed continue through the authoritative conversation-only target', async () => {
|
||||
const initialChatStore = useChatStore.getInitialState()
|
||||
useChatStore.setState({
|
||||
reloadHistory: initialChatStore.reloadHistory,
|
||||
queueComposerPrefill: initialChatStore.queueComposerPrefill,
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-first',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/kept.ts'],
|
||||
insertions: 2,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-failed-continue',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
restoreAvailable: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const rewind = vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-failed-continue',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['transcript-user-failed-continue', 'provider-error'],
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
restoreAvailable: true,
|
||||
unverifiedChangeSources: [],
|
||||
mode: 'conversation',
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getMessages').mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: 'transcript-user-first',
|
||||
type: 'user',
|
||||
content: 'make a file',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'transcript-assistant-first',
|
||||
type: 'assistant',
|
||||
content: 'created src/kept.ts',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
const store = useChatStore.getState()
|
||||
act(() => {
|
||||
store.sendMessage(ACTIVE_TAB, 'make a file')
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
|
||||
store.handleServerMessage(ACTIVE_TAB, {
|
||||
type: 'content_delta',
|
||||
text: 'created src/kept.ts',
|
||||
})
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
|
||||
|
||||
store.sendMessage(ACTIVE_TAB, 'continue')
|
||||
store.handleServerMessage(ACTIVE_TAB, {
|
||||
type: 'error',
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: 'Provider request failed',
|
||||
})
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
|
||||
})
|
||||
|
||||
expect(await screen.findByText('kept.ts')).toBeTruthy()
|
||||
const conversationUndo = await screen.findByRole('button', { name: 'Roll back conversation' })
|
||||
expect(screen.getByText('Provider request failed')).toBeTruthy()
|
||||
fireEvent.click(conversationUndo)
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
expect(within(dialog).getByText(
|
||||
'This will rewind the conversation to before this turn. Files on disk will not be changed.',
|
||||
)).toBeTruthy()
|
||||
expect(within(dialog).queryByRole('button', { name: 'Undo current turn' })).toBeNull()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Roll back conversation only' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'transcript-user-failed-continue',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: 'continue',
|
||||
mode: 'conversation',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
const messages = useChatStore.getState().sessions[ACTIVE_TAB]?.messages ?? []
|
||||
expect(messages.some((message) => message.type === 'user_text' && message.content === 'continue')).toBe(false)
|
||||
expect(messages.some((message) => message.type === 'error')).toBe(false)
|
||||
})
|
||||
expect(screen.getByText('kept.ts')).toBeTruthy()
|
||||
expect(useChatStore.getState().sessions[ACTIVE_TAB]?.composerPrefill).toMatchObject({
|
||||
text: 'continue',
|
||||
})
|
||||
})
|
||||
|
||||
it('offers the lightweight conversation action for an ordinary text-only turn', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-text-only',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
restoreAvailable: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
const store = useChatStore.getState()
|
||||
act(() => {
|
||||
store.sendMessage(ACTIVE_TAB, 'explain this code')
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
|
||||
store.handleServerMessage(ACTIVE_TAB, {
|
||||
type: 'content_delta',
|
||||
text: 'Here is the explanation.',
|
||||
})
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Roll back conversation' })).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the active text-only turn to settle before loading its rewind target', async () => {
|
||||
const getTurnCheckpoints = vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-running',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
const store = useChatStore.getState()
|
||||
act(() => {
|
||||
store.sendMessage(ACTIVE_TAB, 'explain while running')
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'content_start', blockType: 'text' })
|
||||
store.handleServerMessage(ACTIVE_TAB, {
|
||||
type: 'content_delta',
|
||||
text: 'Partial explanation',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(getTurnCheckpoints).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Roll back conversation' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Roll back conversation' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render cards for turns without file changes', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
@@ -6938,6 +7141,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(cards).toHaveLength(1)
|
||||
expect(screen.getByText('first.ts')).toBeTruthy()
|
||||
expect(screen.queryByText('second.ts')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Roll back conversation' })).toBeNull()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Markdown')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
|
||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, Undo2, XCircle } from 'lucide-react'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
|
||||
@@ -1255,7 +1255,10 @@ function buildTurnCardInsertionMap(
|
||||
turnChangeCards.forEach((card) => {
|
||||
// An unverified-only turn has no structured files to list, but still needs
|
||||
// the card for conversation rewind and the warning about changes left on disk.
|
||||
// A conversation-only turn also has no files, but gets a lightweight action
|
||||
// instead of pretending to be a file-change card.
|
||||
if (
|
||||
card.checkpoint.code.available &&
|
||||
card.checkpoint.code.filesChanged.length === 0 &&
|
||||
(card.checkpoint.unverifiedChangeSources?.length ?? 0) === 0
|
||||
) return
|
||||
@@ -2940,25 +2943,29 @@ export function MessageList({
|
||||
// Undo is not reversible, so the dialog — not just the card — has to say which
|
||||
// changes it will leave behind when the checkpoint could not cover them all.
|
||||
const confirmUnverifiedSources = confirmTurnCard?.checkpoint.unverifiedChangeSources ?? []
|
||||
const confirmCanRestoreCode = confirmTurnCard?.checkpoint.restoreAvailable !== false
|
||||
const confirmHasCodeCheckpoint = Boolean(confirmTurnCard?.checkpoint.code.available)
|
||||
const confirmCanRestoreCode = confirmHasCodeCheckpoint &&
|
||||
confirmTurnCard?.checkpoint.restoreAvailable !== false
|
||||
const confirmBodyText = confirmTurnCard?.isLatest
|
||||
? t('chat.turnChangesLatestConfirmBody')
|
||||
: t('chat.turnChangesHistoricalConfirmBody')
|
||||
const confirmCaution = !confirmCanRestoreCode
|
||||
const confirmCaution = confirmHasCodeCheckpoint && !confirmCanRestoreCode
|
||||
? t('chat.turnChangesConversationOnlyConfirmBody')
|
||||
: confirmUnverifiedSources.length > 0
|
||||
? t('chat.turnChangesPartialCoverageConfirmBody', {
|
||||
sources: confirmUnverifiedSources.join(', '),
|
||||
})
|
||||
: null
|
||||
const confirmBody = confirmCaution === null
|
||||
? confirmBodyText
|
||||
: (
|
||||
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{confirmCanRestoreCode ? <p>{confirmBodyText}</p> : null}
|
||||
<p className="text-[var(--color-warning)]">{confirmCaution}</p>
|
||||
</div>
|
||||
)
|
||||
const confirmBody = !confirmHasCodeCheckpoint
|
||||
? t('chat.conversationRewindConfirmBody')
|
||||
: confirmCaution === null
|
||||
? confirmBodyText
|
||||
: (
|
||||
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{confirmCanRestoreCode ? <p>{confirmBodyText}</p> : null}
|
||||
<p className="text-[var(--color-warning)]">{confirmCaution}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const liveKeys = new Set(renderItemKeys)
|
||||
@@ -3020,7 +3027,7 @@ export function MessageList({
|
||||
const target =
|
||||
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
|
||||
targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)
|
||||
if (!target || !checkpoint.code.available) {
|
||||
if (!target) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
@@ -3504,20 +3511,47 @@ export function MessageList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{resolvedSessionId && cardsForItem.map((card) => (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={() => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{resolvedSessionId && cardsForItem.map((card) => {
|
||||
const error = turnActionErrors[card.target.messageId] ?? null
|
||||
const openUndoDialog = () => {
|
||||
setTurnUndoConfirmTargetId(card.target.messageId)
|
||||
}
|
||||
if (!card.checkpoint.code.available) {
|
||||
return (
|
||||
<div
|
||||
key={`conversation-rewind-${card.target.messageId}`}
|
||||
className="mt-1 flex flex-wrap items-center gap-2 px-1"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
loading={rewindingTurnId === card.target.messageId}
|
||||
onClick={openUndoDialog}
|
||||
icon={<Undo2 size={13} strokeWidth={2} aria-hidden="true" />}
|
||||
>
|
||||
{t('chat.conversationRewindAction')}
|
||||
</Button>
|
||||
{error ? (
|
||||
<span role="alert" className="text-xs text-[var(--color-error)]">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={error}
|
||||
isUndoing={rewindingTurnId === card.target.messageId}
|
||||
isLatest={card.isLatest}
|
||||
onUndo={openUndoDialog}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1969,6 +1969,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
|
||||
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
|
||||
'chat.rewindSuccessPartialCoverage': 'Rewound {count} messages and restored the checkpointed files; changes from {sources} were not checkpointed and remain on disk.',
|
||||
'chat.conversationRewindAction': 'Roll back conversation',
|
||||
'chat.conversationRewindConfirmBody': 'This will rewind the conversation to before this turn. Files on disk will not be changed.',
|
||||
'chat.turnChangesTitle': '{count} files changed',
|
||||
'chat.turnChangesLatestCardLabel': 'Turn changed files',
|
||||
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
|
||||
|
||||
@@ -1971,6 +1971,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.rewindSuccessWithCode': '{count} 件のメッセージを巻き戻し、追跡対象のファイルを復元しました。',
|
||||
'chat.rewindSuccessConversationOnly': '{count} 件のメッセージを巻き戻しました。このターンには利用できるファイルチェックポイントがありませんでした。',
|
||||
'chat.rewindSuccessPartialCoverage': '{count} 件のメッセージを巻き戻し、記録済みのファイルを復元しました。{sources} による変更は記録されておらず、ディスク上に残っています。',
|
||||
'chat.conversationRewindAction': '会話を巻き戻す',
|
||||
'chat.conversationRewindConfirmBody': '会話をこのターンの前まで巻き戻します。ディスク上のファイルは変更されません。',
|
||||
'chat.turnChangesTitle': '{count} 個のファイルが変更されました',
|
||||
'chat.turnChangesLatestCardLabel': 'ターンで変更されたファイル',
|
||||
'chat.turnChangesHistoricalCardLabel': 'ターンで変更されたファイル',
|
||||
|
||||
@@ -1971,6 +1971,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.rewindSuccessWithCode': '{count}개의 메시지를 되감고 추적된 파일을 복원했습니다.',
|
||||
'chat.rewindSuccessConversationOnly': '{count}개의 메시지를 되감았습니다. 이 턴에는 사용할 수 있는 파일 체크포인트가 없었습니다.',
|
||||
'chat.rewindSuccessPartialCoverage': '메시지 {count}개를 되돌리고 기록된 파일을 복원했습니다. {sources}(으)로 인한 변경은 기록되지 않아 디스크에 그대로 남아 있습니다.',
|
||||
'chat.conversationRewindAction': '대화 되돌리기',
|
||||
'chat.conversationRewindConfirmBody': '대화를 이 턴 이전으로 되감습니다. 디스크의 파일은 변경되지 않습니다.',
|
||||
'chat.turnChangesTitle': '{count}개의 파일이 변경됨',
|
||||
'chat.turnChangesLatestCardLabel': '턴에서 변경된 파일',
|
||||
'chat.turnChangesHistoricalCardLabel': '턴에서 변경된 파일',
|
||||
|
||||
@@ -1970,6 +1970,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.rewindSuccessWithCode': '已回滾 {count} 條訊息,並恢復相關檔案。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滾 {count} 條訊息。這一輪沒有可用的檔案檢查點。',
|
||||
'chat.rewindSuccessPartialCoverage': '已回滾 {count} 則訊息並還原已記錄的檔案;{sources} 造成的變更未被記錄,仍留在磁碟上。',
|
||||
'chat.conversationRewindAction': '回滾對話',
|
||||
'chat.conversationRewindConfirmBody': '這會把對話回滾到這一輪之前,磁碟上的檔案不會被變更。',
|
||||
'chat.turnChangesTitle': '{count} 個檔案已更改',
|
||||
'chat.turnChangesLatestCardLabel': '輪次已更改檔案',
|
||||
'chat.turnChangesHistoricalCardLabel': '輪次已更改檔案',
|
||||
|
||||
@@ -1970,6 +1970,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.rewindSuccessPartialCoverage': '已回滚 {count} 条消息并恢复已记录的文件;{sources} 造成的改动未被记录,仍留在磁盘上。',
|
||||
'chat.conversationRewindAction': '回滚对话',
|
||||
'chat.conversationRewindConfirmBody': '这会把对话回滚到这一轮之前,磁盘上的文件不会被更改。',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
'chat.turnChangesLatestCardLabel': '轮次已更改文件',
|
||||
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
|
||||
|
||||
@@ -64,13 +64,15 @@ Claude 干活时不是一句话回你,中间会插进来几种卡片:
|
||||
|
||||
## 改错了怎么撤
|
||||
|
||||
Claude 每完成一轮,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
|
||||
Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
|
||||
|
||||
- **撤销当前轮次** — 回滚最近一次回复,并把这一轮改过的文件恢复回去。
|
||||
- **回滚到这一轮之前** — 对历史轮次用,把会话和文件一起退回那个检查点。
|
||||
|
||||
点了会先弹确认框,里面可以选**代码和对话一起回滚**,还是**只回滚对话**(保留磁盘上的文件不动)。
|
||||
|
||||
纯对话轮次或失败后没有文件变化时,不会显示空的文件卡片;回复下方会出现一个轻量的「回滚对话」入口。它会把会话退回这一轮之前并回填原提示词,磁盘上的文件保持不变。
|
||||
|
||||
检查点记录的是 Claude 通过编辑工具(Read/Edit/Write 那一类)改过的文件。**Shell 命令写出去的文件不在检查点里**:`npm install`、`rm`、重定向到文件的命令,撤销都还原不了。遇到这种轮次,卡片和确认框会点名是哪些工具没被记录,撤销照常可用,但只还原它列出的那些文件。需要兜底就用 git。
|
||||
|
||||
如果某一轮的文件检查点本身残缺(会话记录损坏、路径不安全等),代码就还原不了了,确认框里只剩「只回滚对话」——对话永远能退回去。
|
||||
|
||||
@@ -64,13 +64,15 @@ The permission mode is locked while a turn is running and unlocks when the turn
|
||||
|
||||
## Undoing a turn
|
||||
|
||||
After each turn, a card appears in the conversation reading "**{n} files changed**", listing every file that turn touched. It offers two actions:
|
||||
After each turn that changes files, a card appears in the conversation reading "**{n} files changed**", listing every file that turn touched. It offers two actions:
|
||||
|
||||
- **Undo current turn** — roll back the latest reply and restore the files it changed.
|
||||
- **Roll back to before this turn** — for older turns: rewind both the conversation and the files to that checkpoint.
|
||||
|
||||
Both ask for confirmation first, where you choose between rolling back **code and conversation together** or **the conversation only** (leaving the files on disk untouched).
|
||||
|
||||
For a text-only turn, or a failed turn that made no file changes, no empty file card is shown. A lightweight **Roll back conversation** action appears below the response instead. It rewinds the session to before that turn, refills the original prompt, and leaves files on disk untouched.
|
||||
|
||||
Checkpoints capture the files Claude changed through its editing tools. **Files written by shell commands are not checkpointed** — `npm install`, `rm`, or a command redirecting into a file cannot be undone. On such a turn the card and the confirmation name the tools that went unrecorded; undo still works, but it only restores the files it lists. Use git for anything you need a guaranteed way back from.
|
||||
|
||||
When a turn's file checkpoint is itself incomplete (a damaged session log, an unsafe path), the code cannot be restored and the confirmation offers only **Roll back conversation only** — the conversation can always be rewound.
|
||||
|
||||
@@ -6170,6 +6170,105 @@ describe('Sessions API', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should expose authoritative conversation targets for text, provider errors, and repeated continues', async () => {
|
||||
const sessionId = '99999999-bbbb-cccc-dddd-000000001273'
|
||||
const workDir = path.join(tmpDir, 'conversation-only-turn-targets')
|
||||
const untouchedFile = path.join(workDir, 'untouched.txt')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const failedContinueUserId = crypto.randomUUID()
|
||||
const recoveredContinueUserId = crypto.randomUUID()
|
||||
const incompleteUserId = crypto.randomUUID()
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
await fs.writeFile(untouchedFile, 'keep me\n', 'utf-8')
|
||||
await writeSessionFile('-tmp-conversation-only-turn-targets', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
{ ...makeUserEntry('Explain the current behavior', firstUserId), cwd: workDir, sessionId },
|
||||
makeAssistantEntry('Plain text answer.', firstUserId),
|
||||
{ ...makeUserEntry('continue', failedContinueUserId), cwd: workDir, sessionId },
|
||||
{
|
||||
...makeAssistantEntry('Provider request failed.', failedContinueUserId),
|
||||
isApiErrorMessage: true,
|
||||
error: 'API Error',
|
||||
errorDetails: 'upstream provider returned an error',
|
||||
},
|
||||
{ ...makeUserEntry('continue', recoveredContinueUserId), cwd: workDir, sessionId },
|
||||
makeAssistantEntry('Recovered on the next attempt.', recoveredContinueUserId),
|
||||
{ ...makeUserEntry('still running', incompleteUserId), cwd: workDir, sessionId },
|
||||
])
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
expect(listRes.status).toBe(200)
|
||||
const listBody = await listRes.json() as {
|
||||
checkpoints: Array<{
|
||||
target: {
|
||||
targetUserMessageId: string
|
||||
userMessageIndex: number
|
||||
userMessageCount: number
|
||||
}
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}>
|
||||
}
|
||||
|
||||
expect(listBody.checkpoints.map((checkpoint) => ({
|
||||
target: checkpoint.target,
|
||||
code: checkpoint.code,
|
||||
}))).toEqual([
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: firstUserId,
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 4,
|
||||
},
|
||||
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: failedContinueUserId,
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 4,
|
||||
},
|
||||
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
||||
},
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: recoveredContinueUserId,
|
||||
userMessageIndex: 2,
|
||||
userMessageCount: 4,
|
||||
},
|
||||
code: expect.objectContaining({ available: false, filesChanged: [] }),
|
||||
},
|
||||
])
|
||||
|
||||
const rewindRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
targetUserMessageId: failedContinueUserId,
|
||||
expectedContent: 'continue',
|
||||
mode: 'conversation',
|
||||
}),
|
||||
})
|
||||
expect(rewindRes.status).toBe(200)
|
||||
expect(await rewindRes.json()).toMatchObject({
|
||||
target: {
|
||||
targetUserMessageId: failedContinueUserId,
|
||||
userMessageIndex: 1,
|
||||
},
|
||||
mode: 'conversation',
|
||||
})
|
||||
expect(await fs.readFile(untouchedFile, 'utf-8')).toBe('keep me\n')
|
||||
|
||||
const remainingMessages = await service.getSessionMessages(sessionId)
|
||||
expect(remainingMessages.map((message) => message.id)).toEqual([
|
||||
firstUserId,
|
||||
expect.any(String),
|
||||
])
|
||||
expect(remainingMessages.some((message) => message.id === failedContinueUserId)).toBe(false)
|
||||
expect(remainingMessages.some((message) => message.id === recoveredContinueUserId)).toBe(false)
|
||||
expect(remainingMessages.some((message) => message.id === incompleteUserId)).toBe(false)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints should reuse the loaded transcript cwd for every turn', async () => {
|
||||
const sessionId = '99999999-bbbb-cccc-dddd-000000000021'
|
||||
const projectDir = '-tmp-long-turn-checkpoint-session'
|
||||
@@ -7314,9 +7413,18 @@ describe('Sessions API', () => {
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { checkpoints: unknown[] }
|
||||
const body = await res.json() as {
|
||||
checkpoints: Array<{
|
||||
target: { targetUserMessageId: string }
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}>
|
||||
}
|
||||
|
||||
expect(body.checkpoints).toEqual([])
|
||||
expect(body.checkpoints).toHaveLength(1)
|
||||
expect(body.checkpoints[0]).toMatchObject({
|
||||
target: { targetUserMessageId: userId },
|
||||
code: { available: false, filesChanged: [] },
|
||||
})
|
||||
await expect(fs.stat(path.join(workDir, 'permission-denial-test.txt')))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
@@ -869,7 +869,10 @@ function buildTranscriptTurnContexts(
|
||||
const parentTurnMessages = rawTurnMessages.filter((message) => !message.parentToolUseId)
|
||||
return {
|
||||
activeMessageIndex,
|
||||
completed: rawTurnMessages.some((message) =>
|
||||
// Child-agent transcript entries can be physically interleaved after a
|
||||
// later root prompt. They belong to the parent tool's turn and must not
|
||||
// make that later, still-unanswered prompt look safely rewindable.
|
||||
completed: parentTurnMessages.some((message) =>
|
||||
message.type === 'assistant' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
@@ -1839,7 +1842,6 @@ export async function listSessionTurnCheckpoints(
|
||||
signal,
|
||||
)
|
||||
|
||||
if (!checkpoint.code.available) continue
|
||||
checkpoints.push(checkpoint)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user