feat(desktop): expose conversation-only rewind (#1273)

This commit is contained in:
程序员阿江(Relakkes)
2026-09-01 21:29:54 +08:00
parent ece7c54c45
commit aa237f2c22
11 changed files with 394 additions and 32 deletions
@@ -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()
})
+60 -26
View File
@@ -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}
/>
)
})}
</>
)
}
+2
View File
@@ -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',
+2
View File
@@ -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': 'ターンで変更されたファイル',
+2
View File
@@ -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': '턴에서 변경된 파일',
+2
View File
@@ -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': '輪次已更改檔案',
+2
View File
@@ -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': '轮次已更改文件',
+3 -1
View File
@@ -64,13 +64,15 @@ Claude 干活时不是一句话回你,中间会插进来几种卡片:
## 改错了怎么撤
Claude 每完成一轮,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件已更改」的卡片,列出这一轮碰过的文件。卡片上有两个动作:
- **撤销当前轮次** — 回滚最近一次回复,并把这一轮改过的文件恢复回去。
- **回滚到这一轮之前** — 对历史轮次用,把会话和文件一起退回那个检查点。
点了会先弹确认框,里面可以选**代码和对话一起回滚**,还是**只回滚对话**(保留磁盘上的文件不动)。
纯对话轮次或失败后没有文件变化时,不会显示空的文件卡片;回复下方会出现一个轻量的「回滚对话」入口。它会把会话退回这一轮之前并回填原提示词,磁盘上的文件保持不变。
检查点记录的是 Claude 通过编辑工具(Read/Edit/Write 那一类)改过的文件。**Shell 命令写出去的文件不在检查点里**:`npm install`、`rm`、重定向到文件的命令,撤销都还原不了。遇到这种轮次,卡片和确认框会点名是哪些工具没被记录,撤销照常可用,但只还原它列出的那些文件。需要兜底就用 git。
如果某一轮的文件检查点本身残缺(会话记录损坏、路径不安全等),代码就还原不了了,确认框里只剩「只回滚对话」——对话永远能退回去。
+3 -1
View File
@@ -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.
+110 -2
View File
@@ -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' })
})
+4 -2
View File
@@ -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)
}