fix: merge QA-005 oversized history workspace fallback

This commit is contained in:
程序员阿江(Relakkes)
2026-10-01 17:44:20 +08:00
10 changed files with 490 additions and 9 deletions
@@ -42,6 +42,7 @@ import type { MessageEntry } from '../../types/session'
import type { PerSessionState } from '../../stores/chatStore'
import { FindInPageModal } from '../search/FindInPageModal'
import { getConversationFindController } from '../search/conversationFindBridge'
import { workspaceOpen } from '@/lib/workspace/openTarget'
vi.mock('@/lib/workspace/openSideChat', () => ({ openSideChat: vi.fn(async () => 'tab-side') }))
@@ -6129,15 +6130,22 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
})
it('does not show the checkpoint preview budget as a chat error', async () => {
it('retains the workspace file list when a long history exceeds the checkpoint preview budget', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockRejectedValue(new ApiError(413, {
error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT',
message: 'This transcript exceeds the full checkpoint preview budget. Chat history remains available in pages.',
}))
let resolveStatus!: (result: Awaited<ReturnType<typeof sessionsApi.getWorkspaceStatus>>) => void
vi.mocked(sessionsApi.getWorkspaceStatus).mockReturnValue(new Promise((resolve) => { resolveStatus = resolve }))
const openFile = vi.spyOn(workspaceOpen, 'file')
const openReview = vi.spyOn(workspaceOpen, 'review')
const rewind = vi.spyOn(sessionsApi, 'rewind')
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({
messages: [
{ id: 'user-1', type: 'user_text', content: 'Continue the work', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
{ id: 'write-1', type: 'tool_use', toolUseId: 'write-1', toolName: 'Write', input: { file_path: '/tmp/example-project/qa-status.txt', content: 'QA-005 retained workspace file\n' }, timestamp: 2 },
{ id: 'write-result', type: 'tool_result', toolUseId: 'write-1', content: 'Created file', isError: false, timestamp: 3 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 4 },
],
}) } })
@@ -6149,6 +6157,148 @@ describe('MessageList nested tool calls', () => {
expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(1)
expect(screen.getByText('Done')).toBeTruthy()
expect(screen.queryByText(/This transcript exceeds the full checkpoint preview budget/)).toBeNull()
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
await act(async () => resolveStatus({
state: 'ok', workDir: '/tmp/example-project', repoName: null, branch: null, isGitRepo: false,
changedFiles: [{ path: 'qa-status.txt', status: 'added', additions: 1, deletions: 0 }],
}))
const fallback = await screen.findByRole('region', { name: 'Workspace changed files' })
expect(fallback.closest('[data-chat-render-item-key]')).toBeNull()
expect(within(fallback).getByText(/too long for turn previews or file undo/)).toBeTruthy()
expect(within(fallback).getByText(/current workspace changes/)).toBeTruthy()
fireEvent.click(within(fallback).getByRole('button', { name: 'Open current content of qa-status.txt' }))
expect(openFile).toHaveBeenCalledWith(ACTIVE_TAB, 'qa-status.txt')
expect(openReview).not.toHaveBeenCalled()
expect(rewind).not.toHaveBeenCalled()
expect(screen.queryByLabelText('Turn changed files')).toBeNull()
expect(screen.queryByRole('button', { name: /Undo current turn|Rewind to before this turn|Roll back conversation/ })).toBeNull()
})
it('explains the checkpoint preview limit even when the verified workspace has no changed files', async () => {
vi.mocked(sessionsApi.getTurnCheckpoints).mockRejectedValue(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: [
{ id: 'user-1', type: 'user_text', content: 'Continue', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
] }) } })
render(<MessageList />)
const fallback = await screen.findByRole('region', { name: 'Workspace changed files' })
expect(within(fallback).getByText(/too long for turn previews or file undo/)).toBeTruthy()
expect(within(fallback).getByText('No current workspace changes were found.')).toBeTruthy()
expect(within(fallback).queryByRole('button')).toBeNull()
})
it.each(['error', 'missing_workdir', 'not_git_repo', 'incomplete', 'rejected', 'workspace-budget'] as const)(
'keeps the transcript readable and withholds an unverified fallback file list when workspace status is %s', async (failure) => {
vi.mocked(sessionsApi.getTurnCheckpoints).mockRejectedValue(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
if (failure === 'rejected' || failure === 'workspace-budget') {
vi.mocked(sessionsApi.getWorkspaceStatus).mockRejectedValue(failure === 'rejected'
? new Error('offline')
: new ApiError(413, { error: 'HISTORY_WORKSPACE_LIMIT' }))
} else {
vi.mocked(sessionsApi.getWorkspaceStatus).mockResolvedValue({
state: failure === 'incomplete' ? 'ok' : failure,
workDir: '/tmp/example-project', repoName: null, branch: null, isGitRepo: false,
...(failure === 'incomplete' ? {} : { changedFiles: [{ path: 'unverified.txt', status: 'added', additions: 1, deletions: 0 }] }),
} as Awaited<ReturnType<typeof sessionsApi.getWorkspaceStatus>>)
}
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: [
{ id: 'user-1', type: 'user_text', content: 'Continue', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Transcript remains readable', timestamp: 2 },
] }) } })
render(<MessageList />)
const fallback = await screen.findByRole('region', { name: 'Workspace changed files' })
expect(within(fallback).getByText(/Current workspace changes could not be verified/)).toBeTruthy()
expect(within(fallback).queryByRole('button')).toBeNull()
expect(screen.queryByText('unverified.txt')).toBeNull()
expect(screen.getByText('Transcript remains readable')).toBeTruthy()
},
)
it.each([
new ApiError(500, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT', message: 'Checkpoint request failed' }),
new ApiError(413, { error: 'HISTORY_WORKSPACE_LIMIT', message: 'Workspace evidence is incomplete' }),
new Error('Checkpoint request failed'),
])('does not replace a non-checkpoint-budget error with workspace evidence: %s', async (error) => {
vi.mocked(sessionsApi.getTurnCheckpoints).mockRejectedValue(error)
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: [
{ id: 'user-1', type: 'user_text', content: 'Continue', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
] }) } })
render(<MessageList />)
expect(await screen.findByText(error instanceof ApiError ? String((error.body as { message: string }).message) : error.message)).toBeTruthy()
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
})
it('does not show a stale workspace fallback after switching sessions while status is pending', async () => {
vi.mocked(sessionsApi.getTurnCheckpoints).mockImplementation((sessionId) => sessionId === 'session-one'
? Promise.reject(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
: Promise.resolve({ checkpoints: [] }))
let resolveStatus!: (result: Awaited<ReturnType<typeof sessionsApi.getWorkspaceStatus>>) => void
const requests: Array<AbortSignal | undefined> = []
vi.mocked(sessionsApi.getWorkspaceStatus).mockImplementation((sessionId, signal) => {
requests.push(signal)
return sessionId === 'session-one'
? new Promise((resolve) => { resolveStatus = resolve })
: Promise.resolve({ state: 'ok', workDir: '/tmp/two', repoName: null, branch: null, isGitRepo: false, changedFiles: [] })
})
const messages: UIMessage[] = [
{ id: 'user-1', type: 'user_text', content: 'Continue', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
]
useChatStore.setState({ sessions: {
'session-one': makeSessionState({ messages }), 'session-two': makeSessionState({ messages }),
} })
const { rerender } = render(<MessageList sessionId="session-one" />)
await waitFor(() => expect(requests).toHaveLength(1))
rerender(<MessageList sessionId="session-two" />)
await waitFor(() => expect(requests).toHaveLength(2))
expect(requests[0]?.aborted).toBe(true)
await act(async () => resolveStatus({
state: 'ok', workDir: '/tmp/one', repoName: null, branch: null, isGitRepo: false,
changedFiles: [{ path: 'stale.txt', status: 'added', additions: 1, deletions: 0 }],
}))
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
expect(screen.queryByText('stale.txt')).toBeNull()
})
it('keeps successful turn checkpoints authoritative when workspace status also contains cumulative changes', async () => {
vi.mocked(sessionsApi.getTurnCheckpoints).mockResolvedValue({ checkpoints: [{
target: { targetUserMessageId: 'user-1', userMessageIndex: 0, userMessageCount: 1 },
code: { available: true, filesChanged: ['turn-owned.ts'], insertions: 1, deletions: 0 },
}] })
vi.mocked(sessionsApi.getWorkspaceStatus).mockResolvedValue({
state: 'ok', workDir: '/tmp/example-project', repoName: null, branch: null, isGitRepo: false,
changedFiles: [{ path: 'unrelated-workspace.ts', status: 'modified', additions: 8, deletions: 2 }],
})
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: [
{ id: 'user-1', type: 'user_text', content: 'Create a file', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
] }) } })
render(<MessageList />)
const turnCard = await screen.findByRole('region', { name: 'Turn changed files' })
fireEvent.click(within(turnCard).getByRole('button', { name: 'Show 1 changed files' }))
expect(within(turnCard).getByRole('button', { name: 'Open turn-owned.ts in workspace' })).toBeTruthy()
expect(within(turnCard).getByRole('button', { name: 'Undo current turn changes' })).toBeTruthy()
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
expect(screen.queryByText('unrelated-workspace.ts')).toBeNull()
})
it('hides workspace fallback while a new turn runs and clears it after successful checkpoint reload', async () => {
vi.mocked(sessionsApi.getTurnCheckpoints)
.mockRejectedValueOnce(new ApiError(413, { error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' }))
.mockResolvedValue({ checkpoints: [] })
const messages: UIMessage[] = [
{ id: 'user-1', type: 'user_text', content: 'Continue', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Done', timestamp: 2 },
]
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } })
render(<MessageList />)
expect(await screen.findByRole('region', { name: 'Workspace changed files' })).toBeTruthy()
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages, chatState: 'thinking' }) } }))
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
act(() => useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } }))
await waitFor(() => expect(sessionsApi.getTurnCheckpoints).toHaveBeenCalledTimes(2))
expect(screen.queryByRole('region', { name: 'Workspace changed files' })).toBeNull()
})
it('renders multiple historical turn change cards across three turns', async () => {
+41 -7
View File
@@ -3,7 +3,7 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredVal
import { createPortal, flushSync } from 'react-dom'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, Settings, Target, Undo2, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint } from '../../api/sessions'
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint, type WorkspaceChangedFile } from '../../api/sessions'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
@@ -32,6 +32,7 @@ import { RenderItemBoundary } from './RenderItemBoundary'
import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import { WorkspaceChangesFallback } from '@/components/chat/WorkspaceChangesFallback'
import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary'
import { MEMBER_AVATARS, memberAccentColor } from '../agentTeams/agentTeamsAvatars'
import {
@@ -2391,6 +2392,10 @@ export function MessageList({
const [expandedChangeCards, setExpandedChangeCards] = useState<Record<string, boolean>>({})
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
const [workspaceChangesFallback, setWorkspaceChangesFallback] = useState<{
sessionId: string
files: WorkspaceChangedFile[] | null
} | null>(null)
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [branchingMessageId, setBranchingMessageId] = useState<string | null>(null)
@@ -3088,18 +3093,21 @@ export function MessageList({
if (!resolvedSessionId || !checkpointHistoryReady || completedTurnTargets.length === 0 || isDirectAgentSession) {
setTurnChangeCards([])
setTurnChangeLoadError(null)
setWorkspaceChangesFallback(null)
setIsLoadingTurnChangeCards(false)
return
}
if (hasRunningBackgroundTasks) {
setTurnChangeLoadError(null)
setWorkspaceChangesFallback(null)
setIsLoadingTurnChangeCards(false)
return
}
if (chatState !== 'idle') {
setTurnChangeLoadError(null)
setWorkspaceChangesFallback(null)
setIsLoadingTurnChangeCards(false)
return
}
@@ -3108,13 +3116,31 @@ export function MessageList({
const controller = new AbortController()
setIsLoadingTurnChangeCards(true)
setTurnChangeLoadError(null)
setWorkspaceChangesFallback(null)
Promise.all([
// Keep the bounded workspace evidence even if full turn previews exceed
// their budget. These files describe the current workspace, not a turn.
Promise.allSettled([
sessionsApi.getTurnCheckpoints(resolvedSessionId, { signal: controller.signal }),
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
sessionsApi.getWorkspaceStatus(resolvedSessionId, controller.signal),
])
.then(([checkpointResponse, workspaceStatus]) => {
.then(([checkpointResult, workspaceResult]) => {
if (cancelled) return
const workspaceStatus = workspaceResult.status === 'fulfilled' ? workspaceResult.value : null
if (checkpointResult.status === 'rejected') {
setTurnChangeCards([])
if (isCheckpointPreviewBudgetError(checkpointResult.reason)) {
const files = workspaceStatus?.state === 'ok' &&
Array.isArray(workspaceStatus.changedFiles) &&
workspaceStatus.changedFiles.every((file) => typeof file?.path === 'string' && file.path.trim().length > 0)
? workspaceStatus.changedFiles
: null
setWorkspaceChangesFallback({ sessionId: resolvedSessionId, files })
} else {
setTurnChangeLoadError(getApiErrorMessage(checkpointResult.reason))
}
return
}
const targetByMessageId = new Map(
completedTurnTargets.map((target) => [target.messageId, target] as const),
)
@@ -3122,7 +3148,7 @@ export function MessageList({
completedTurnTargets.map((target) => [target.userMessageIndex, target] as const),
)
const nextCards = normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
const nextCards = normalizeTurnCheckpoints(checkpointResult.value).flatMap((checkpoint) => {
const target =
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
(sessionState?.historyWindowed ? undefined : targetByUserMessageIndex.get(checkpoint.target.userMessageIndex))
@@ -3141,8 +3167,7 @@ export function MessageList({
.catch((error) => {
if (cancelled) return
setTurnChangeCards([])
// This limit only disables optional turn previews; the chat transcript is still readable.
setTurnChangeLoadError(isCheckpointPreviewBudgetError(error) ? null : getApiErrorMessage(error))
setTurnChangeLoadError(getApiErrorMessage(error))
})
.finally(() => {
if (!cancelled) {
@@ -3786,6 +3811,15 @@ export function MessageList({
</div>
)}
{!isLoadingTurnChangeCards && chatState === 'idle' && !hasRunningBackgroundTasks &&
workspaceChangesFallback?.sessionId === resolvedSessionId ? (
<WorkspaceChangesFallback
key={workspaceChangesFallback.sessionId}
sessionId={workspaceChangesFallback.sessionId}
files={workspaceChangesFallback.files}
/>
) : null}
<div />
</div>
</div>
@@ -0,0 +1,62 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceChangedFile } from '@/api/sessions'
import { WorkspaceChangesFallback } from '@/components/chat/WorkspaceChangesFallback'
import { workspaceOpen } from '@/lib/workspace/openTarget'
import { useSettingsStore } from '@/stores/settingsStore'
vi.mock('@/lib/workspace/openTarget', () => ({
workspaceOpen: { file: vi.fn(), review: vi.fn() },
}))
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
})
describe('WorkspaceChangesFallback', () => {
it('opens current workspace file content without attributing changes to a turn or offering undo', () => {
const files: WorkspaceChangedFile[] = [
{ path: 'src/retained.ts', status: 'modified', additions: 1, deletions: 0 },
]
render(<WorkspaceChangesFallback sessionId="long-session" files={files} />)
const region = screen.getByRole('region', { name: 'Workspace changed files' })
expect(within(region).getByText(/may include changes from other turns or outside this conversation/)).toBeInTheDocument()
const opener = within(region).getByRole('button', { name: 'Open current content of src/retained.ts' })
expect(opener).toHaveClass('focus-visible:ring-2')
fireEvent.click(opener)
expect(workspaceOpen.file).toHaveBeenCalledWith('long-session', 'src/retained.ts')
expect(workspaceOpen.review).not.toHaveBeenCalled()
expect(within(region).getAllByRole('button')).toHaveLength(1)
})
it('offers a bounded initial list with access to every verified workspace file', () => {
const files: WorkspaceChangedFile[] = Array.from({ length: 8 }, (_, index) => ({
path: `file-${index}.txt`, status: 'added', additions: 1, deletions: 0,
}))
render(<WorkspaceChangesFallback sessionId="long-session" files={files} />)
expect(screen.queryByRole('button', { name: 'Open current content of file-7.txt' })).not.toBeInTheDocument()
const showMore = screen.getByRole('button', { name: 'Show 3 more files' })
expect(showMore).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(showMore)
fireEvent.click(screen.getByRole('button', { name: 'Open current content of file-7.txt' }))
expect(workspaceOpen.file).toHaveBeenCalledWith('long-session', 'file-7.txt')
fireEvent.click(screen.getByRole('button', { name: 'Show less' }))
expect(screen.queryByRole('button', { name: 'Open current content of file-7.txt' })).not.toBeInTheDocument()
})
it('keeps the budget explanation visible for a verified empty workspace', () => {
render(<WorkspaceChangesFallback sessionId="long-session" files={[]} />)
expect(screen.getByText(/too long for turn previews or file undo/)).toBeInTheDocument()
expect(screen.getByText('No current workspace changes were found.')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('announces unavailable evidence without rendering files or rollback actions', () => {
render(<WorkspaceChangesFallback sessionId="long-session" files={null} />)
expect(screen.getByRole('alert')).toHaveTextContent('Current workspace changes could not be verified')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import type { WorkspaceChangedFile } from '@/api/sessions'
import { Button } from '@/components/ui/Button'
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
import { useTranslation } from '@/i18n'
import { workspaceOpen } from '@/lib/workspace/openTarget'
type WorkspaceChangesFallbackProps = {
sessionId: string
/** Null means the workspace evidence could not be verified. */
files: WorkspaceChangedFile[] | null
}
const COLLAPSED_COUNT = 5
/** Current workspace evidence is cumulative and has no turn baseline or undo. */
export function WorkspaceChangesFallback({ sessionId, files }: WorkspaceChangesFallbackProps) {
const t = useTranslation()
const [showAllFiles, setShowAllFiles] = useState(false)
const visibleFiles = showAllFiles ? files : files?.slice(0, COLLAPSED_COUNT)
return (
<section
aria-label={t('chat.workspaceChangesFallbackLabel')}
className="mx-auto mb-5 mt-2 w-full max-w-[var(--chat-content-max-width)] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
>
<div className="space-y-1 bg-[var(--color-surface-container-low)] px-3 py-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{files === null
? t('chat.workspaceChangesFallbackLabel')
: t('chat.workspaceChangesFallbackTitle', { count: files.length })}
</h3>
<p role={files === null ? 'alert' : undefined} className="text-xs leading-5 text-[var(--color-text-secondary)]">
{t(files === null ? 'chat.workspaceChangesFallbackUnavailable' : 'chat.workspaceChangesFallbackExplanation')}
</p>
</div>
{files?.length === 0 ? (
<p className="border-t border-[var(--color-border)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
{t('chat.workspaceChangesFallbackEmpty')}
</p>
) : null}
{visibleFiles?.length ? (
<ul className="divide-y divide-[var(--color-border)] border-t border-[var(--color-border)]">
{visibleFiles.map((file) => (
<li key={file.path}>
<button
type="button"
aria-label={t('chat.workspaceChangesFallbackOpenFile', { path: file.path })}
title={file.path}
onClick={() => workspaceOpen.file(sessionId, file.path)}
className="flex min-h-[44px] w-full min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<FileTypeIcon path={file.path} size={20} />
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{file.path}</span>
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
</li>
))}
</ul>
) : null}
{files && files.length > COLLAPSED_COUNT ? (
<div className="border-t border-[var(--color-border)] px-3 py-2">
<Button
variant="ghost"
size="sm"
aria-expanded={showAllFiles}
onClick={() => setShowAllFiles((current) => !current)}
>
{t(showAllFiles ? 'chat.turnChangesShowLess' : 'chat.turnChangesShowMore', { count: files.length - COLLAPSED_COUNT })}
</Button>
</div>
) : null}
</section>
)
}
+6
View File
@@ -2832,6 +2832,12 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'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.workspaceChangesFallbackLabel': 'Workspace changed files',
'chat.workspaceChangesFallbackTitle': 'Current workspace changes ({count})',
'chat.workspaceChangesFallbackExplanation': 'This conversation is too long for turn previews or file undo. This list shows current workspace changes and may include changes from other turns or outside this conversation.',
'chat.workspaceChangesFallbackUnavailable': 'This conversation is too long for turn previews or file undo. Current workspace changes could not be verified, so no file list is available.',
'chat.workspaceChangesFallbackEmpty': 'No current workspace changes were found.',
'chat.workspaceChangesFallbackOpenFile': 'Open current content of {path}',
'chat.turnChangesTitle': '{count} files changed',
'chat.turnChangesLatestCardLabel': 'Turn changed files',
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
+6
View File
@@ -2833,6 +2833,12 @@ export const jp: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '{count} 件のメッセージを巻き戻し、記録済みのファイルを復元しました。{sources} による変更は記録されておらず、ディスク上に残っています。',
'chat.conversationRewindAction': '会話を巻き戻す',
'chat.conversationRewindConfirmBody': '会話をこのターンの前まで巻き戻します。ディスク上のファイルは変更されません。',
'chat.workspaceChangesFallbackLabel': 'ワークスペースの変更ファイル',
'chat.workspaceChangesFallbackTitle': '現在のワークスペースの変更({count})',
'chat.workspaceChangesFallbackExplanation': '会話が長すぎるため、ターンごとのプレビューやファイルの取り消しは利用できません。この一覧は現在のワークスペース全体の変更を示し、他のターンやこの会話外の変更を含む場合があります。',
'chat.workspaceChangesFallbackUnavailable': '会話が長すぎるため、ターンごとのプレビューやファイルの取り消しは利用できません。現在のワークスペースの変更を確認できないため、ファイル一覧は表示できません。',
'chat.workspaceChangesFallbackEmpty': '現在のワークスペースに変更は見つかりませんでした。',
'chat.workspaceChangesFallbackOpenFile': '{path} の現在の内容を開く',
'chat.turnChangesTitle': '{count} 個のファイルが変更されました',
'chat.turnChangesLatestCardLabel': 'ターンで変更されたファイル',
'chat.turnChangesHistoricalCardLabel': 'ターンで変更されたファイル',
+6
View File
@@ -2835,6 +2835,12 @@ export const kr: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '메시지 {count}개를 되돌리고 기록된 파일을 복원했습니다. {sources}(으)로 인한 변경은 기록되지 않아 디스크에 그대로 남아 있습니다.',
'chat.conversationRewindAction': '대화 되돌리기',
'chat.conversationRewindConfirmBody': '대화를 이 턴 이전으로 되감습니다. 디스크의 파일은 변경되지 않습니다.',
'chat.workspaceChangesFallbackLabel': '워크스페이스 변경 파일',
'chat.workspaceChangesFallbackTitle': '현재 워크스페이스 변경 ({count})',
'chat.workspaceChangesFallbackExplanation': '대화가 너무 길어 턴별 미리보기나 파일 실행 취소를 사용할 수 없습니다. 이 목록은 현재 워크스페이스의 누적 변경을 표시하며 다른 턴이나 이 대화 밖의 변경도 포함할 수 있습니다.',
'chat.workspaceChangesFallbackUnavailable': '대화가 너무 길어 턴별 미리보기나 파일 실행 취소를 사용할 수 없습니다. 현재 워크스페이스 변경을 확인할 수 없어 파일 목록을 표시할 수 없습니다.',
'chat.workspaceChangesFallbackEmpty': '현재 워크스페이스 변경을 찾지 못했습니다.',
'chat.workspaceChangesFallbackOpenFile': '{path}의 현재 내용 열기',
'chat.turnChangesTitle': '{count}개의 파일이 변경됨',
'chat.turnChangesLatestCardLabel': '턴에서 변경된 파일',
'chat.turnChangesHistoricalCardLabel': '턴에서 변경된 파일',
+6
View File
@@ -2832,6 +2832,12 @@ export const zh: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '已回滾 {count} 則訊息並還原已記錄的檔案;{sources} 造成的變更未被記錄,仍留在磁碟上。',
'chat.conversationRewindAction': '回滾對話',
'chat.conversationRewindConfirmBody': '這會把對話回滾到這一輪之前,磁碟上的檔案不會被變更。',
'chat.workspaceChangesFallbackLabel': '工作區變更檔案',
'chat.workspaceChangesFallbackTitle': '目前工作區變更({count})',
'chat.workspaceChangesFallbackExplanation': '對話過長,無法提供逐輪預覽或檔案復原。此清單顯示目前工作區的累計變更,可能包含其他輪次或對話外的變更。',
'chat.workspaceChangesFallbackUnavailable': '對話過長,無法提供逐輪預覽或檔案復原。目前工作區變更無法確認,暫不提供檔案清單。',
'chat.workspaceChangesFallbackEmpty': '未發現目前工作區變更。',
'chat.workspaceChangesFallbackOpenFile': '開啟 {path} 的目前內容',
'chat.turnChangesTitle': '{count} 個檔案已更改',
'chat.turnChangesLatestCardLabel': '輪次已更改檔案',
'chat.turnChangesHistoricalCardLabel': '輪次已更改檔案',
+6
View File
@@ -2831,6 +2831,12 @@ export const zh: Record<TranslationKey, string> = {
'chat.rewindSuccessPartialCoverage': '已回滚 {count} 条消息并恢复已记录的文件;{sources} 造成的改动未被记录,仍留在磁盘上。',
'chat.conversationRewindAction': '回滚对话',
'chat.conversationRewindConfirmBody': '这会把对话回滚到这一轮之前,磁盘上的文件不会被更改。',
'chat.workspaceChangesFallbackLabel': '工作区更改文件',
'chat.workspaceChangesFallbackTitle': '当前工作区改动({count})',
'chat.workspaceChangesFallbackExplanation': '会话过长,无法提供逐轮预览或文件撤销。此列表显示当前工作区的累计改动,可能包含其他轮次或会话外的改动。',
'chat.workspaceChangesFallbackUnavailable': '会话过长,无法提供逐轮预览或文件撤销。当前工作区改动无法确认,暂不提供文件列表。',
'chat.workspaceChangesFallbackEmpty': '未发现当前工作区改动。',
'chat.workspaceChangesFallbackOpenFile': '打开 {path} 的当前内容',
'chat.turnChangesTitle': '{count} 个文件已更改',
'chat.turnChangesLatestCardLabel': '轮次已更改文件',
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
@@ -0,0 +1,126 @@
import { afterAll, beforeAll, expect, test } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js'
const checkpointBudget = 16 * 1024 * 1024
const originalEnvironment = { ...process.env }
let sandbox: string
let workDir: string
let configDir: string
let handleSessionsApi: typeof import('./sessions.js').handleSessionsApi
beforeAll(async () => {
sandbox = await mkdtemp(join(tmpdir(), 'qa-005-checkpoint-budget-'))
const environment = createSandboxedTestEnvironment(sandbox, {
DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', DISABLE_AUTOUPDATER: '1',
})
for (const key of Object.keys(process.env)) delete process.env[key]
Object.assign(process.env, environment)
configDir = environment.CLAUDE_CONFIG_DIR!
workDir = join(sandbox, 'workspace')
await mkdir(workDir)
await mkdir(join(configDir, 'projects', 'fixture'), { recursive: true })
handleSessionsApi = (await import('./sessions.js')).handleSessionsApi
})
afterAll(async () => {
for (const key of Object.keys(process.env)) delete process.env[key]
Object.assign(process.env, originalEnvironment)
await rm(sandbox, { recursive: true, force: true })
})
function row(type: string, content: unknown, extra: Record<string, unknown> = {}) {
return JSON.stringify({
uuid: crypto.randomUUID(), type, cwd: workDir,
timestamp: '2026-10-01T08:00:00.000Z',
message: { role: type, content }, ...extra,
}) + '\n'
}
async function createTranscript(content: string, activityCount = 0, totalBytes?: number) {
const sessionId = crypto.randomUUID()
const filePath = join(configDir, 'projects', 'fixture', `${sessionId}.jsonl`)
const changedPath = join(workDir, `${sessionId}.txt`)
const entries = [row('user', 'Synthetic history replay; inspect the file')]
// Like the QA fixture, these are completed Agent records below the per-record
// budget. Their cumulative activity is allowed to overflow recovery while the
// small Write remains complete. This never invokes an agent or provider.
for (let index = 0; index < activityCount; index++) {
entries.push(row('assistant', [{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {
description: 'a'.repeat(10 * 1024), subagent_type: 'general-purpose',
} }]))
entries.push(row('user', [{ type: 'tool_result', tool_use_id: `agent-${index}`, content: 'Synthetic completed result' }]))
}
entries.push(row('assistant', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: changedPath, content } }]))
entries.push(row('user', [{ type: 'tool_result', tool_use_id: 'write', content: 'Created successfully' }]))
entries.push(row('assistant', [{ type: 'text', text: 'Synthetic replay complete' }]))
if (totalBytes !== undefined) {
let remaining = totalBytes - Buffer.byteLength(entries.join(''))
const overhead = Buffer.byteLength(JSON.stringify({ type: 'progress', padding: '' }) + '\n')
while (remaining > 0) {
const bytes = Math.min(60 * 1024, remaining)
if (bytes < overhead) throw new Error('Invalid fixture padding')
entries.push(JSON.stringify({ type: 'progress', padding: 'x'.repeat(bytes - overhead) }) + '\n')
remaining -= bytes
}
}
const transcript = entries.join('')
await writeFile(filePath, transcript)
await writeFile(changedPath, content)
return { sessionId, fileName: `${sessionId}.txt`, bytes: Buffer.byteLength(transcript) }
}
async function get(sessionId: string, resource: string) {
const request = new Request(`http://localhost/api/sessions/${sessionId}/${resource}`)
const url = new URL(request.url)
return handleSessionsApi(request, url, url.pathname.split('/').filter(Boolean))
}
test('QA-005 keeps the full checkpoint limit while retaining bounded workspace files after activity overflow', async () => {
const fixture = await createTranscript('QA retained workspace evidence\n', 2050)
expect(fixture.bytes).toBeGreaterThan(checkpointBudget)
const checkpoints = await get(fixture.sessionId, 'turn-checkpoints')
expect(checkpoints.status).toBe(413)
expect(await checkpoints.json()).toMatchObject({ error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' })
const workspace = await get(fixture.sessionId, 'workspace/status')
expect(workspace.status).toBe(200)
expect(await workspace.json()).toMatchObject({
state: 'ok', isGitRepo: false,
changedFiles: [{ path: fixture.fileName, status: 'added', additions: 1, deletions: 0 }],
})
const file = await get(fixture.sessionId, `workspace/file?path=${fixture.fileName}`)
expect(file.status).toBe(200)
expect(await file.json()).toMatchObject({ state: 'ok', content: 'QA retained workspace evidence\n' })
// A current-content preview is safe; a claimed turn baseline still requires
// the full checkpoint evidence and must not sneak past its original budget.
expect((await get(fixture.sessionId, `turn-checkpoints/diff?userMessageIndex=0&path=${fixture.fileName}`)).status).toBe(413)
}, 20_000)
test('QA-005 oversized Write remains conservative even though the current file is readable', async () => {
const fixture = await createTranscript('x'.repeat(128 * 1024), 2050)
const workspace = await get(fixture.sessionId, 'workspace/status')
expect(workspace.status).toBe(413)
expect(await workspace.json()).toMatchObject({ error: 'HISTORY_WORKSPACE_LIMIT' })
const file = await get(fixture.sessionId, `workspace/file?path=${fixture.fileName}`)
expect(file.status).toBe(200)
expect(await file.json()).toMatchObject({ state: 'ok', size: 128 * 1024 })
const checkpoints = await get(fixture.sessionId, 'turn-checkpoints')
expect(checkpoints.status).toBe(413)
expect(await checkpoints.json()).toMatchObject({ error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' })
}, 20_000)
test('QA-005 full checkpoint preview admits exactly 16MiB and rejects the next byte', async () => {
const admitted = await createTranscript('boundary\n', 0, checkpointBudget)
expect(admitted.bytes).toBe(checkpointBudget)
const checkpoints = await get(admitted.sessionId, 'turn-checkpoints')
expect(checkpoints.status).toBe(200)
const body = await checkpoints.json() as { checkpoints: Array<{ code: { filesChanged: string[] } }> }
expect(body.checkpoints[0]?.code.filesChanged).toContain(join(workDir, admitted.fileName))
const rejected = await createTranscript('boundary\n', 0, checkpointBudget + 1)
expect(rejected.bytes).toBe(checkpointBudget + 1)
const response = await get(rejected.sessionId, 'turn-checkpoints')
expect(response.status).toBe(413)
expect(await response.json()).toMatchObject({ error: 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' })
}, 20_000)