mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user