diff --git a/mobile/src/agent-history/use-mobile-agent-history-worktrees.test.ts b/mobile/src/agent-history/use-mobile-agent-history-worktrees.test.ts new file mode 100644 index 00000000000..526d1cdf579 --- /dev/null +++ b/mobile/src/agent-history/use-mobile-agent-history-worktrees.test.ts @@ -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; 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() + 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() + const secondResponse = deferred() + 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 + + await render(client) + + expect(latest).toEqual({ worktrees: [], worktreesLoaded: true }) + }) + + async function render(client: Pick): Promise { + await act(async () => { + renderer = create(createElement(Harness, { client })) + await Promise.resolve() + await Promise.resolve() + }) + } +}) + +function clientWithResponse(response: Promise | RpcResponse) { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as Pick +} + +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(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + return { + promise: new Promise((settle) => { + resolve = settle + }), + resolve + } +} diff --git a/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts index e40c5203f15..682ed90012e 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-worktrees.ts @@ -7,8 +7,11 @@ export function useMobileAgentHistoryWorktrees( client: Pick | null, connected: boolean ): { worktrees: Worktree[]; worktreesLoaded: boolean } { - const [worktrees, setWorktrees] = useState([]) - const [worktreesLoaded, setWorktreesLoaded] = useState(false) + const [snapshot, setSnapshot] = useState<{ + client: Pick | 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 } } diff --git a/mobile/src/session/mobile-cursor-command.test.ts b/mobile/src/session/mobile-cursor-command.test.ts index c34be3ff03a..25d3204b67e 100644 --- a/mobile/src/session/mobile-cursor-command.test.ts +++ b/mobile/src/session/mobile-cursor-command.test.ts @@ -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 () => { diff --git a/mobile/src/session/mobile-cursor-command.ts b/mobile/src/session/mobile-cursor-command.ts index db5d34df96c..2c30e0e6b38 100644 --- a/mobile/src/session/mobile-cursor-command.ts +++ b/mobile/src/session/mobile-cursor-command.ts @@ -9,19 +9,20 @@ export async function resolveMobileCursorCommand(args: { settings?: MobileAiVaultResumeSettings | null wslDistro?: string | null }): Promise { + 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: { diff --git a/src/main/ai-vault/session-scanner-cursor-sidecar-cache.test.ts b/src/main/ai-vault/session-scanner-cursor-sidecar-cache.test.ts new file mode 100644 index 00000000000..eca2a1a60c3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-sidecar-cache.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { cursorBucketForCwd } from './session-scanner-cursor-paths' +import { + parseCursorSidecarFileCached, + resetCursorSidecarParseCacheForTests +} from './session-scanner-cursor-sidecar' + +const roots: string[] = [] + +afterEach(async () => { + resetCursorSidecarParseCacheForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Cursor sidecar parse cache isolation', () => { + it('does not reuse host-stamped issues across execution hosts', async () => { + const fixture = await createSidecar() + const first = await parseCursorSidecarFileCached({ + ...fixture, + executionHostId: 'ssh:first' + }) + const second = await parseCursorSidecarFileCached({ + ...fixture, + executionHostId: 'runtime:second' + }) + + expect(first.issue?.executionHostId).toBe('ssh:first') + expect(second.issue?.executionHostId).toBe('runtime:second') + }) + + it('revalidates the file when the expected root changes', async () => { + const fixture = await createSidecar() + await parseCursorSidecarFileCached(fixture) + const unrelatedRoot = await mkdtemp(join(tmpdir(), 'orca-cursor-unrelated-')) + roots.push(unrelatedRoot) + + await expect( + parseCursorSidecarFileCached({ + ...fixture, + expectedRootRealPath: await realpath(unrelatedRoot) + }) + ).rejects.toThrow('verified_file_outside_root') + }) +}) + +async function createSidecar() { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-cache-')) + roots.push(root) + const chatsRoot = join(root, 'chats') + const bucket = cursorBucketForCwd('/correct', process.platform) + const sessionDir = join(chatsRoot, bucket, 'session') + const metaPath = join(sessionDir, 'meta.json') + const storePath = join(sessionDir, 'store.db') + await mkdir(sessionDir, { recursive: true }) + await Promise.all([ + writeFile( + metaPath, + JSON.stringify({ createdAtMs: 10, updatedAtMs: 20, hasConversation: true, cwd: '/wrong' }) + ), + writeFile(storePath, '') + ]) + const [meta, store, expectedRootRealPath] = await Promise.all([ + lstat(metaPath), + lstat(storePath), + realpath(chatsRoot) + ]) + return { + file: { + path: metaPath, + mtimeMs: meta.mtimeMs, + modifiedAt: meta.mtime.toISOString(), + sizeBytes: meta.size, + cursorStoreMtimeMs: store.mtimeMs, + dev: meta.dev, + ino: meta.ino, + nlink: meta.nlink + }, + platform: process.platform, + expectedRootRealPath + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-sidecar.ts b/src/main/ai-vault/session-scanner-cursor-sidecar.ts index 28544144103..944d8dce031 100644 --- a/src/main/ai-vault/session-scanner-cursor-sidecar.ts +++ b/src/main/ai-vault/session-scanner-cursor-sidecar.ts @@ -34,7 +34,12 @@ const sidecarCache = new Map< mtimeMs: number sizeBytes: number | null storeMtimeMs: number | null + dev: number | null + ino: number | null + nlink: number | null platform: NodeJS.Platform + executionHostId: ExecutionHostId | null + expectedRootRealPath: string | null result: CursorSidecarParseResult } >() @@ -68,7 +73,12 @@ export async function parseCursorSidecarFileCached(args: { cached && cached.mtimeMs === args.file.mtimeMs && cached.storeMtimeMs === (args.file.cursorStoreMtimeMs ?? null) && + cached.dev === (args.file.dev ?? null) && + cached.ino === (args.file.ino ?? null) && + cached.nlink === (args.file.nlink ?? null) && cached.platform === args.platform && + cached.executionHostId === (args.executionHostId ?? null) && + cached.expectedRootRealPath === (args.expectedRootRealPath ?? null) && (cached.sizeBytes === null || args.file.sizeBytes === undefined || cached.sizeBytes === args.file.sizeBytes) @@ -83,7 +93,12 @@ export async function parseCursorSidecarFileCached(args: { mtimeMs: args.file.mtimeMs, sizeBytes: args.file.sizeBytes ?? null, storeMtimeMs: args.file.cursorStoreMtimeMs ?? null, + dev: args.file.dev ?? null, + ino: args.file.ino ?? null, + nlink: args.file.nlink ?? null, platform: args.platform, + executionHostId: args.executionHostId ?? null, + expectedRootRealPath: args.expectedRootRealPath ?? null, result }) if (sidecarCache.size > SIDECAR_CACHE_MAX_ENTRIES) { diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index c15536bd23f..080d7fb9d9f 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -276,6 +276,23 @@ describe('listAiVaultSessions host routing', () => { expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) + it('invalidates the all-host cache when the connected host set changes', async () => { + mocks.getActiveSshAiVaultHostInfo.mockImplementation((targetId) => hostInfo(targetId)) + mocks.scanRemoteAiVaultSessions.mockImplementation(({ executionHostId }) => + Promise.resolve(result([session(executionHostId, `${executionHostId}-session`)])) + ) + await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([ + hostInfo('dev-box'), + hostInfo('second-box') + ]) + const scanResult = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + expect(scanResult.sessions.map((entry) => entry.executionHostId)).toContain('ssh:second-box') + }) + it('keeps successful hosts when one SSH scan fails', async () => { mocks.getActiveSshAiVaultHostInfos.mockReturnValue([ hostInfo('unavailable'), diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index d4e7433f8c3..62352ff246d 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -58,6 +58,11 @@ type AiVaultHandlerOptions = AiVaultSessionSources & scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner } +type AllHostScanSnapshot = { + sshHosts: AiVaultHostDiscoveryResult<{ targetId: string }> + runtimeHosts: AiVaultHostDiscoveryResult +} + let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() @@ -69,17 +74,22 @@ async function listAiVaultSessions( const executionHostScope = normalizeExecutionHostScope( args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID ) + const allHostSnapshot = executionHostScope === 'all' ? captureAllHostScanSnapshot() : undefined // Scope paths change the result set, so they must be part of the cache key. // A scanner consumes at most 64 paths, so smaller equivalent workspace sets // can share a snapshot regardless of which worktree was selected first. const scopePaths = args?.scopePaths ?? [] - const key = JSON.stringify({ + const hostLegCacheKey = JSON.stringify({ scopePaths: scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT ? [...new Set(scopePaths)].sort() : scopePaths, executionHostScope }) + const key = JSON.stringify({ + hostLegCacheKey, + hostTopology: allHostSnapshot ? allHostTopologyKey(allHostSnapshot) : undefined + }) const depth = requestedAiVaultSessionDepth(args) const scanKey = JSON.stringify({ key, depth }) const force = @@ -94,7 +104,14 @@ async function listAiVaultSessions( force, signal: options.signal, start: (scanSignal) => { - const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key) + const scan = () => + scanAiVaultSessionsByHostScope( + args, + executionHostScope, + scanSignal, + hostLegCacheKey, + allHostSnapshot + ) if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { return scan() } @@ -113,7 +130,8 @@ async function scanAiVaultSessionsByHostScope( args: AiVaultListArgs | undefined, executionHostScope: ExecutionHostScope, signal?: AbortSignal, - cacheKey = '' + cacheKey = '', + allHostSnapshot?: AllHostScanSnapshot ): Promise { const depth = requestedAiVaultSessionDepth(args) const scopePaths = args?.scopePaths ?? [] @@ -121,8 +139,8 @@ async function scanAiVaultSessionsByHostScope( return scanLocalAiVaultSessions(args, signal) } if (executionHostScope === 'all') { - const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() - const sshHosts = getActiveSshAiVaultHostInfosResult() + const snapshot = allHostSnapshot ?? captureAllHostScanSnapshot() + const { runtimeHosts, sshHosts } = snapshot const runtimeResults = [ ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), ...(sshHosts.issue ? [sshHosts.issue] : []) @@ -208,6 +226,24 @@ function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult host.targetId).sort(), + runtime: snapshot.runtimeHosts.hostInfos.map((host) => host.executionHostId).sort(), + issues: [snapshot.sshHosts.issue, snapshot.runtimeHosts.issue] + .flatMap((result) => result?.issues ?? []) + .map((issue) => `${issue.path}:${issue.message}`) + .sort() + } +} + function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> { return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, { path: 'SSH hosts', diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index c935f6d3c14..2b6fcac5a3d 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,6 +5,7 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { + detectInstalledAgentCommandsWithShellPathHydrationMock, detectInstalledAgentInventoryWithShellPathHydrationMock, detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentInventoryMock, @@ -13,6 +14,7 @@ const { refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ + detectInstalledAgentCommandsWithShellPathHydrationMock: vi.fn(), detectInstalledAgentInventoryWithShellPathHydrationMock: vi.fn(), detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentInventoryMock: vi.fn(), @@ -23,6 +25,8 @@ const { })) vi.mock('../../../ipc/preflight', () => ({ + detectInstalledAgentCommandsWithShellPathHydration: + detectInstalledAgentCommandsWithShellPathHydrationMock, detectInstalledAgentInventoryWithShellPathHydration: detectInstalledAgentInventoryWithShellPathHydrationMock, detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, @@ -124,6 +128,38 @@ describe('preflight RPC methods', () => { expect(detectInstalledAgentInventoryWithShellPathHydrationMock).toHaveBeenCalledWith({}) }) + it('forwards WSL context through every local agent detection method', async () => { + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['cursor']) + detectInstalledAgentCommandsWithShellPathHydrationMock.mockResolvedValueOnce({ + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + }) + refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({ + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' }, + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS }) + const context = { wslDistro: 'Ubuntu' } + + await expect( + dispatcher.dispatch(makeRequest('preflight.detectAgents', context)) + ).resolves.toMatchObject({ ok: true, result: ['cursor'] }) + await expect( + dispatcher.dispatch(makeRequest('preflight.detectAgentCommands', context)) + ).resolves.toMatchObject({ ok: true, result: { agents: ['cursor'] } }) + await expect( + dispatcher.dispatch(makeRequest('preflight.refreshAgents', context)) + ).resolves.toMatchObject({ ok: true, result: { agents: ['cursor'] } }) + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenLastCalledWith(context) + expect(detectInstalledAgentCommandsWithShellPathHydrationMock).toHaveBeenLastCalledWith(context) + expect(refreshShellPathAndDetectAgentsMock).toHaveBeenLastCalledWith(context) + }) + it('detects agents on remote SSH connections through runtime RPC', async () => { detectRemoteAgentsMock.mockResolvedValueOnce(['claude']) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index c61dc5220e9..b11f30f3004 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -33,13 +33,13 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'preflight.detectAgents', - params: null, - handler: async () => detectInstalledAgentsWithShellPathHydration() + params: PreflightLocalAgentContext, + handler: async (params) => detectInstalledAgentsWithShellPathHydration(params) }), defineMethod({ name: 'preflight.detectAgentCommands', - params: null, - handler: async () => detectInstalledAgentCommandsWithShellPathHydration() + params: PreflightLocalAgentContext, + handler: async (params) => detectInstalledAgentCommandsWithShellPathHydration(params) }), defineMethod({ name: 'preflight.detectAgentInventory', @@ -68,7 +68,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'preflight.refreshAgents', - params: null, - handler: async () => refreshShellPathAndDetectAgents() + params: PreflightLocalAgentContext, + handler: async (params) => refreshShellPathAndDetectAgents(params) }) ] diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 0e17a4c5ea6..5ea0283e4bd 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -105,6 +105,15 @@ describe('createDetectedAgentsSlice WSL context', () => { }) }) + it('preserves the legacy Cursor command when the preload lacks inventory methods', async () => { + detectAgents.mockResolvedValueOnce(['cursor']) + const store = createTestStore() + store.getState().clearLocalDetectedAgents() + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['cursor']) + expect(store.getState().detectedAgentCommands).toEqual({ cursor: 'cursor-agent' }) + expect(store.getState().detectedAgentCommandsByContext.host).toEqual({ cursor: 'cursor-agent' }) + }) + it('detects local agents inside the active WSL worktree distro', async () => { const store = createTestStore({ repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], @@ -455,6 +464,17 @@ describe('createDetectedAgentsSlice remote detection', () => { }) }) + it('preserves the legacy Cursor command when remote inventory methods are unavailable', async () => { + detectRemoteAgents.mockResolvedValueOnce(['cursor']) + const store = createTestStore() + await expect(store.getState().ensureRemoteDetectedAgents('ssh-legacy')).resolves.toEqual([ + 'cursor' + ]) + expect(store.getState().remoteDetectedAgentCommands['ssh-legacy']).toEqual({ + cursor: 'cursor-agent' + }) + }) + it('retains remote detection promises only while requests are in flight', async () => { const store = createTestStore() let resolveRemote: (ids: string[]) => void = () => {} diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index 49a9a4e389f..9901fbf2051 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -5,6 +5,7 @@ import { getLocalAgentPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { legacyDetectedAgentInventory } from '../../../../shared/detected-agent-inventory' export type DetectedAgentsSlice = { detectedAgentIds: TuiAgent[] | null @@ -89,9 +90,7 @@ export const createDetectedAgentsSlice: StateCreator } => ({ - agents - })) + .then((agents) => legacyDetectedAgentInventory(agents as TuiAgent[])) const pending = detection .then((result) => { const typed = result.agents as TuiAgent[] @@ -223,9 +222,7 @@ export const createDetectedAgentsSlice: StateCreator } => ({ - agents - })) + .then((agents) => legacyDetectedAgentInventory(agents as TuiAgent[])) const pending = detection .then((result) => { const typed = result.agents as TuiAgent[] diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a8aec38fc92..1a1fe1dbd16 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -164,6 +164,13 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } +function successfulRuntimeResponse( + id: string, + result: TResult +): RuntimeRpcResponse { + return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + function installClipboardImageBase64(contentBase64: string): void { vi.stubGlobal( 'FileReader', @@ -1600,6 +1607,87 @@ describe('web AI Vault preload API', () => { }) }) +describe('web preflight preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves Cursor commands when paired with an older runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'preflight.detectAgents') { + return Promise.resolve(successfulRuntimeResponse(method, ['cursor', 'codex'])) + } + if (method === 'preflight.detectRemoteAgents') { + return Promise.resolve(successfulRuntimeResponse(method, ['cursor'])) + } + if (method === 'preflight.refreshAgents') { + return Promise.resolve( + successfulRuntimeResponse(method, { + agents: ['cursor'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'shell_hydrate', + pathFailureReason: 'none' + }) + ) + } + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'method_not_found', message: `Unknown method: ${method}` }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.preflight.detectAgentInventory({ wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ + agents: ['cursor', 'codex'], + matchedCommands: { cursor: 'cursor-agent' } + }) + await expect( + globals.window.api.preflight.detectRemoteAgentCommands({ connectionId: 'ssh-old' }) + ).resolves.toMatchObject({ + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + }) + await expect( + globals.window.api.preflight.refreshAgents({ wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ + agents: ['cursor'], + matchedCommands: { cursor: 'cursor-agent' } + }) + expect(runtimeCalls).toEqual([ + { method: 'preflight.detectAgentInventory', params: { wslDistro: 'Ubuntu' } }, + { method: 'preflight.detectAgents', params: undefined }, + { + method: 'preflight.detectRemoteAgentCommands', + params: { connectionId: 'ssh-old' } + }, + { method: 'preflight.detectRemoteAgents', params: { connectionId: 'ssh-old' } }, + { method: 'preflight.refreshAgents', params: { wslDistro: 'Ubuntu' } } + ]) + }) +}) + describe('web UI preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ea171dba01a..2b33a9ed6eb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -10,6 +10,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { parseHostAccessLink } from '../../../shared/remote-pairing-address' import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' import type { DetectedAgentInventoryV1 } from '../../../shared/detected-agent-inventory' +import { legacyDetectedAgentInventory } from '../../../shared/detected-agent-inventory' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -32,6 +33,7 @@ import type { RemoveWorktreeResult, SearchResult, StatsSummary, + TuiAgent, Worktree, WorktreeLineage, WorkspaceLineage, @@ -2797,6 +2799,12 @@ function createPreflightApi(): NonNullable['preflight']> { pathSource: 'sync_seed_only', pathFailureReason: 'spawn_error' } + const withLegacyMatchedCommands = (refresh: RefreshAgentsResult): RefreshAgentsResult => ({ + ...refresh, + matchedCommands: + refresh.matchedCommands ?? + legacyDetectedAgentInventory(refresh.agents as TuiAgent[]).matchedCommands + }) type WindowsTerminalCapabilityBridgeResult = { wslAvailable: boolean wslDistros: string[] @@ -2818,39 +2826,63 @@ function createPreflightApi(): NonNullable['preflight']> { } return callRuntimeResult('preflight.check', args) }, - detectAgents: async () => { + detectAgents: async (args) => { if (!requireActiveEnvironmentOrNull()) { return [] } - return callRuntimeResult('preflight.detectAgents').catch(() => []) + try { + return await callRuntimeResult('preflight.detectAgents', args) + } catch { + return args ? callRuntimeResult('preflight.detectAgents').catch(() => []) : [] + } }, - detectAgentInventory: async () => { + detectAgentInventory: async (args) => { if (!requireActiveEnvironmentOrNull()) { return { version: 1, agents: [], matchedCommands: {} } } - return callRuntimeResult('preflight.detectAgentInventory').catch( - () => ({ - version: 1, - agents: [], - matchedCommands: {} - }) + return callRuntimeResult( + 'preflight.detectAgentInventory', + args + ).catch(async () => + legacyDetectedAgentInventory( + (await callRuntimeResult('preflight.detectAgents').catch( + () => [] + )) as TuiAgent[] + ) ) }, - detectAgentCommands: async () => { + detectAgentCommands: async (args) => { if (!requireActiveEnvironmentOrNull()) { return { agents: [], matchedCommands: {} } } return callRuntimeResult<{ agents: string[] matchedCommands?: Record - }>('preflight.detectAgentCommands').catch(() => ({ agents: [], matchedCommands: {} })) + }>('preflight.detectAgentCommands', args).catch(async () => + legacyDetectedAgentInventory( + (await callRuntimeResult('preflight.detectAgents').catch( + () => [] + )) as TuiAgent[] + ) + ) + }, + refreshAgents: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return fallbackRefreshAgents + } + try { + return withLegacyMatchedCommands( + await callRuntimeResult('preflight.refreshAgents', args) + ) + } catch { + if (!args) { + return fallbackRefreshAgents + } + return callRuntimeResult('preflight.refreshAgents') + .then(withLegacyMatchedCommands) + .catch(() => fallbackRefreshAgents) + } }, - refreshAgents: () => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.refreshAgents') - .then((result) => result as RefreshAgentsResult) - .catch(() => fallbackRefreshAgents) - : Promise.resolve(fallbackRefreshAgents), detectRemoteAgents: async (args) => requireActiveEnvironmentOrNull() ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) @@ -2860,21 +2892,26 @@ function createPreflightApi(): NonNullable['preflight']> { ? callRuntimeResult( 'preflight.detectRemoteAgentInventory', args - ).catch(() => ({ - version: 1, - agents: [], - matchedCommands: {} - })) + ).catch(async () => + legacyDetectedAgentInventory( + (await callRuntimeResult('preflight.detectRemoteAgents', args).catch( + () => [] + )) as TuiAgent[] + ) + ) : { version: 1, agents: [], matchedCommands: {} }, detectRemoteAgentCommands: async (args) => requireActiveEnvironmentOrNull() ? callRuntimeResult<{ agents: string[] matchedCommands?: Record - }>('preflight.detectRemoteAgentCommands', args).catch(() => ({ - agents: [], - matchedCommands: {} - })) + }>('preflight.detectRemoteAgentCommands', args).catch(async () => + legacyDetectedAgentInventory( + (await callRuntimeResult('preflight.detectRemoteAgents', args).catch( + () => [] + )) as TuiAgent[] + ) + ) : { agents: [], matchedCommands: {} }, detectRemoteWindowsTerminalCapabilities: async (args) => requireActiveEnvironmentOrNull() diff --git a/src/shared/node-verified-bounded-text-file.test.ts b/src/shared/node-verified-bounded-text-file.test.ts new file mode 100644 index 00000000000..ad53016cbe7 --- /dev/null +++ b/src/shared/node-verified-bounded-text-file.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FileHandle } from 'node:fs/promises' +import { readBoundedFileHandle } from './node-verified-bounded-text-file' + +describe('readBoundedFileHandle', () => { + it('continues after short reads until EOF', async () => { + const source = Buffer.from('abcdef') + const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(2, length, source.length - position) + if (bytesRead > 0) { + source.copy(buffer, offset, position, position + bytesRead) + } + return { bytesRead, buffer } + }) + + await expect( + readBoundedFileHandle({ read } as unknown as FileHandle, source.length) + ).resolves.toEqual(source) + expect(read).toHaveBeenCalledTimes(4) + }) + + it('detects an oversized file even when the first read is short', async () => { + const source = Buffer.from('abcdefg') + const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(2, length, source.length - position) + if (bytesRead > 0) { + source.copy(buffer, offset, position, position + bytesRead) + } + return { bytesRead, buffer } + }) + + await expect( + readBoundedFileHandle({ read } as unknown as FileHandle, source.length - 1) + ).rejects.toThrow('file_too_large') + }) +}) diff --git a/src/shared/node-verified-bounded-text-file.ts b/src/shared/node-verified-bounded-text-file.ts index 6fa8b45a339..d24b1bbfd34 100644 --- a/src/shared/node-verified-bounded-text-file.ts +++ b/src/shared/node-verified-bounded-text-file.ts @@ -77,11 +77,23 @@ async function assertRegularDescendantPath(rootPath: string, filePath: string): export async function readBoundedFileHandle(handle: FileHandle, maxBytes: number): Promise { const safeLimit = validatedByteLimit(maxBytes) const buffer = Buffer.alloc(safeLimit + 1) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) - if (bytesRead > safeLimit) { + let totalBytesRead = 0 + while (totalBytesRead < buffer.length) { + const { bytesRead } = await handle.read( + buffer, + totalBytesRead, + buffer.length - totalBytesRead, + totalBytesRead + ) + if (bytesRead === 0) { + break + } + totalBytesRead += bytesRead + } + if (totalBytesRead > safeLimit) { throw new Error('file_too_large') } - return buffer.subarray(0, bytesRead) + return buffer.subarray(0, totalBytesRead) } export async function openNoFollow(filePath: string): Promise {