fix: verify Cursor chat store before reopen (#1037)

* test: reproduce issue #841

* test: cover Cursor chat store discovery

* fix: verify Cursor chat store before resume (closes #841)

* test: preserve non-Cursor resume behavior

* test: cover conservative Cursor resume gating

* fix: gate Cursor reopen until store verification

* test: cover legacy Cursor drawer fallback

* fix: scan unique legacy Cursor store drawer

* test: preserve raw Cursor workspace path hashing

* fix: hash raw Cursor workspace path

* test: pin Cursor probe owner and machine

* fix: probe Cursor store on recorded owner

* test: normalize Cursor probe owner home

* fix: normalize Cursor probe owner home
This commit is contained in:
SSU-WEI HUANG
2026-07-16 12:34:41 +08:00
committed by GitHub
parent adb6f41858
commit 520c3f511a
27 changed files with 955 additions and 34 deletions
+13
View File
@@ -79,4 +79,17 @@ describe('ApiClient error mapping', () => {
expect(apiError.body).toContain('cursorSessionId')
}
})
it('loads the Cursor chat store status for the selected session', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ onDisk: false, store: null }), { status: 200 })
)
const api = new ApiClient('test-token')
await expect(api.getCursorChatStoreStatus('session cursor')).resolves.toEqual({
onDisk: false,
store: null
})
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/sessions/session%20cursor/cursor-chat-store')
})
})
+7
View File
@@ -27,6 +27,7 @@ import type {
CodexModelsResponse,
CursorMigrateOutcome,
CursorMigrateToAcpRequest,
CursorChatStoreStatus,
CursorModelsResponse,
DeleteUploadResponse,
FileReadResponse,
@@ -389,6 +390,12 @@ export class ApiClient {
return response.sessionId
}
async getCursorChatStoreStatus(sessionId: string): Promise<CursorChatStoreStatus> {
return await this.request<CursorChatStoreStatus>(
`/api/sessions/${encodeURIComponent(sessionId)}/cursor-chat-store`
)
}
async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: 'POST',
@@ -52,6 +52,23 @@ describe('SessionActionMenu - Reopen action', () => {
expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument()
})
it('renders a disabled Reopen item with an explanation when resume data is missing', () => {
const onClose = vi.fn()
renderMenu({
sessionActive: false,
onReopen: undefined,
reopenDisabledReason: 'Cursor chat data is no longer available on this machine.',
onClose,
})
const reopen = screen.getByRole('menuitem', { name: /Reopen/ })
expect(reopen).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('tooltip')).toHaveTextContent('Cursor chat data is no longer available')
fireEvent.click(reopen)
expect(onClose).not.toHaveBeenCalled()
})
it('fires onReopen and closes the menu when the Reopen item is clicked', () => {
const onReopen = vi.fn()
const onClose = vi.fn()
+26 -9
View File
@@ -8,6 +8,7 @@ import {
type CSSProperties
} from 'react'
import { useTranslation } from '@/lib/use-translation'
import { HoverTooltip } from '@/components/HoverTooltip'
type SessionActionMenuProps = {
isOpen: boolean
@@ -17,6 +18,7 @@ type SessionActionMenuProps = {
onExport?: () => void
onArchive: () => void
onReopen?: () => void
reopenDisabledReason?: string
onDelete: () => void
anchorPoint: { x: number; y: number }
menuId?: string
@@ -143,6 +145,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
onExport,
onArchive,
onReopen,
reopenDisabledReason,
onDelete,
anchorPoint,
menuId
@@ -318,16 +321,30 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
</button>
) : (
<>
{onReopen ? (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={handleReopen}
{onReopen || reopenDisabledReason ? (
<HoverTooltip
id={`${resolvedMenuId}-reopen-tooltip`}
className="w-full [&>span:first-child]:w-full"
align="start"
revealOnParentFocusClass="group-focus-within:opacity-100 group-focus-within:visible"
target={(
<button
type="button"
role="menuitem"
aria-disabled={reopenDisabledReason ? true : undefined}
aria-describedby={reopenDisabledReason ? `${resolvedMenuId}-reopen-tooltip` : undefined}
className={`${baseItemClassName} ${reopenDisabledReason
? 'cursor-not-allowed opacity-50'
: 'hover:bg-[var(--app-subtle-bg)]'}`}
onClick={reopenDisabledReason ? undefined : handleReopen}
>
<ReopenIcon className="text-[var(--app-hint)]" />
{t('session.action.reopen')}
</button>
)}
>
<ReopenIcon className="text-[var(--app-hint)]" />
{t('session.action.reopen')}
</button>
{reopenDisabledReason ?? t('session.action.reopen')}
</HoverTooltip>
) : null}
<button
type="button"
+9 -1
View File
@@ -395,6 +395,8 @@ function hasAbortableAgentRun(blocks: readonly ChatBlock[]): boolean {
type SessionChatProps = {
api: ApiClient
session: Session
cursorChatOnDisk?: boolean
reopenDisabledReason?: string
messages: DecryptedMessage[]
pendingMessages?: DecryptedMessage[]
messagesWarning: string | null
@@ -452,7 +454,11 @@ function SessionChatInner(props: SessionChatProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const sessionInactive = !props.session.active
const inactiveCanResume = inactiveSessionCanResume(props.session, props.messages.length)
const inactiveCanResume = inactiveSessionCanResume(
props.session,
props.messages.length,
props.cursorChatOnDisk
)
const terminalSupported = isRemoteTerminalSupported(props.session.metadata)
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
@@ -1217,6 +1223,8 @@ function SessionChatInner(props: SessionChatProps) {
onToggleOutline={handleToggleOutline}
outlineActive={outlineOpen}
api={props.api}
canReopen={inactiveCanResume}
reopenDisabledReason={props.reopenDisabledReason}
onSessionDeleted={props.onBack}
onSessionReopened={(newSessionId) => {
navigate({
+4 -1
View File
@@ -92,6 +92,8 @@ export function SessionHeader(props: {
onToggleOutline?: () => void
outlineActive?: boolean
api: ApiClient | null
canReopen?: boolean
reopenDisabledReason?: string
onSessionDeleted?: () => void
onSessionReopened?: (newSessionId: string) => void
}) {
@@ -258,7 +260,8 @@ export function SessionHeader(props: {
onRename={() => setRenameOpen(true)}
onExport={() => setExportOpen(true)}
onArchive={() => setArchiveOpen(true)}
onReopen={handleReopen}
onReopen={props.canReopen === false ? undefined : handleReopen}
reopenDisabledReason={props.reopenDisabledReason}
onDelete={() => setDeleteOpen(true)}
anchorPoint={menuAnchorPoint}
menuId={menuId}
+19 -1
View File
@@ -26,6 +26,7 @@ import { getSessionTitle } from '@/lib/sessionTitle'
import type { Machine } from '@/types/api'
import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth'
import { MachineGroupHeader } from '@/components/MachineGroupHeader'
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
type SessionGroup = {
key: string
@@ -596,6 +597,22 @@ function SessionItem(props: {
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const {
status: cursorChatStoreStatus,
isApplicable: cursorChatStoreApplicable,
error: cursorChatStoreError,
} = useCursorChatStoreStatus({
api,
session: s,
enabled: menuOpen
})
const cursorReopenDisabledReason = cursorChatStoreApplicable && cursorChatStoreStatus?.onDisk !== true
? cursorChatStoreError
? t('session.action.reopenCursorCheckFailed')
: cursorChatStoreStatus?.onDisk === false
? t('session.action.reopenCursorMissing')
: t('session.action.reopenCursorChecking')
: undefined
const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions(
api,
@@ -732,7 +749,8 @@ function SessionItem(props: {
sessionActive={s.active}
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onReopen={handleReopen}
onReopen={cursorReopenDisabledReason ? undefined : handleReopen}
reopenDisabledReason={cursorReopenDisabledReason}
onDelete={() => setDeleteOpen(true)}
anchorPoint={menuAnchorPoint}
/>
@@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query'
import type { ApiClient } from '@/api/client'
import type { CursorChatStoreStatus, Session, SessionSummary } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
type CursorChatStoreSession = Pick<Session | SessionSummary, 'id' | 'active' | 'metadata'>
export function useCursorChatStoreStatus(args: {
api: ApiClient | null
session: CursorChatStoreSession | null
enabled?: boolean
}): {
status: CursorChatStoreStatus | undefined
isApplicable: boolean
isLoading: boolean
error: string | null
} {
const { api, session } = args
const metadata = session?.metadata
const cursorSessionId = metadata && 'cursorSessionId' in metadata
? metadata.cursorSessionId
: metadata && 'agentSessionId' in metadata
? metadata.agentSessionId
: undefined
const shouldProbe = Boolean(
session
&& !session.active
&& metadata?.flavor === 'cursor'
&& cursorSessionId
&& metadata.path
)
const enabled = Boolean((args.enabled ?? true) && api && shouldProbe)
const sessionId = session?.id ?? 'unknown'
const query = useQuery({
queryKey: queryKeys.sessionCursorChatStore(sessionId),
queryFn: async () => {
if (!api || !session) {
throw new Error('Cursor session unavailable')
}
return await api.getCursorChatStoreStatus(session.id)
},
enabled,
staleTime: 5_000,
retry: false,
})
return {
status: query.data,
isApplicable: shouldProbe,
isLoading: query.isLoading,
error: query.error instanceof Error
? query.error.message
: query.error
? 'Failed to inspect Cursor chat store'
: null,
}
}
+3
View File
@@ -159,6 +159,9 @@ export default {
'session.action.export': 'Export conversation',
'session.action.archive': 'Archive',
'session.action.reopen': 'Reopen',
'session.action.reopenCursorChecking': 'Checking whether Cursor chat data is still available on the recorded machine.',
'session.action.reopenCursorMissing': 'Cursor chat data is no longer available on the recorded machine.',
'session.action.reopenCursorCheckFailed': 'Could not verify Cursor chat data on the recorded machine.',
'session.action.delete': 'Delete',
'session.action.copy': 'Copy',
+3
View File
@@ -159,6 +159,9 @@ export default {
'session.action.export': '导出对话',
'session.action.archive': '归档',
'session.action.reopen': '重新打开',
'session.action.reopenCursorChecking': '正在检查记录设备上的 Cursor 聊天数据是否仍然可用。',
'session.action.reopenCursorMissing': '记录设备上的 Cursor 聊天数据已不可用。',
'session.action.reopenCursorCheckFailed': '无法验证记录设备上的 Cursor 聊天数据。',
'session.action.delete': '删除',
'session.action.copy': '复制',
+1
View File
@@ -17,6 +17,7 @@ export const queryKeys = {
slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const,
sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const,
sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const,
sessionCursorChatStore: (sessionId: string) => ['session-cursor-chat-store', sessionId] as const,
sessionPiModels: (sessionId: string) => ['session-pi-models', sessionId] as const,
machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const,
sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const,
+34 -1
View File
@@ -64,7 +64,40 @@ describe('sessionResume', () => {
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5)).toBe(true)
}), 5, true)).toBe(true)
})
it('conservatively rejects cursor resume until the chat store is verified', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5)).toBe(false)
})
it('rejects cursor resume when the recorded chat store is missing on its machine', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5, false)).toBe(false)
})
it('does not apply Cursor chat store status to other agent flavors', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
},
}), 5, false)).toBe(true)
})
it('resolveAgentSessionIdFromMetadata still returns cursorSessionId regardless of protocol', () => {
+5
View File
@@ -34,6 +34,7 @@ export function resolveAgentSessionIdFromMetadata(
export function inactiveSessionCanResume(
session: Session,
userMessageCount: number,
cursorChatOnDisk?: boolean,
): boolean {
if (session.active) {
return true
@@ -42,6 +43,10 @@ export function inactiveSessionCanResume(
return false
}
if (resolveAgentSessionIdFromMetadata(session.metadata)) {
const flavor = isKnownFlavor(session.metadata.flavor) ? session.metadata.flavor : 'claude'
if (flavor === 'cursor') {
return cursorChatOnDisk === true
}
return true
}
const flavor = isKnownFlavor(session.metadata.flavor) ? session.metadata.flavor : 'claude'
+20 -2
View File
@@ -28,6 +28,7 @@ import { useSidebarResize } from '@/hooks/useSidebarResize'
import { useMessages } from '@/hooks/queries/useMessages'
import { useMachines } from '@/hooks/queries/useMachines'
import { useSession } from '@/hooks/queries/useSession'
import { useCursorChatStoreStatus } from '@/hooks/queries/useCursorChatStoreStatus'
import { useSessions } from '@/hooks/queries/useSessions'
import { useSlashCommands } from '@/hooks/queries/useSlashCommands'
import { useSkills } from '@/hooks/queries/useSkills'
@@ -633,6 +634,11 @@ function SessionPage() {
error: sessionError,
refetch: refetchSession,
} = useSession(api, sessionId)
const {
status: cursorChatStoreStatus,
isApplicable: cursorChatStoreApplicable,
error: cursorChatStoreError,
} = useCursorChatStoreStatus({ api, session })
const {
messages,
pendingMessages,
@@ -728,6 +734,16 @@ function SessionPage() {
})()
}, [api, queryClient, navigate, addToast, t])
const cursorReopenDisabledReason = cursorChatStoreApplicable && cursorChatStoreStatus?.onDisk !== true
? cursorChatStoreError
? t('session.action.reopenCursorCheckFailed')
: cursorChatStoreStatus?.onDisk === false
? t('session.action.reopenCursorMissing')
: t('session.action.reopenCursorChecking')
: undefined
const canOfferInactiveReopen = session
? inactiveSessionCanResume(session, messages.length, cursorChatStoreStatus?.onDisk)
: false
const rawSendError = sendErrors[sessionId] ?? null
const sendError: ComposerSendError | null = rawSendError
? {
@@ -735,7 +751,7 @@ function SessionPage() {
text: rawSendError.text,
message: rawSendError.message,
scheduledAt: rawSendError.scheduledAt,
action: rawSendError.code === 'session_inactive'
action: rawSendError.code === 'session_inactive' && canOfferInactiveReopen
? {
label: t('chat.sendError.sessionInactive.action'),
onClick: () => reopenFromErrorAffordance(sessionId),
@@ -782,7 +798,7 @@ function SessionPage() {
if (!api || !session || session.active) {
return currentSessionId
}
if (!inactiveSessionCanResume(session, messages.length)) {
if (!inactiveSessionCanResume(session, messages.length, cursorChatStoreStatus?.onDisk)) {
// #918: surface as a session_inactive ApiError so the
// onError consumer's classifier renders the Reopen
// affordance. `status: 409` mirrors the hub guard for
@@ -919,6 +935,8 @@ function SessionPage() {
<SessionChat
api={api}
session={session}
cursorChatOnDisk={cursorChatStoreStatus?.onDisk}
reopenDisabledReason={cursorReopenDisabledReason}
messages={messages}
pendingMessages={pendingMessages}
messagesWarning={messagesWarning}
+1
View File
@@ -14,6 +14,7 @@ export type {
CommandResponse,
CursorModelsResponse,
CursorModelSummary,
CursorChatStoreStatus,
DeleteUploadResponse,
DirectoryEntry,
FileReadResponse,