From aa237f2c225363faa1cd201a7b1d039b3e2b417a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 1 Sep 2026 21:29:54 +0800 Subject: [PATCH] feat(desktop): expose conversation-only rewind (#1273) --- .../src/components/chat/MessageList.test.tsx | 204 ++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 86 +++++--- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + docs/desktop/sessions.md | 4 +- docs/en/desktop/sessions.md | 4 +- src/server/__tests__/sessions.test.ts | 112 +++++++++- src/server/services/sessionRewindService.ts | 6 +- 11 files changed, 394 insertions(+), 32 deletions(-) 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 ( +
+ + {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) }