extend(ai-vault): support Cursor sessions with host-aware context isolat

- Hide previous client snapshot while next client loads; ignore stale responses
- Extend session-scanner cache keys with execution host ID and file identity (dev/ino/nlink) to prevent reusing host-stamped issues across different execution contexts
- Invalidate all-host cache when connected host set changes via topology snapshot
- Forward WSL distro context through preflight agent detection methods
- Preserve legacy Cursor command when paired with older runtimes via fallback detection
- Fix bounded file read to handle partial reads until EOF
- Add test coverage for snapshot isolation, cache invalidation, and backward compatibility
This commit is contained in:
Jinjing
2026-08-05 12:48:01 -07:00
parent d6a27423fc
commit 1cff3be4e4
16 changed files with 587 additions and 56 deletions
@@ -0,0 +1,137 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { Worktree } from '../worktree/workspace-list-types'
import { useMobileAgentHistoryWorktrees } from './use-mobile-agent-history-worktrees'
describe('useMobileAgentHistoryWorktrees', () => {
let renderer: ReactTestRenderer | null = null
let latest = { worktrees: [] as Worktree[], worktreesLoaded: false }
let consoleSpy: MockInstance
function Harness(props: { client: Pick<RpcClient, 'sendRequest'>; connected?: boolean }): null {
latest = useMobileAgentHistoryWorktrees(props.client, props.connected ?? true)
return null
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
latest = { worktrees: [], worktreesLoaded: false }
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
consoleSpy.mockRestore()
})
it('hides a previous client snapshot while the next client loads', async () => {
const firstWorktree = worktree('first')
const secondWorktree = worktree('second')
const firstClient = clientWithResponse(success([firstWorktree]))
const secondResponse = deferred<RpcResponse>()
const secondClient = clientWithResponse(secondResponse.promise)
await render(firstClient)
expect(latest).toEqual({ worktrees: [firstWorktree], worktreesLoaded: true })
act(() => renderer?.update(createElement(Harness, { client: secondClient })))
expect(latest).toEqual({ worktrees: [], worktreesLoaded: false })
await act(async () => {
secondResponse.resolve(success([secondWorktree]))
await Promise.resolve()
})
expect(latest).toEqual({ worktrees: [secondWorktree], worktreesLoaded: true })
})
it('ignores a response from a replaced client', async () => {
const firstResponse = deferred<RpcResponse>()
const secondResponse = deferred<RpcResponse>()
const firstClient = clientWithResponse(firstResponse.promise)
const secondClient = clientWithResponse(secondResponse.promise)
await render(firstClient)
act(() => renderer?.update(createElement(Harness, { client: secondClient })))
await act(async () => {
firstResponse.resolve(success([worktree('stale')]))
await Promise.resolve()
})
expect(latest).toEqual({ worktrees: [], worktreesLoaded: false })
await act(async () => {
secondResponse.resolve(success([worktree('current')]))
await Promise.resolve()
})
expect(latest.worktrees[0]?.worktreeId).toBe('current')
})
it('finishes loading without scope data when the request fails', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('offline'))
} as Pick<RpcClient, 'sendRequest'>
await render(client)
expect(latest).toEqual({ worktrees: [], worktreesLoaded: true })
})
async function render(client: Pick<RpcClient, 'sendRequest'>): Promise<void> {
await act(async () => {
renderer = create(createElement(Harness, { client }))
await Promise.resolve()
await Promise.resolve()
})
}
})
function clientWithResponse(response: Promise<RpcResponse> | RpcResponse) {
return {
sendRequest: vi.fn().mockResolvedValue(response)
} as Pick<RpcClient, 'sendRequest'>
}
function success(worktrees: Worktree[]): RpcResponse {
return {
id: 'response',
ok: true,
result: { worktrees },
_meta: { runtimeId: 'runtime' }
}
}
function worktree(worktreeId: string): Worktree {
return {
worktreeId,
repoId: 'repo',
repo: 'repo',
branch: worktreeId,
displayName: worktreeId,
path: `/repo/${worktreeId}`,
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
return {
promise: new Promise<T>((settle) => {
resolve = settle
}),
resolve
}
}
@@ -7,8 +7,11 @@ export function useMobileAgentHistoryWorktrees(
client: Pick<RpcClient, 'sendRequest'> | null,
connected: boolean
): { worktrees: Worktree[]; worktreesLoaded: boolean } {
const [worktrees, setWorktrees] = useState<Worktree[]>([])
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
const [snapshot, setSnapshot] = useState<{
client: Pick<RpcClient, 'sendRequest'> | null
worktrees: Worktree[]
loaded: boolean
}>({ client: null, worktrees: [], loaded: false })
useEffect(() => {
if (!client || !connected) {
return
@@ -18,13 +21,21 @@ export function useMobileAgentHistoryWorktrees(
try {
const response = await client.sendRequest('worktree.ps', { limit: 10000 })
if (!cancelled && response.ok) {
setWorktrees(((response as RpcSuccess).result as { worktrees: Worktree[] }).worktrees)
setSnapshot({
client,
worktrees: ((response as RpcSuccess).result as { worktrees: Worktree[] }).worktrees,
loaded: true
})
}
} catch {
// Scope context is best effort; the session scan can still proceed unscoped.
} finally {
if (!cancelled) {
setWorktreesLoaded(true)
setSnapshot((current) =>
current.client === client
? { ...current, loaded: true }
: { client, worktrees: [], loaded: true }
)
}
}
})()
@@ -32,5 +43,7 @@ export function useMobileAgentHistoryWorktrees(
cancelled = true
}
}, [client, connected])
return { worktrees, worktreesLoaded }
return snapshot.client === client && connected
? { worktrees: snapshot.worktrees, worktreesLoaded: snapshot.loaded }
: { worktrees: [], worktreesLoaded: false }
}
@@ -21,12 +21,14 @@ describe('resolveMobileCursorCommand', () => {
it('fails closed without a valid inventory unless an override exists', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: false })
await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBeNull()
sendRequest.mockRejectedValueOnce(new Error('offline'))
await expect(
resolveMobileCursorCommand({
client: { sendRequest },
settings: { agentCmdOverrides: { cursor: 'cursor-dev' } }
})
).resolves.toBe('cursor-dev')
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('requests inventory from the exact WSL distro context', async () => {
+6 -5
View File
@@ -9,19 +9,20 @@ export async function resolveMobileCursorCommand(args: {
settings?: MobileAiVaultResumeSettings | null
wslDistro?: string | null
}): Promise<string | null> {
const configured = resolveEffectiveCursorCommand(args.settings?.agentCmdOverrides?.cursor, null)
if (configured) {
return configured
}
const wslDistro = args.wslDistro?.trim()
const response = await args.client.sendRequest(
'preflight.detectAgentInventory',
wslDistro ? { wslDistro } : undefined
)
if (!response.ok) {
return resolveEffectiveCursorCommand(args.settings?.agentCmdOverrides?.cursor, null)
return null
}
const result = detectedAgentInventorySchema.safeParse(response.result)
return resolveEffectiveCursorCommand(
args.settings?.agentCmdOverrides?.cursor,
result.success ? result.data : null
)
return resolveEffectiveCursorCommand(null, result.success ? result.data : null)
}
export async function resolveRequiredMobileCursorResumeCommand(args: {