diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index 62627391..6ae1f09e 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -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()
+ 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()
+ 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()
+ 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()
})
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index 571aa244..90dbc8ab 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -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
- : (
-
- {confirmCanRestoreCode ?
{confirmBodyText}
: null}
-
{confirmCaution}
-
- )
+ const confirmBody = !confirmHasCodeCheckpoint
+ ? t('chat.conversationRewindConfirmBody')
+ : confirmCaution === null
+ ? confirmBodyText
+ : (
+
+ {confirmCanRestoreCode ?
{confirmBodyText}
: null}
+
{confirmCaution}
+
+ )
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) => (
- {
- 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 (
+
+ }
+ >
+ {t('chat.conversationRewindAction')}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+ }
+ return (
+
+ )
+ })}
>
)
}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 10b25994..835e29a4 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index a3517380..001e5391 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1971,6 +1971,8 @@ export const jp: Record = {
'chat.rewindSuccessWithCode': '{count} 件のメッセージを巻き戻し、追跡対象のファイルを復元しました。',
'chat.rewindSuccessConversationOnly': '{count} 件のメッセージを巻き戻しました。このターンには利用できるファイルチェックポイントがありませんでした。',
'chat.rewindSuccessPartialCoverage': '{count} 件のメッセージを巻き戻し、記録済みのファイルを復元しました。{sources} による変更は記録されておらず、ディスク上に残っています。',
+ 'chat.conversationRewindAction': '会話を巻き戻す',
+ 'chat.conversationRewindConfirmBody': '会話をこのターンの前まで巻き戻します。ディスク上のファイルは変更されません。',
'chat.turnChangesTitle': '{count} 個のファイルが変更されました',
'chat.turnChangesLatestCardLabel': 'ターンで変更されたファイル',
'chat.turnChangesHistoricalCardLabel': 'ターンで変更されたファイル',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 5fb170a6..7ddc10fd 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1971,6 +1971,8 @@ export const kr: Record = {
'chat.rewindSuccessWithCode': '{count}개의 메시지를 되감고 추적된 파일을 복원했습니다.',
'chat.rewindSuccessConversationOnly': '{count}개의 메시지를 되감았습니다. 이 턴에는 사용할 수 있는 파일 체크포인트가 없었습니다.',
'chat.rewindSuccessPartialCoverage': '메시지 {count}개를 되돌리고 기록된 파일을 복원했습니다. {sources}(으)로 인한 변경은 기록되지 않아 디스크에 그대로 남아 있습니다.',
+ 'chat.conversationRewindAction': '대화 되돌리기',
+ 'chat.conversationRewindConfirmBody': '대화를 이 턴 이전으로 되감습니다. 디스크의 파일은 변경되지 않습니다.',
'chat.turnChangesTitle': '{count}개의 파일이 변경됨',
'chat.turnChangesLatestCardLabel': '턴에서 변경된 파일',
'chat.turnChangesHistoricalCardLabel': '턴에서 변경된 파일',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 6e06f6b5..6c1f8fe3 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1970,6 +1970,8 @@ export const zh: Record = {
'chat.rewindSuccessWithCode': '已回滾 {count} 條訊息,並恢復相關檔案。',
'chat.rewindSuccessConversationOnly': '已回滾 {count} 條訊息。這一輪沒有可用的檔案檢查點。',
'chat.rewindSuccessPartialCoverage': '已回滾 {count} 則訊息並還原已記錄的檔案;{sources} 造成的變更未被記錄,仍留在磁碟上。',
+ 'chat.conversationRewindAction': '回滾對話',
+ 'chat.conversationRewindConfirmBody': '這會把對話回滾到這一輪之前,磁碟上的檔案不會被變更。',
'chat.turnChangesTitle': '{count} 個檔案已更改',
'chat.turnChangesLatestCardLabel': '輪次已更改檔案',
'chat.turnChangesHistoricalCardLabel': '輪次已更改檔案',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index b58c57ed..66aa1fcc 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1970,6 +1970,8 @@ export const zh: Record = {
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.rewindSuccessPartialCoverage': '已回滚 {count} 条消息并恢复已记录的文件;{sources} 造成的改动未被记录,仍留在磁盘上。',
+ 'chat.conversationRewindAction': '回滚对话',
+ 'chat.conversationRewindConfirmBody': '这会把对话回滚到这一轮之前,磁盘上的文件不会被更改。',
'chat.turnChangesTitle': '{count} 个文件已更改',
'chat.turnChangesLatestCardLabel': '轮次已更改文件',
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
diff --git a/docs/desktop/sessions.md b/docs/desktop/sessions.md
index 1a123fce..c0825a41 100644
--- a/docs/desktop/sessions.md
+++ b/docs/desktop/sessions.md
@@ -64,13 +64,15 @@ Claude 干活时不是一句话回你,中间会插进来几种卡片:
## 改错了怎么撤
-Claude 每完成一轮,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
+Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
- **撤销当前轮次** — 回滚最近一次回复,并把这一轮改过的文件恢复回去。
- **回滚到这一轮之前** — 对历史轮次用,把会话和文件一起退回那个检查点。
点了会先弹确认框,里面可以选**代码和对话一起回滚**,还是**只回滚对话**(保留磁盘上的文件不动)。
+纯对话轮次或失败后没有文件变化时,不会显示空的文件卡片;回复下方会出现一个轻量的「回滚对话」入口。它会把会话退回这一轮之前并回填原提示词,磁盘上的文件保持不变。
+
检查点记录的是 Claude 通过编辑工具(Read/Edit/Write 那一类)改过的文件。**Shell 命令写出去的文件不在检查点里**:`npm install`、`rm`、重定向到文件的命令,撤销都还原不了。遇到这种轮次,卡片和确认框会点名是哪些工具没被记录,撤销照常可用,但只还原它列出的那些文件。需要兜底就用 git。
如果某一轮的文件检查点本身残缺(会话记录损坏、路径不安全等),代码就还原不了了,确认框里只剩「只回滚对话」——对话永远能退回去。
diff --git a/docs/en/desktop/sessions.md b/docs/en/desktop/sessions.md
index 7033b24b..2a7fcdbf 100644
--- a/docs/en/desktop/sessions.md
+++ b/docs/en/desktop/sessions.md
@@ -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.
diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts
index 82841343..14587b4b 100644
--- a/src/server/__tests__/sessions.test.ts
+++ b/src/server/__tests__/sessions.test.ts
@@ -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' })
})
diff --git a/src/server/services/sessionRewindService.ts b/src/server/services/sessionRewindService.ts
index f868c575..bf4027e2 100644
--- a/src/server/services/sessionRewindService.ts
+++ b/src/server/services/sessionRewindService.ts
@@ -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)
}