test: add session resume controls and id building tests

Comprehensive test coverage for cursor session resume controls and
session ID building with host-aware context isolation, covering command
detection, bucket collision handling, and identity preservation.
This commit is contained in:
Jinjing
2026-08-05 12:48:01 -07:00
parent a07efd89fb
commit 8cdfae055c
2 changed files with 253 additions and 0 deletions
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Repo, Worktree } from '../../../../shared/types'
import type { CursorCommandState } from '@/lib/ai-vault-cursor-command'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import { useAiVaultSessionResumeControls } from './use-ai-vault-session-resume-controls'
const WORKTREE_ID = 'repo-1::/repo/orca'
function makeWorktree(): Worktree {
return {
id: WORKTREE_ID,
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/orca',
head: 'abc123',
branch: 'main',
isBare: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
isMainWorktree: false
}
}
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo/orca',
displayName: 'orca',
badgeColor: '#000000',
addedAt: 1
}
}
function makeSession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
id: 'local:cursor:session-1',
executionHostId: 'local',
agent: 'cursor',
sessionId: 'session-1',
title: 'Session',
cwd: '/repo/orca',
branch: null,
model: null,
filePath: '/home/ada/.cursor/chats/bucket/session-1/meta.json',
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-07-30T00:00:00.000Z',
messageCount: 4,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: "cursor-agent --resume 'session-1'",
subagent: null,
...overrides
}
}
function makeTargetState(
detectedAgentCommandsByContext: CursorCommandState['detectedAgentCommandsByContext'] = {}
): AiVaultSessionResumeTargetState & CursorCommandState {
return {
activeRepoId: null,
activeWorktreeId: null,
folderWorkspaces: [],
projectGroups: [],
projects: [],
repos: [makeRepo()],
settings: {} as CursorCommandState['settings'],
worktreesByRepo: { 'repo-1': [makeWorktree()] },
detectedAgentCommandsByContext
} as AiVaultSessionResumeTargetState & CursorCommandState
}
function renderControls(args: {
targetState?: AiVaultSessionResumeTargetState & CursorCommandState
cursorCommandOverride?: string | null
activeWorktreeId?: string | null
worktrees?: readonly Worktree[]
}) {
return renderHook(() =>
useAiVaultSessionResumeControls({
activeWorktreeId: args.activeWorktreeId === undefined ? WORKTREE_ID : args.activeWorktreeId,
worktrees: args.worktrees ?? [makeWorktree()],
repos: [makeRepo()],
targetState: args.targetState ?? makeTargetState(),
cursorCommandOverride: args.cursorCommandOverride,
getSessionWorktreeInfo: () => null
})
).result.current
}
describe('useAiVaultSessionResumeControls', () => {
it('leaves non-cursor sessions ungated by cursor detection', () => {
const controls = renderControls({})
const session = makeSession({ agent: 'claude', filePath: '/home/ada/.claude/s.jsonl' })
expect(controls.getSessionResumeState(session)).not.toHaveProperty('cursorCommandAvailable')
expect(controls.getSessionResumeActions(session).newTab.disabled).toBe(false)
})
it('blocks cursor resume when the host inventory has no cursor command', () => {
const controls = renderControls({})
const session = makeSession()
expect(controls.getSessionResumeState(session)).toMatchObject({
blocked: false,
worktreeId: WORKTREE_ID,
cursorCommandAvailable: false
})
expect(controls.getSessionResumeActions(session).newTab.disabled).toBe(true)
})
it('enables cursor resume from the detected host command', () => {
const controls = renderControls({
targetState: makeTargetState({ host: { cursor: 'cursor-agent' } })
})
const session = makeSession()
expect(controls.getSessionResumeState(session).cursorCommandAvailable).toBe(true)
expect(controls.getSessionResumeActions(session).newTab.disabled).toBe(false)
})
it('enables cursor resume from an explicit override without detection', () => {
const controls = renderControls({ cursorCommandOverride: 'cursor-agent' })
const session = makeSession()
expect(controls.getSessionResumeState(session).cursorCommandAvailable).toBe(true)
expect(controls.getSessionResumeActions(session).newTab.disabled).toBe(false)
})
it('reports no cursor command when there is no resume worktree', () => {
const controls = renderControls({
targetState: makeTargetState({ host: { cursor: 'cursor-agent' } }),
activeWorktreeId: null,
worktrees: []
})
const session = makeSession()
const state = controls.getSessionResumeState(session)
expect(state.worktreeId).toBeNull()
expect(state.cursorCommandAvailable).toBe(false)
const actions = controls.getSessionResumeActions(session)
expect(actions.worktree.disabled).toBe(true)
expect(actions.newTab.disabled).toBe(true)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildAiVaultSessionId } from './ai-vault-session-id'
const base = {
executionHostId: 'local' as const,
sessionId: 'abc123',
filePath: '/home/ada/.claude/projects/repo/abc123.jsonl'
}
describe('buildAiVaultSessionId', () => {
it('appends the file path for non-cursor agents', () => {
expect(buildAiVaultSessionId({ ...base, agent: 'claude' })).toBe(
'local:claude:abc123:/home/ada/.claude/projects/repo/abc123.jsonl'
)
})
it('omits the file path for cursor so bucket moves keep session identity', () => {
expect(buildAiVaultSessionId({ ...base, agent: 'cursor' })).toBe('local:cursor:abc123')
})
it('adds a context hash when the storage context differs from the execution host', () => {
const id = buildAiVaultSessionId({
...base,
agent: 'cursor',
cursorStorageContextKey: 'wsl:Ubuntu'
})
expect(id).toMatch(/^local:cursor:abc123:ctx-[0-9a-f]{16}$/)
expect(id).toBe(
buildAiVaultSessionId({ ...base, agent: 'cursor', cursorStorageContextKey: 'wsl:Ubuntu' })
)
})
it('skips the context hash for native storage and for the execution host itself', () => {
expect(
buildAiVaultSessionId({ ...base, agent: 'cursor', cursorStorageContextKey: 'native' })
).toBe('local:cursor:abc123')
expect(
buildAiVaultSessionId({ ...base, agent: 'cursor', cursorStorageContextKey: 'local' })
).toBe('local:cursor:abc123')
})
it('tags bucket collisions and prefers them over legacy path collisions', () => {
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
cursorBucketCollision: 'deadbeef',
cursorLegacyPathCollision: '/home/ada/.cursor/chats/deadbeef'
})
).toBe('local:cursor:abc123:bucket-deadbeef')
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
cursorLegacyPathCollision: '/home/ada/.cursor/chats/deadbeef'
})
).toMatch(/^local:cursor:abc123:legacy-[0-9a-f]{16}$/)
})
it('preserves the suffix of a previous id so rescans keep the same row identity', () => {
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
previousId: 'local:cursor:abc123:ctx-0123456789abcdef:bucket-deadbeef',
cursorBucketCollision: 'cafebabe'
})
).toBe('local:cursor:abc123:ctx-0123456789abcdef:bucket-deadbeef')
})
it('reads the last session-id marker when the session id repeats in the previous id', () => {
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
previousId: 'ssh:box:cursor:abc123:cursor:abc123:bucket-deadbeef'
})
).toBe('local:cursor:abc123:bucket-deadbeef')
})
it('ignores a previous id whose suffix is not a recognized identity marker', () => {
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
previousId: 'local:cursor:abc123:/home/ada/.cursor/chats/deadbeef/abc123/meta.json'
})
).toBe('local:cursor:abc123')
})
it('ignores a previous id recorded for a different session', () => {
expect(
buildAiVaultSessionId({
...base,
agent: 'cursor',
previousId: 'local:cursor:other:bucket-deadbeef'
})
).toBe('local:cursor:abc123')
})
})