mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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': '复制',
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
CommandResponse,
|
||||
CursorModelsResponse,
|
||||
CursorModelSummary,
|
||||
CursorChatStoreStatus,
|
||||
DeleteUploadResponse,
|
||||
DirectoryEntry,
|
||||
FileReadResponse,
|
||||
|
||||
Reference in New Issue
Block a user