diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c5292da7..7f62567a 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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>) => 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() + 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>) + } + 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() + 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() + 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>) => void + const requests: Array = [] + 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() + await waitFor(() => expect(requests).toHaveLength(1)) + rerender() + 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() + 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() + 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 () => { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a6c13d1c..5c8667c7 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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>({}) const [turnChangeCards, setTurnChangeCards] = useState([]) const [turnChangeLoadError, setTurnChangeLoadError] = useState(null) + const [workspaceChangesFallback, setWorkspaceChangesFallback] = useState<{ + sessionId: string + files: WorkspaceChangedFile[] | null + } | null>(null) const [turnActionErrors, setTurnActionErrors] = useState>({}) const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false) const [branchingMessageId, setBranchingMessageId] = useState(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({ )} + {!isLoadingTurnChangeCards && chatState === 'idle' && !hasRunningBackgroundTasks && + workspaceChangesFallback?.sessionId === resolvedSessionId ? ( + + ) : null} +
diff --git a/desktop/src/components/chat/WorkspaceChangesFallback.test.tsx b/desktop/src/components/chat/WorkspaceChangesFallback.test.tsx new file mode 100644 index 00000000..e81544ae --- /dev/null +++ b/desktop/src/components/chat/WorkspaceChangesFallback.test.tsx @@ -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() + 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() + 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() + 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() + expect(screen.getByRole('alert')).toHaveTextContent('Current workspace changes could not be verified') + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/chat/WorkspaceChangesFallback.tsx b/desktop/src/components/chat/WorkspaceChangesFallback.tsx new file mode 100644 index 00000000..00d61d23 --- /dev/null +++ b/desktop/src/components/chat/WorkspaceChangesFallback.tsx @@ -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 ( +
+
+

+ {files === null + ? t('chat.workspaceChangesFallbackLabel') + : t('chat.workspaceChangesFallbackTitle', { count: files.length })} +

+

+ {t(files === null ? 'chat.workspaceChangesFallbackUnavailable' : 'chat.workspaceChangesFallbackExplanation')} +

+
+ + {files?.length === 0 ? ( +

+ {t('chat.workspaceChangesFallbackEmpty')} +

+ ) : null} + + {visibleFiles?.length ? ( +
    + {visibleFiles.map((file) => ( +
  • + +
  • + ))} +
+ ) : null} + + {files && files.length > COLLAPSED_COUNT ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 2102f584..02a49bfa 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index c7eb01eb..7c84d49d 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2833,6 +2833,12 @@ export const jp: Record = { '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': 'ターンで変更されたファイル', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 3c0ce05c..cea5b2e4 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2835,6 +2835,12 @@ export const kr: Record = { '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': '턴에서 변경된 파일', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index c2170a91..60fc18c9 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2832,6 +2832,12 @@ export const zh: Record = { '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': '輪次已更改檔案', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index eebf9d3b..e7cdf4d5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2831,6 +2831,12 @@ export const zh: Record = { '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': '轮次已更改文件', diff --git a/src/server/api/sessions.checkpointBudget.test.ts b/src/server/api/sessions.checkpointBudget.test.ts new file mode 100644 index 00000000..5e05c2d9 --- /dev/null +++ b/src/server/api/sessions.checkpointBudget.test.ts @@ -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 = {}) { + 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)