diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.test.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.test.ts new file mode 100644 index 00000000000..104f498c780 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-session-resume-controls.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/shared/ai-vault-session-id.test.ts b/src/shared/ai-vault-session-id.test.ts new file mode 100644 index 00000000000..043ca6ddd03 --- /dev/null +++ b/src/shared/ai-vault-session-id.test.ts @@ -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') + }) +})