diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 1a54a853031..74342df4f03 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -51,6 +51,10 @@ vi.mock('child_process', () => { } }) +vi.mock('../runtime/structured-agent-shell-environment', () => ({ + captureHostShellEnvironment: vi.fn(async () => undefined) +})) + vi.mock('../startup/hydrate-shell-path', () => ({ hydrateShellPath: hydrateShellPathMock, mergePathSegments: mergePathSegmentsMock @@ -89,6 +93,7 @@ import { detectInstalledAgentsWithShellPathHydration } from '../preflight/agent-detection' import { registerPreflightHandlers } from './preflight' +import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness' describe('preflight', () => { @@ -123,6 +128,70 @@ describe('preflight', () => { }) }) + it.each(['win32', 'darwin', 'linux'])( + 'skips disabled probes on automatic %s detection and refresh while Settings stays complete', + async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }) + const settings = createGlobalSettingsFixture({ disabledTuiAgents: ['kiro'] }) + registerPreflightHandlers({ getSettings: () => settings }) + isCommandOnLocalPathMock.mockImplementation(async (command: string) => + ['claude', 'kiro-cli'].includes(command) + ) + await expect( + handlers['preflight:detectAgents'](undefined, { + enabledOnly: true, + disabledTuiAgents: [] + }) + ).resolves.toEqual(['claude']) + expect(isCommandOnLocalPathMock.mock.calls.map(([command]) => command)).not.toContain( + 'kiro-cli' + ) + expect(resolveCliCommandsMock).toHaveBeenCalledWith(expect.not.arrayContaining(['kiro-cli'])) + + isCommandOnLocalPathMock.mockClear() + await expect( + handlers['preflight:refreshAgents'](undefined, { enabledOnly: true }) + ).resolves.toMatchObject({ agents: ['claude'] }) + expect(isCommandOnLocalPathMock.mock.calls.map(([command]) => command)).not.toContain( + 'kiro-cli' + ) + + await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['claude', 'kiro']) + expect(isCommandOnLocalPathMock.mock.calls.map(([command]) => command)).toContain('kiro-cli') + await expect(handlers['preflight:refreshAgents']()).resolves.toMatchObject({ + agents: ['claude', 'kiro'] + }) + + settings.disabledTuiAgents = [] + await expect( + handlers['preflight:detectAgents'](undefined, { enabledOnly: true }) + ).resolves.toEqual(['claude', 'kiro']) + } + ) + + it('filters the WSL guest command batch for automatic inventory and keeps explicit inventory complete', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const settings = createGlobalSettingsFixture({ disabledTuiAgents: ['kiro'] }) + registerPreflightHandlers({ getSettings: () => settings }) + runWslProcessMock.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: + '__ORCA_AGENT_PATH__claude\t/home/me/bin/claude\n__ORCA_AGENT_PATH__kiro-cli\t/home/me/bin/kiro-cli\n', + stderr: '' + }) + await expect( + handlers['preflight:detectAgents'](undefined, { wslDistro: 'Ubuntu', enabledOnly: true }) + ).resolves.toEqual(['claude']) + expect(runWslProcessMock.mock.lastCall?.[0]).toMatchObject({ distro: 'Ubuntu' }) + expect(runWslProcessMock.mock.lastCall?.[0].script).not.toContain("'kiro-cli'") + await expect( + handlers['preflight:detectAgents'](undefined, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual(['claude', 'kiro']) + expect(runWslProcessMock.mock.lastCall?.[0].script).toContain("'kiro-cli'") + expect(isCommandOnLocalPathMock).not.toHaveBeenCalled() + }) + it('only reports agents when which/where resolves to a real executable path', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 879e003ea89..d38229b0185 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron' +import type { Store } from '../persistence' import { detectRemoteWindowsTerminalCapabilities, runPreflightCheck @@ -12,7 +13,18 @@ import type { import { readZCodeInteractiveCapability } from '../zcode/interactive-capability' -export function registerPreflightHandlers(): void { +type AgentDetectionRequest = PreflightRuntimeContext & { enabledOnly?: boolean } + +export function registerPreflightHandlers(store?: Pick): void { + const detectionOptions = (args?: AgentDetectionRequest) => { + if (!args?.enabledOnly) { + return undefined + } + if (!store) { + throw new Error('Agent detection settings unavailable') + } + return { disabledTuiAgents: store.getSettings().disabledTuiAgents } + } ipcMain.handle( 'preflight:check', async ( @@ -23,16 +35,16 @@ export function registerPreflightHandlers(): void { } ) - ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => - detectAgentsOnHost({ kind: 'local', context: args }) + ipcMain.handle('preflight:detectAgents', async (_event, args?: AgentDetectionRequest) => + detectAgentsOnHost({ kind: 'local', context: args }, detectionOptions(args)) ) ipcMain.handle('preflight:zcodeInteractiveCapability', async () => readZCodeInteractiveCapability() ) - ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { - return refreshAgentsOnHost({ kind: 'local', context: args }) + ipcMain.handle('preflight:refreshAgents', async (_event, args?: AgentDetectionRequest) => { + return refreshAgentsOnHost({ kind: 'local', context: args }, detectionOptions(args)) }) ipcMain.handle( diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 7952e55ef37..48896979682 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -182,7 +182,7 @@ export function registerCoreHandlers( onBeforeRelaunchExit: () => closeNotificationDismissalsBeforeRelaunch(runtime) }) registerCliHandlers() - registerPreflightHandlers() + registerPreflightHandlers(store) registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 1cecbd972b1..ed3b02b3fe5 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -30,7 +30,10 @@ import { } from '../ipc/preflight-remote-windows-terminal-capabilities' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' -import { detectInstalledAgents } from './installed-agent-detection' +import { + detectInstalledAgents, + type InstalledAgentDetectionOptions +} from './installed-agent-detection' export { detectAgentCommandsOnHost } from './agent-command-detection' export { detectInstalledAgents, @@ -123,13 +126,14 @@ export type RefreshAgentsResult = { /** Refresh login-shell PATH so newly installed CLIs appear without restarting Orca. */ export async function refreshShellPathAndDetectAgents( - context?: PreflightRuntimeContext + context?: PreflightRuntimeContext, + options?: InstalledAgentDetectionOptions ): Promise { const wslTarget = getPreflightWslTarget(context) if (wslTarget) { // Discard cached guest PATH before detecting a newly installed WSL CLI. invalidateWslGuestEnvironment(wslTarget.distro) - const agents = await detectInstalledAgents(context) + const agents = await detectInstalledAgents(context, options) return { agents, addedPathSegments: [], @@ -141,7 +145,7 @@ export async function refreshShellPathAndDetectAgents( const hydration = await hydrateShellPath({ force: true }) const added = hydration.ok ? mergePathSegments(hydration.segments) : [] - const agents = await detectInstalledAgents(context) + const agents = await detectInstalledAgents(context, options) return { agents, addedPathSegments: added, diff --git a/src/main/preflight/installed-agent-detection.test.ts b/src/main/preflight/installed-agent-detection.test.ts new file mode 100644 index 00000000000..1abc471801d --- /dev/null +++ b/src/main/preflight/installed-agent-detection.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { detectCommandsMock } = vi.hoisted(() => ({ + detectCommandsMock: vi.fn(async (_commands: readonly string[]) => new Set()) +})) + +vi.mock('./agent-command-detection', () => ({ + detectAgentCommandsOnHost: detectCommandsMock +})) +vi.mock('../ipc/agent-detection-shell-path', () => ({ + hydrateShellPathForAgentDetection: vi.fn(async () => undefined) +})) +vi.mock('../ipc/preflight-runtime-target', () => ({ + getPreflightWslTarget: vi.fn(() => null) +})) + +import { detectInstalledAgents } from './installed-agent-detection' + +const originalPlatform = process.platform + +describe('detectInstalledAgents disabled-agent filtering', () => { + beforeEach(() => { + detectCommandsMock.mockReset() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + it('omits a disabled agent with a unique command from probes and results', async () => { + detectCommandsMock.mockImplementation( + async (commands) => + new Set(commands.filter((command) => command === 'codex' || command === 'gemini')) + ) + + await expect(detectInstalledAgents()).resolves.toEqual(['codex', 'gemini']) + await expect( + detectInstalledAgents(undefined, { disabledTuiAgents: ['codex'] }) + ).resolves.toEqual(['gemini']) + expect(detectCommandsMock).toHaveBeenLastCalledWith(expect.not.arrayContaining(['codex']), { + context: undefined + }) + }) + + it('keeps a shared command for an enabled agent without reporting the disabled agent', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + detectCommandsMock.mockImplementation( + async (commands) => + new Set(commands.filter((command) => command === 'claude' || command === 'orca')) + ) + + await expect( + detectInstalledAgents(undefined, { disabledTuiAgents: ['claude'] }) + ).resolves.toEqual(['claude-agent-teams']) + expect(detectCommandsMock).toHaveBeenCalledExactlyOnceWith( + expect.arrayContaining(['claude', 'orca']), + { context: undefined } + ) + + await expect( + detectInstalledAgents(undefined, { disabledTuiAgents: ['claude-agent-teams'] }) + ).resolves.toEqual(['claude']) + expect(detectCommandsMock).toHaveBeenLastCalledWith(expect.not.arrayContaining(['orca']), { + context: undefined + }) + }) +}) diff --git a/src/main/preflight/installed-agent-detection.ts b/src/main/preflight/installed-agent-detection.ts index 8df4d63aa93..f610febee1c 100644 --- a/src/main/preflight/installed-agent-detection.ts +++ b/src/main/preflight/installed-agent-detection.ts @@ -2,7 +2,8 @@ import { getTuiAgentDetectionProbeCommands, KNOWN_TUI_AGENT_DETECTION_COMMANDS, - resolveDetectedTuiAgentIds + resolveDetectedTuiAgentIds, + type TuiAgentDetectionCommand } from '../../shared/tui-agent-detection-commands' import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path' import { @@ -10,20 +11,43 @@ import { type PreflightRuntimeContext } from '../ipc/preflight-runtime-target' import { detectAgentCommandsOnHost } from './agent-command-detection' +import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' +import type { TuiAgent } from '../../shared/tui-agent' -export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { +export type InstalledAgentDetectionOptions = { + /** Agents disabled in Settings → Agents. Background callers skip probing them entirely. */ + disabledTuiAgents?: readonly TuiAgent[] +} + +function activeDetectionCommands( + options?: InstalledAgentDetectionOptions +): TuiAgentDetectionCommand[] { + const disabled = new Set(normalizeDisabledTuiAgents(options?.disabledTuiAgents)) + if (disabled.size === 0) { + return KNOWN_TUI_AGENT_DETECTION_COMMANDS + } + // A disabled agent is not a detection result even when another agent probes the same command. + return KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter((command) => !disabled.has(command.id)) +} + +export async function detectInstalledAgents( + context?: PreflightRuntimeContext, + options?: InstalledAgentDetectionOptions +): Promise { const runtime = getPreflightWslTarget(context) ? 'wsl' : process.platform - const commands = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + const commands = activeDetectionCommands(options) + const probeCommands = getTuiAgentDetectionProbeCommands(commands, runtime) return resolveDetectedTuiAgentIds( - KNOWN_TUI_AGENT_DETECTION_COMMANDS, - await detectAgentCommandsOnHost(commands, { context }), + commands, + await detectAgentCommandsOnHost(probeCommands, { context }), runtime ) } export async function detectInstalledAgentsWithShellPathHydration( - context?: PreflightRuntimeContext + context?: PreflightRuntimeContext, + options?: InstalledAgentDetectionOptions ): Promise { await hydrateShellPathForAgentDetection(context) - return detectInstalledAgents(context) + return detectInstalledAgents(context, options) } diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index 1834dc64c27..5a153a0d463 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -155,7 +155,8 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => { } }) expect(detectInstalledMock).toHaveBeenCalledWith( - wslContext('Ubuntu-24.04', 'prospective', 'project-override') + wslContext('Ubuntu-24.04', 'prospective', 'project-override'), + undefined ) }) @@ -341,7 +342,8 @@ describe('preflight.detectAgents resolves the workspace on the host', () => { params: { worktreeId: `r1::${WSL_REPO_PATH}` } }) expect(detectInstalledMock).toHaveBeenCalledWith( - wslContext('Ubuntu-24.04', 'r1', 'project-override') + wslContext('Ubuntu-24.04', 'r1', 'project-override'), + undefined ) expect(response).toMatchObject({ ok: true, result: ['claude', 'codex'] }) }) diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index e9c8355262c..9bd219b1097 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -32,6 +32,7 @@ import { refreshShellPathAndDetectAgents, type RefreshAgentsResult } from './agent-detection' +import type { InstalledAgentDetectionOptions } from './installed-agent-detection' export type AgentDetectionHost = | { kind: 'local'; context?: PreflightRuntimeContext } @@ -130,12 +131,15 @@ function resolveFolderWorkspace( : null } -export async function detectAgentsOnHost(host: AgentDetectionHost): Promise { +export async function detectAgentsOnHost( + host: AgentDetectionHost, + options?: InstalledAgentDetectionOptions +): Promise { // Unserved hosts are unverifiable; [] would falsely cache no installed agents. if (host.kind === 'ssh') { throw new SshHostNotServedError(host.connectionId) } - return detectInstalledAgentsWithShellPathHydration(host.context) + return detectInstalledAgentsWithShellPathHydration(host.context, options) } const agentRefreshListeners = new Set<() => void>() @@ -145,13 +149,18 @@ export function onAgentsRefreshedOnHost(listener: () => void): () => void { return () => agentRefreshListeners.delete(listener) } -export async function refreshAgentsOnHost(host: AgentDetectionHost): Promise { +export async function refreshAgentsOnHost( + host: AgentDetectionHost, + options?: InstalledAgentDetectionOptions +): Promise { if (host.kind === 'ssh') { throw new SshHostNotServedError(host.connectionId) } const wsl = getPreflightWslTarget(host.context) const refresh = async (): Promise => { - const result = await refreshShellPathAndDetectAgents(host.context) + const result = await (options + ? refreshShellPathAndDetectAgents(host.context, options) + : refreshShellPathAndDetectAgents(host.context)) if (!wsl) { await captureHostShellEnvironment() } diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index f5cd16dcd68..41e33aeb48a 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -70,7 +70,10 @@ describe('preflight RPC methods', () => { // An older client sends no workspace and keeps the host-default probe. expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(1, undefined, undefined) expect(resolveAgentDetectionHost).toHaveBeenNthCalledWith(2, undefined, undefined) - expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith(undefined) + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith( + undefined, + undefined + ) expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalledWith(undefined) expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ @@ -112,7 +115,10 @@ describe('preflight RPC methods', () => { makeRequest('preflight.detectAgents', { worktreeId: 'repo-ssh::/srv/w' }) ) - expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith({ projectRuntime }) + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledWith( + { projectRuntime }, + undefined + ) expect(wsl).toMatchObject({ ok: true, result: ['codex'] }) expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalledTimes(1) expect(ssh).toMatchObject({ ok: false, error: { message: SSH_HOST_NOT_SERVED_MESSAGE } }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 5231ca098b4..c988a8b53f0 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -367,6 +367,9 @@ describe('resolveWorktreeStartupDraftAgent', () => { resolve({ defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] }, 'codex') ).resolves.toBe('gemini') expect(mocks.detectInstalledAgentsWithShellPathHydration).toHaveBeenCalledTimes(1) + expect(mocks.detectInstalledAgentsWithShellPathHydration).toHaveBeenCalledWith(undefined, { + disabledTuiAgents: ['codex'] + }) }) it('starts no agent when the default is blank', async () => { @@ -378,6 +381,10 @@ describe('resolveWorktreeStartupDraftAgent', () => { mocks.detectInstalledAgentsWithShellPathHydration.mockResolvedValue(['codex', 'claude']) await expect(resolve({ disabledTuiAgents: ['codex'] })).resolves.toBe('claude') + expect(mocks.detectInstalledAgentsWithShellPathHydration).toHaveBeenCalledExactlyOnceWith( + undefined, + { disabledTuiAgents: ['codex'] } + ) }) // An unserved SSH host cannot be answered by local detection. diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 1a835b86788..50a966ad52a 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -61,8 +61,10 @@ export async function resolveWorktreeStartupDraftAgent( const sshConnectionId = getRepoSshConnectionId(repo) try { // Sparse launch environments may lack host detection dependencies. + // Disabled agents cannot be picked here, so background detection never probes them (#34635). detected = await detectAgentsOnHost( - sshConnectionId ? { kind: 'ssh', connectionId: sshConnectionId } : { kind: 'local' } + sshConnectionId ? { kind: 'ssh', connectionId: sshConnectionId } : { kind: 'local' }, + { disabledTuiAgents: settings.disabledTuiAgents } ) } catch { detected = [] diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts index 2d1dbfc57a1..8d5c6460059 100644 --- a/src/preload/api/preflight-api.ts +++ b/src/preload/api/preflight-api.ts @@ -44,10 +44,13 @@ export type PreflightRuntimeContext = { export type PreflightApi = { check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise - detectAgents: (args?: PreflightRuntimeContext) => Promise + /** Complete by default; background callers opt into host-owned enabled preferences. */ + detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }) => Promise /** Cached in main for the process lifetime. */ zcodeInteractiveCapability: () => Promise - refreshAgents: (args?: PreflightRuntimeContext) => Promise + refreshAgents: ( + args?: PreflightRuntimeContext & { enabledOnly?: boolean } + ) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ wslAvailable: boolean diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index 843eec93331..ab4e5c3f76f 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -26,12 +26,13 @@ export const preflightApi = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: PreflightRuntimeContext): Promise => + detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }): Promise => ipcRenderer.invoke('preflight:detectAgents', args), zcodeInteractiveCapability: (): Promise => ipcRenderer.invoke('preflight:zcodeInteractiveCapability'), - refreshAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), + refreshAgents: ( + args?: PreflightRuntimeContext & { enabledOnly?: boolean } + ): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args), detectRemoteWindowsTerminalCapabilities: (args: { diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index 68b578610ba..a6383d734a4 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -87,7 +87,7 @@ export function AgentLaunchSettingsCatalog({ detectionFailed, isRefreshing, refresh - } = useDetectedAgents(detectionTarget) + } = useDetectedAgents(detectionTarget, 'all') const detectedIds = useMemo(() => (detected ? new Set(detected) : null), [detected]) useHostStructuredAgentsVersion() const installedHere = useAppStore(readNativeDetectedAgents) diff --git a/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx b/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx index 1eb266a675c..14d2d2f7d52 100644 --- a/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.local-invalidation.test.tsx @@ -4,7 +4,19 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { useNewTabAgentMenuRows } from '@/components/tab-bar/use-new-tab-agent-menu-rows' +import { + useSourceControlActionAgent, + useSourceControlActionAgentInventory +} from '@/components/settings/use-source-control-action-agent' +import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' +import { useHostStructuredAgentsSync } from '@/runtime/host-structured-agents-sync' +import { + readHostStructuredAgentsForRuntime, + resetHostStructuredAgentsForTests +} from '@/runtime/host-structured-agents' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { Project } from '../../../shared/project-types' import { useDetectedAgents, type AgentDetectionTarget } from './useDetectedAgents' @@ -28,6 +40,8 @@ beforeEach(() => { afterEach(() => { cleanup() + resetHostStructuredAgentsForTests() + setLocalRuntimeCapabilitiesForTests(null) useAppStore.getState().clearLocalDetectedAgents() useAppStore.setState(initialState, true) }) @@ -122,3 +136,97 @@ it('retargets a mounted first load when the project switches to WSL', async () = }) expect(result.current.detection.detectedIds).toEqual(['codex']) }) + +it('recovers mounted native consumers after availability changes alongside WSL launch and Settings inventories', async () => { + const project: Project = { + id: TEST_REPO.id, + displayName: TEST_REPO.displayName, + badgeColor: TEST_REPO.badgeColor, + sourceRepoIds: [TEST_REPO.id], + createdAt: 0, + updatedAt: 0, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + const settings = { + ...getDefaultSettings('/home/test'), + experimentalNativeChat: true, + localWindowsRuntimeDefault: { kind: 'windows-host' } as const + } + useAppStore.setState({ + activeRepoId: TEST_REPO.id, + repos: [{ ...TEST_REPO, path: 'C:\\project' }], + projects: [project], + settings + }) + setLocalRuntimeCapabilitiesForTests([ + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ]) + Object.assign(window.api, { + runtime: { + call: vi.fn(async () => ({ + id: 'registered-agents', + ok: true, + result: { agents: [{ agent: 'pi', capabilities: {} }] } + })) + } + }) + let nativeAgents = ['pi', 'codex'] + detectAgents.mockImplementation((context) => + Promise.resolve( + context?.projectRuntime?.runtime.kind === 'wsl' + ? context.enabledOnly + ? ['claude'] + : ['claude', 'kiro'] + : nativeAgents + ) + ) + const { result } = renderHook(() => { + useHostStructuredAgentsSync() + useSourceControlActionAgentInventory() + return { + native: useSourceControlActionAgent('pi', 'codex'), + workspace: useDetectedAgents({ kind: 'local' }), + settings: useDetectedAgents({ kind: 'local' }, 'all') + } + }) + await act(async () => {}) + expect(result.current.native).toMatchObject({ status: 'ready', agent: 'pi' }) + expect(readHostStructuredAgentsForRuntime('local', null)?.map((row) => row.agent)).toEqual(['pi']) + + nativeAgents = ['codex'] + await act(async () => { + useAppStore.setState({ settings: { ...settings, disabledTuiAgents: ['pi', 'kiro'] } }) + }) + expect(readNativeDetectedAgents(useAppStore.getState())).toEqual(['codex']) + expect(result.current.native).toMatchObject({ status: 'ready', agent: 'codex' }) + expect(result.current.workspace).toMatchObject({ detectedIds: ['claude'], isLoading: false }) + expect(result.current.settings).toMatchObject({ + detectedIds: ['claude', 'kiro'], + isLoading: false + }) + const probesAfterDisable = detectAgents.mock.calls.length + await act(async () => { + useAppStore.setState({ settings: { ...settings, disabledTuiAgents: ['kiro', 'pi'] } }) + }) + expect(detectAgents).toHaveBeenCalledTimes(probesAfterDisable) + + nativeAgents = ['pi', 'codex'] + await act(async () => { + useAppStore.setState({ settings: { ...settings, disabledTuiAgents: ['kiro'] } }) + }) + expect(readNativeDetectedAgents(useAppStore.getState())).toEqual(['pi', 'codex']) + expect(result.current.native).toMatchObject({ status: 'ready', agent: 'pi' }) + expect(result.current.workspace).toMatchObject({ detectedIds: ['claude'], isLoading: false }) + expect(result.current.settings).toMatchObject({ + detectedIds: ['claude', 'kiro'], + isLoading: false + }) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + expect(useAppStore.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(useAppStore.getState().isRefreshingLocalAgentsByContext).toEqual({}) + expect(detectAgents.mock.calls.filter(([context]) => !context?.projectRuntime)).toEqual([ + [{ enabledOnly: true }], + [{ enabledOnly: true }], + [{ enabledOnly: true }] + ]) +}) diff --git a/src/renderer/src/hooks/useDetectedAgents.test.tsx b/src/renderer/src/hooks/useDetectedAgents.test.tsx index 09d12e25f26..593d6054a2a 100644 --- a/src/renderer/src/hooks/useDetectedAgents.test.tsx +++ b/src/renderer/src/hooks/useDetectedAgents.test.tsx @@ -28,12 +28,14 @@ let latestHookResult: UseDetectedAgentsResult | null = null function HookProbe({ target, - onResult + onResult, + inventory }: { target: AgentDetectionTarget | undefined onResult?: (result: UseDetectedAgentsResult) => void + inventory?: 'enabled' | 'all' }): null { - latestHookResult = useDetectedAgents(target) + latestHookResult = useDetectedAgents(target, inventory) onResult?.(latestHookResult) return null } @@ -109,6 +111,35 @@ beforeEach(() => { } as unknown as Window['api'] }) +it('loads complete Settings inventory even when the automatic inventory is already cached', async () => { + useAppStore.getState().clearLocalDetectedAgents() + detectLocalAgents.mockImplementation((args) => + Promise.resolve(args?.enabledOnly ? ['claude'] : ['claude', 'kiro']) + ) + await useAppStore.getState().ensureDetectedAgents() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { target: { kind: 'local' }, inventory: 'all' })) + }) + await flushEffects() + expect(latestHookResult?.detectedIds).toEqual(['claude', 'kiro']) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) + expect(detectLocalAgents.mock.calls.map(([args]) => args?.enabledOnly === true)).toEqual([ + true, + false + ]) + refreshLocalAgents.mockResolvedValueOnce({ agents: ['claude', 'codex', 'kiro'] }) + await act(async () => { + await latestHookResult?.refresh() + }) + expect(latestHookResult?.detectedIds).toEqual(['claude', 'codex', 'kiro']) + expect(refreshLocalAgents.mock.lastCall?.[0]?.enabledOnly).not.toBe(true) + expect(useAppStore.getState().detectedAgentIds).toEqual(['claude', 'codex', 'kiro']) +}) + describe('Floating Workspace authority', () => { it('advertises native Windows agents beside an active WSL project', async () => { const activeResult: { current: UseDetectedAgentsResult | null } = { current: null } @@ -171,7 +202,7 @@ describe('Floating Workspace authority', () => { runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) }) }), - undefined + { enabledOnly: true } ]) refreshLocalAgents.mockResolvedValueOnce({ @@ -189,7 +220,7 @@ describe('Floating Workspace authority', () => { expect(activeResult.current?.detectedIds).toEqual(['claude']) expect(floatingResult.current?.detectedIds).toEqual(['codex']) expect(useAppStore.getState().detectedAgentIds).toEqual(['claude']) - expect(refreshLocalAgents).toHaveBeenLastCalledWith(undefined) + expect(refreshLocalAgents).toHaveBeenLastCalledWith({ enabledOnly: true }) }) }) diff --git a/src/renderer/src/hooks/useDetectedAgents.ts b/src/renderer/src/hooks/useDetectedAgents.ts index b063f9d5dc8..ce8e1febda3 100644 --- a/src/renderer/src/hooks/useDetectedAgents.ts +++ b/src/renderer/src/hooks/useDetectedAgents.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' +import { getLocalAgentPreflightContext } from '@/lib/local-preflight-context' +import { localAgentInventoryContextKey } from '@/lib/local-preflight-context-key' import { runtimeHostContactForEntry } from '../../../shared/runtime-host-contact' import type { TuiAgent } from '../../../shared/tui-agent' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' @@ -38,9 +40,10 @@ function normalizeAgentDetectionTarget( return target } -/** One detected-agent inventory keeps launch surfaces synchronized. Accept legacy SSH IDs or host targets; null means local, undefined means unresolved/loading. */ +/** Local Settings requests a complete inventory; launch surfaces use enabled agents. Remote inventories remain complete. */ export function useDetectedAgents( - connectionId: AgentDetectionTarget | string | null | undefined + connectionId: AgentDetectionTarget | string | null | undefined, + inventory: 'enabled' | 'all' = 'enabled' ): UseDetectedAgentsResult { const target = normalizeAgentDetectionTarget(connectionId) const observedRemoteTargetKeysRef = useRef>(new Set()) @@ -60,7 +63,17 @@ export function useDetectedAgents( targetKind === 'runtime' && targetId ? getRuntimeAgentInventoryKey(targetId, runtimeWorktreeId) : targetId - const localContextKey = target?.kind === 'local' ? target.contextKey : undefined + const includeDisabled = inventory === 'all' + const completeLocalContextKey = useAppStore((s) => + targetKind === 'local' && includeDisabled + ? localAgentInventoryContextKey( + getLocalAgentPreflightContext(s, undefined, undefined, localWorktreeId), + true + ) + : undefined + ) + const localContextKey = + completeLocalContextKey ?? (target?.kind === 'local' ? target.contextKey : undefined) const remoteTargetKey = targetKind === 'ssh' && targetId ? `ssh:${targetId}` @@ -166,8 +179,10 @@ export function useDetectedAgents( if (targetKind === 'ssh' && targetId) { return state.refreshRemoteDetectedAgents(targetId) } - return state.refreshDetectedAgents(localWorktreeId) - }, [isUnknown, localWorktreeId, runtimeWorktreeId, targetKind, targetId]) + return includeDisabled + ? state.refreshAllDetectedAgents(localWorktreeId) + : state.refreshDetectedAgents(localWorktreeId) + }, [includeDisabled, isUnknown, localWorktreeId, runtimeWorktreeId, targetKind, targetId]) useEffect(() => { if (isUnknown) { @@ -212,10 +227,13 @@ export function useDetectedAgents( } } else { if (detectedIds === null && !localProbePending) { - void state.ensureDetectedAgents(localWorktreeId) + void (includeDisabled + ? state.ensureAllDetectedAgents(localWorktreeId) + : state.ensureDetectedAgents(localWorktreeId)) } } }, [ + includeDisabled, isUnknown, targetKind, targetId, diff --git a/src/renderer/src/lib/local-preflight-context-key.ts b/src/renderer/src/lib/local-preflight-context-key.ts index 45ba4987717..c8d70934905 100644 --- a/src/renderer/src/lib/local-preflight-context-key.ts +++ b/src/renderer/src/lib/local-preflight-context-key.ts @@ -25,9 +25,13 @@ export function localPreflightContextKey(context: LocalPreflightContextKeyInput) } /** Native agent detection uses process PATH, independent of the project's directory. */ -export function localAgentInventoryContextKey(context: LocalPreflightContextKeyInput): string { - return context?.projectRuntime?.status === 'resolved' && - context.projectRuntime.runtime.kind !== 'wsl' - ? 'host' - : localPreflightContextKey(context) +export function localAgentInventoryContextKey( + context: LocalPreflightContextKeyInput, + includeDisabled = false +): string { + const key = + context?.projectRuntime?.status === 'resolved' && context.projectRuntime.runtime.kind !== 'wsl' + ? 'host' + : localPreflightContextKey(context) + return includeDisabled ? `${key}\u0000all-agents` : key } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index b30320e7a3f..f832f66f65d 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -126,6 +126,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, wslDistro: 'Ubuntu', projectRuntime: { status: 'resolved', @@ -204,6 +205,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) expect(refreshAgents).toHaveBeenCalledWith({ + enabledOnly: true, wslDistro: 'Debian', projectRuntime: { status: 'resolved', @@ -245,6 +247,7 @@ describe('createDetectedAgentsSlice WSL context', () => { expect(store.getState().detectedAgentIds).toEqual([]) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, projectRuntime: { status: 'repair-required', repair: { @@ -272,6 +275,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, wslDistro: 'Debian', projectRuntime: { status: 'resolved', @@ -302,6 +306,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, projectRuntime: { status: 'resolved', runtime: { @@ -330,6 +335,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, wslDistro: 'Fedora', projectRuntime: { status: 'resolved', @@ -357,6 +363,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, wslDistro: 'Ubuntu', projectRuntime: { status: 'resolved', @@ -399,6 +406,7 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) expect(detectAgents).toHaveBeenCalledWith({ + enabledOnly: true, projectRuntime: { status: 'resolved', runtime: { diff --git a/src/renderer/src/store/slices/local-agent-inventory-refresh.ts b/src/renderer/src/store/slices/local-agent-inventory-refresh.ts new file mode 100644 index 00000000000..9f0f27d7097 --- /dev/null +++ b/src/renderer/src/store/slices/local-agent-inventory-refresh.ts @@ -0,0 +1,191 @@ +import type { StoreApi } from 'zustand' +import type { AppState } from '../types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { getLocalAgentPreflightContext } from '@/lib/local-preflight-context' +import { localAgentInventoryContextKey } from '@/lib/local-preflight-context-key' +import * as contextEviction from './local-agent-context-eviction' +import { getLegacyLoadingPatch, getSupersededDetectPatch } from './local-agent-legacy-loading' + +export type LocalAgentInventoryLifecycle = { + detectPromises: Map> + refreshPromises: Map> + keptThroughFailure: WeakSet + failedDetectContextKeys: Set + detectedContextKey: string | null + legacyDetectContextKey: string | null + legacyRefreshContextKey: string | null + localDetectionGeneration: number +} + +export function refreshLocalAgentInventory( + set: StoreApi['setState'], + get: StoreApi['getState'], + lifecycle: LocalAgentInventoryLifecycle, + worktreeId?: string | null, + includeDisabled = false +): Promise { + const isolatedInventory = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) + const contextKey = localAgentInventoryContextKey(context, includeDisabled) + const publishedContextKey = localAgentInventoryContextKey(context) + const refreshContextKeys = new Set([contextKey, publishedContextKey]) + const cached = get().localDetectedAgentIdsByContext[contextKey] + const hadUsableCache = cached != null && !lifecycle.failedDetectContextKeys.has(contextKey) + const publishedHadUsableCache = + get().localDetectedAgentIdsByContext[publishedContextKey] != null && + !lifecycle.failedDetectContextKeys.has(publishedContextKey) + const requestGeneration = lifecycle.localDetectionGeneration + const exposeInflightToLegacy = (): void => { + if (!isolatedInventory) { + lifecycle.legacyRefreshContextKey = contextKey + } + if (isolatedInventory) { + return + } + const state = get() + const patch = getLegacyLoadingPatch( + state, + lifecycle.detectedContextKey === publishedContextKey, + 'refresh' + ) + if (patch) { + set(patch) + } + } + const inflight = lifecycle.refreshPromises.get(contextKey) + if (inflight) { + exposeInflightToLegacy() + return inflight + } + if (!isolatedInventory) { + lifecycle.legacyRefreshContextKey = contextKey + } + lifecycle.detectPromises.delete(contextKey) + if (includeDisabled) { + lifecycle.detectPromises.delete(publishedContextKey) + lifecycle.refreshPromises.delete(publishedContextKey) + } + const clearsLegacyDetect = + lifecycle.legacyDetectContextKey !== null && + refreshContextKeys.has(lifecycle.legacyDetectContextKey) + if (clearsLegacyDetect) { + lifecycle.legacyDetectContextKey = null + } + set((state) => ({ + ...(isolatedInventory + ? {} + : (getLegacyLoadingPatch( + state, + lifecycle.detectedContextKey === publishedContextKey, + 'refresh' + ) ?? {})), + ...getSupersededDetectPatch(state, refreshContextKeys, clearsLegacyDetect), + isRefreshingLocalAgentsByContext: { + ...state.isRefreshingLocalAgentsByContext, + [contextKey]: true, + [publishedContextKey]: true + } + })) + const pending = window.api.preflight + .refreshAgents(includeDisabled ? context : { ...context, enabledOnly: true }) + .then((result) => { + const typed = result.agents.filter(isTuiAgent) + const published = includeDisabled + ? filterEnabledTuiAgents(typed, get().settings?.disabledTuiAgents) + : typed + if ( + requestGeneration === lifecycle.localDetectionGeneration && + lifecycle.refreshPromises.get(contextKey) === pending + ) { + lifecycle.failedDetectContextKeys.delete(contextKey) + lifecycle.failedDetectContextKeys.delete(publishedContextKey) + const exposeToLegacy = + lifecycle.legacyRefreshContextKey !== null && + refreshContextKeys.has(lifecycle.legacyRefreshContextKey) + if (exposeToLegacy) { + lifecycle.legacyRefreshContextKey = null + lifecycle.detectedContextKey = publishedContextKey + } + set((state) => ({ + ...(exposeToLegacy + ? { + detectedAgentIds: published, + isRefreshingAgents: false, + pathSource: result.pathSource, + pathFailureReason: result.pathFailureReason + } + : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: typed, + [publishedContextKey]: published + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntries( + state.isRefreshingLocalAgentsByContext, + refreshContextKeys + ) + })) + } + return typed + }) + .catch(() => { + const fallback = get().localDetectedAgentIdsByContext[contextKey] ?? [] + if ( + requestGeneration === lifecycle.localDetectionGeneration && + lifecycle.refreshPromises.get(contextKey) === pending + ) { + lifecycle.keptThroughFailure.add(fallback) + const publishedFallback = get().localDetectedAgentIdsByContext[publishedContextKey] ?? [] + lifecycle.keptThroughFailure.add(publishedFallback) + if (!publishedHadUsableCache) { + lifecycle.failedDetectContextKeys.add(publishedContextKey) + } + if (!hadUsableCache) { + lifecycle.failedDetectContextKeys.add(contextKey) + } + const exposeToLegacy = + lifecycle.legacyRefreshContextKey !== null && + refreshContextKeys.has(lifecycle.legacyRefreshContextKey) + if (exposeToLegacy) { + lifecycle.legacyRefreshContextKey = null + lifecycle.detectedContextKey = publishedContextKey + } + set((state) => ({ + ...(exposeToLegacy + ? { detectedAgentIds: publishedFallback, isRefreshingAgents: false } + : {}), + localDetectedAgentIdsByContext: { + ...state.localDetectedAgentIdsByContext, + [contextKey]: fallback, + [publishedContextKey]: publishedFallback + }, + isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntries( + state.isRefreshingLocalAgentsByContext, + refreshContextKeys + ) + })) + } + return fallback + }) + .finally(() => { + if (lifecycle.refreshPromises.get(contextKey) === pending) { + lifecycle.refreshPromises.delete(contextKey) + } + }) + const publishedPending = includeDisabled + ? pending.then(() => get().localDetectedAgentIdsByContext[publishedContextKey] ?? []) + : pending + if (includeDisabled) { + lifecycle.refreshPromises.set(publishedContextKey, publishedPending) + void publishedPending.finally(() => { + if (lifecycle.refreshPromises.get(publishedContextKey) === publishedPending) { + lifecycle.refreshPromises.delete(publishedContextKey) + } + }) + } + lifecycle.refreshPromises.set(contextKey, pending) + return pending +} diff --git a/src/renderer/src/store/slices/local-agent-legacy-loading.ts b/src/renderer/src/store/slices/local-agent-legacy-loading.ts index 18e99768003..1aaccb3a14e 100644 --- a/src/renderer/src/store/slices/local-agent-legacy-loading.ts +++ b/src/renderer/src/store/slices/local-agent-legacy-loading.ts @@ -1,5 +1,5 @@ import type { AppState } from '../types' -import { removeLocalAgentContextEntry } from './local-agent-context-eviction' +import { removeLocalAgentContextEntries } from './local-agent-context-eviction' type LocalAgentLegacyLoadingState = Pick< AppState, @@ -25,19 +25,14 @@ export function getLegacyLoadingPatch( export function getSupersededDetectPatch( state: LocalAgentLegacyLoadingState & Pick, - contextKey: string, - supersedesDetect: boolean, + contextKeys: ReadonlySet, clearsLegacyDetect: boolean ): Partial { return { ...(clearsLegacyDetect ? { isDetectingAgents: false } : {}), - ...(supersedesDetect - ? { - isDetectingLocalAgentsByContext: removeLocalAgentContextEntry( - state.isDetectingLocalAgentsByContext, - contextKey - ) - } - : {}) + isDetectingLocalAgentsByContext: removeLocalAgentContextEntries( + state.isDetectingLocalAgentsByContext, + contextKeys + ) } } diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts index 8a2121e4ab7..d7b387388ff 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.test.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -62,6 +62,223 @@ describe('local detected agent context lifecycle', () => { }) }) + it('keeps complete Settings inventory separate from automatic probes in either completion order', async () => { + const store = createTestStore([]) + const resolveDetection = new Map void>() + detectAgents.mockImplementation( + (args) => + new Promise((resolve) => { + resolveDetection.set(args?.enabledOnly === true, resolve) + }) + ) + const automatic = store.getState().ensureDetectedAgents() + const settings = store.getState().ensureAllDetectedAgents() + expect(store.getState().ensureAllDetectedAgents()).toBe(settings) + resolveDetection.get(true)?.(['claude']) + await automatic + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toBeNull() + resolveDetection.get(false)?.(['claude', 'kiro']) + await settings + expect(store.getState().detectedAgentIds).toEqual(['claude']) + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([ + 'claude', + 'kiro' + ]) + await expect(store.getState().ensureAllDetectedAgents()).resolves.toEqual(['claude', 'kiro']) + expect(detectAgents).toHaveBeenCalledTimes(2) + + store.getState().clearLocalDetectedAgents() + const nextAutomatic = store.getState().ensureDetectedAgents() + const nextSettings = store.getState().ensureAllDetectedAgents() + resolveDetection.get(false)?.(['codex', 'kiro']) + await nextSettings + resolveDetection.get(true)?.(['codex']) + await nextAutomatic + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([ + 'codex', + 'kiro' + ]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + }) + + it('re-detects re-enabled agents and prevents pre-change requests from restoring caches', async () => { + const store = createTestStore([]) + let finishOld: (ids: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishOld = resolve + }) + ) + const stale = store.getState().ensureDetectedAgents() + const settings = store.getState().settings! + store.setState({ settings: { ...settings, disabledTuiAgents: ['kiro'] } }) + detectAgents.mockResolvedValueOnce(['claude']) + await store.getState().ensureDetectedAgents() + finishOld(['kiro']) + await stale + expect(store.getState().detectedAgentIds).toEqual(['claude']) + store.setState({ settings: { ...settings, disabledTuiAgents: [] } }) + expect(store.getState().detectedAgentIds).toBeNull() + detectAgents.mockResolvedValueOnce(['claude', 'kiro']) + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude', 'kiro']) + expect(detectAgents).toHaveBeenCalledTimes(3) + }) + + it('separates automatic refresh from a complete Settings refresh and fences late detects', async () => { + const store = createTestStore([]) + let finishOld: (ids: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishOld = resolve + }) + ) + const stale = store.getState().ensureAllDetectedAgents() + refreshAgents.mockResolvedValueOnce({ agents: ['claude', 'kiro'] }) + await expect(store.getState().refreshAllDetectedAgents()).resolves.toEqual(['claude', 'kiro']) + expect(refreshAgents.mock.lastCall?.[0]?.enabledOnly).not.toBe(true) + finishOld(['claude']) + await stale + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([ + 'claude', + 'kiro' + ]) + expect(store.getState().detectedAgentIds).toEqual(['claude', 'kiro']) + refreshAgents.mockResolvedValueOnce({ agents: ['claude'] }) + await store.getState().refreshDetectedAgents() + expect(refreshAgents).toHaveBeenLastCalledWith(expect.objectContaining({ enabledOnly: true })) + expect(store.getState().detectedAgentIds).toEqual(['claude']) + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([ + 'claude', + 'kiro' + ]) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + }) + + it.each([ + { refreshFails: false, detectFinishesFirst: false }, + { refreshFails: false, detectFinishesFirst: true }, + { refreshFails: true, detectFinishesFirst: false }, + { refreshFails: true, detectFinishesFirst: true } + ])( + 'settles both loading modes when a full refresh supersedes detects ($refreshFails, $detectFinishesFirst)', + async ({ refreshFails, detectFinishesFirst }) => { + const store = createTestStore([]) + const enabledDetection = Promise.withResolvers() + const fullDetection = Promise.withResolvers() + const fullRefresh = Promise.withResolvers<{ agents: string[] }>() + detectAgents + .mockReturnValueOnce(enabledDetection.promise) + .mockReturnValueOnce(fullDetection.promise) + refreshAgents.mockReturnValueOnce(fullRefresh.promise) + const enabled = store.getState().ensureDetectedAgents() + const full = store.getState().ensureAllDetectedAgents() + const refreshed = store.getState().refreshAllDetectedAgents() + + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({ + host: true, + 'host\u0000all-agents': true + }) + const finishDetections = async () => { + enabledDetection.resolve(['claude']) + fullDetection.resolve(['claude', 'kiro']) + await Promise.all([enabled, full]) + } + if (detectFinishesFirst) { + await finishDetections() + expect(store.getState().isRefreshingAgents).toBe(true) + } + if (refreshFails) { + fullRefresh.reject(new Error('shell unavailable')) + } else { + fullRefresh.resolve({ agents: ['codex', 'kiro'] }) + } + await refreshed + if (!detectFinishesFirst) { + await finishDetections() + } + + expect(store.getState().localDetectedAgentIdsByContext).toEqual({ + host: refreshFails ? [] : ['codex', 'kiro'], + 'host\u0000all-agents': refreshFails ? [] : ['codex', 'kiro'] + }) + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isRefreshingAgents).toBe(false) + } + ) + + it('publishes a complete refresh to automatic readers without letting their late probe overwrite it', async () => { + const store = createTestStore([]) + store.setState({ settings: { ...store.getState().settings!, disabledTuiAgents: ['kiro'] } }) + let finishOld: (ids: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishOld = resolve + }) + ) + const old = store.getState().ensureDetectedAgents() + let finishRefresh: (result: { agents: string[] }) => void = () => {} + refreshAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishRefresh = resolve + }) + ) + const settings = store.getState().refreshAllDetectedAgents() + const automatic = store.getState().ensureDetectedAgents() + expect(store.getState().refreshDetectedAgents()).toBe(automatic) + finishOld(['claude']) + await old + finishRefresh({ agents: ['codex', 'kiro'] }) + await expect(settings).resolves.toEqual(['codex', 'kiro']) + await expect(automatic).resolves.toEqual(['codex']) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([ + 'codex', + 'kiro' + ]) + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + expect(refreshAgents).toHaveBeenCalledTimes(1) + }) + + it('preserves both inventories on full refresh failure without treating a partial list as complete', async () => { + const store = createTestStore([]) + detectAgents.mockResolvedValueOnce(['claude']) + await store.getState().ensureDetectedAgents() + refreshAgents.mockRejectedValueOnce(new Error('shell unavailable')) + await expect(store.getState().refreshAllDetectedAgents()).resolves.toEqual([]) + expect(store.getState().detectedAgentIds).toEqual(['claude']) + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toEqual([]) + expect(store.getState().readLocalAgentInventory().state).toBe('failed') + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + detectAgents.mockResolvedValueOnce(['claude', 'kiro']) + await expect(store.getState().ensureAllDetectedAgents()).resolves.toEqual(['claude', 'kiro']) + expect(store.getState().localDetectedAgentIdsByContext.host).toEqual(['claude']) + }) + + it('detaches both modes of a full refresh when disabled preferences change', async () => { + const store = createTestStore([]) + let finishOld: (result: { agents: string[] }) => void = () => {} + refreshAgents.mockReturnValueOnce( + new Promise((resolve) => { + finishOld = resolve + }) + ) + const oldSettings = store.getState().refreshAllDetectedAgents() + const oldAutomatic = store.getState().ensureDetectedAgents() + store.setState({ settings: { ...store.getState().settings!, disabledTuiAgents: ['kiro'] } }) + detectAgents.mockResolvedValueOnce(['codex']) + await store.getState().ensureDetectedAgents() + finishOld({ agents: ['claude', 'kiro'] }) + await Promise.all([oldSettings, oldAutomatic]) + expect(store.getState().detectedAgentIds).toEqual(['codex']) + expect(store.getState().localDetectedAgentIdsByContext['host\u0000all-agents']).toBeUndefined() + expect(store.getState().isRefreshingLocalAgentsByContext).toEqual({}) + }) + it('deduplicates Floating-first callers without cached or settlement broadcast fanout', async () => { let resolveDetection: (agents: string[]) => void = () => {} detectAgents.mockReturnValueOnce( @@ -99,7 +316,7 @@ describe('local detected agent context lifecycle', () => { const projects = store.getState().projects detectAgents.mockResolvedValueOnce(['grok']).mockResolvedValueOnce([]) await store.getState().ensureNativeDetectedAgents() - expect(detectAgents).toHaveBeenNthCalledWith(1, undefined) + expect(detectAgents).toHaveBeenNthCalledWith(1, { enabledOnly: true }) await store.getState().ensureDetectedAgents() await store.getState().refreshDetectedAgents() expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) @@ -134,6 +351,37 @@ describe('local detected agent context lifecycle', () => { expect(readNativeDetectedAgents(store.getState())).toEqual(['codex']) }) + it('reacquires requested native inventory across preference changes and fences retired native probes', async () => { + const store = createTestStore([makeRepo('repo-1')]) + const firstDetection = Promise.withResolvers() + const disabledDetection = Promise.withResolvers() + const reenabledDetection = Promise.withResolvers() + detectAgents + .mockReturnValueOnce(firstDetection.promise) + .mockReturnValueOnce(disabledDetection.promise) + .mockReturnValueOnce(reenabledDetection.promise) + const first = store.getState().ensureNativeDetectedAgents() + const settings = store.getState().settings! + + store.setState({ settings: { ...settings, disabledTuiAgents: ['pi'] } }) + store.setState({ settings: { ...settings, disabledTuiAgents: [] } }) + expect(detectAgents.mock.calls).toEqual([ + [{ enabledOnly: true }], + [{ enabledOnly: true }], + [{ enabledOnly: true }] + ]) + reenabledDetection.resolve(['pi', 'codex']) + await vi.waitFor(() => + expect(readNativeDetectedAgents(store.getState())).toEqual(['pi', 'codex']) + ) + disabledDetection.resolve(['codex']) + firstDetection.resolve(['claude']) + await first + expect(readNativeDetectedAgents(store.getState())).toEqual(['pi', 'codex']) + expect(store.getState().detectedAgentIds).toBeNull() + expect(store.getState().isDetectingLocalAgentsByContext).toEqual({}) + }) + it('evicts removed project contexts without retaining settled loading entries', async () => { const repo1 = makeRepo('repo-1') const repo2 = makeRepo('repo-2') @@ -263,8 +511,8 @@ describe('local detected agent context lifecycle', () => { await expect( store.getState().refreshDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) ).resolves.toEqual([]) - resolveDetection(['stale']) - await expect(supersededDetect).resolves.toEqual(['stale']) + resolveDetection(['grok']) + await expect(supersededDetect).resolves.toEqual(['grok']) await expect( store.getState().ensureDetectedAgents(FLOATING_TERMINAL_WORKTREE_ID) diff --git a/src/renderer/src/store/slices/local-detected-agent-state.ts b/src/renderer/src/store/slices/local-detected-agent-state.ts index 7f95f0f7746..ccd5abe8392 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.ts @@ -1,51 +1,74 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' +import { haveSameDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../shared/tui-agent-config' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getLocalAgentPreflightContext } from '@/lib/local-preflight-context' import { localAgentInventoryContextKey } from '@/lib/local-preflight-context-key' import * as contextEviction from './local-agent-context-eviction' -import { getLegacyLoadingPatch, getSupersededDetectPatch } from './local-agent-legacy-loading' +import { getLegacyLoadingPatch } from './local-agent-legacy-loading' +import { + refreshLocalAgentInventory, + type LocalAgentInventoryLifecycle +} from './local-agent-inventory-refresh' import { createEmptyLocalDetectedAgentState } from './local-detected-agent-store-state' import { readLocalAgentInventoryOutcome } from './local-agent-inventory-outcome' import type { LocalDetectedAgentState } from './local-detected-agent-store-state' type LocalDetectedAgentStateCreator = StateCreator -export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get) => { - const detectPromises = new Map>() - const refreshPromises = new Map>() - const keptThroughFailure = new WeakSet() - const failedDetectContextKeys = new Set() - let detectedContextKey: string | null = null - let legacyDetectContextKey: string | null = null - let legacyRefreshContextKey: string | null = null - let localDetectionGeneration = 0 +export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get, store) => { + let nativeInventoryRequested = false + const lifecycle: LocalAgentInventoryLifecycle = { + detectPromises: new Map(), + refreshPromises: new Map(), + keptThroughFailure: new WeakSet(), + failedDetectContextKeys: new Set(), + detectedContextKey: null, + legacyDetectContextKey: null, + legacyRefreshContextKey: null, + localDetectionGeneration: 0 + } const actions = { ...createEmptyLocalDetectedAgentState(), readLocalAgentInventory: (id?: string | null) => - readLocalAgentInventoryOutcome(get(), id, failedDetectContextKeys, keptThroughFailure), - ensureNativeDetectedAgents: (): Promise => - actions.ensureDetectedAgents(undefined, true), - ensureDetectedAgents: (worktreeId?: string | null, nativeHost = false): Promise => { - const isFloating = nativeHost || worktreeId === FLOATING_TERMINAL_WORKTREE_ID + readLocalAgentInventoryOutcome( + get(), + id, + lifecycle.failedDetectContextKeys, + lifecycle.keptThroughFailure + ), + ensureNativeDetectedAgents: (): Promise => { + nativeInventoryRequested = true + return actions.ensureDetectedAgents(undefined, true) + }, + ensureAllDetectedAgents: (worktreeId?: string | null): Promise => + actions.ensureDetectedAgents(worktreeId, false, true), + ensureDetectedAgents: ( + worktreeId?: string | null, + nativeHost = false, + includeDisabled = false + ): Promise => { + const isolatedInventory = + includeDisabled || nativeHost || worktreeId === FLOATING_TERMINAL_WORKTREE_ID const context = nativeHost ? undefined : getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) - const contextKey = localAgentInventoryContextKey(context) + const contextKey = localAgentInventoryContextKey(context, includeDisabled) const existing = get().localDetectedAgentIdsByContext[contextKey] - const inflightRefresh = refreshPromises.get(contextKey) + const inflightRefresh = lifecycle.refreshPromises.get(contextKey) if (inflightRefresh) { - if (!isFloating) { - legacyRefreshContextKey = contextKey + if (!isolatedInventory) { + lifecycle.legacyRefreshContextKey = contextKey } return inflightRefresh } - if (existing != null && !failedDetectContextKeys.has(contextKey)) { - if (!isFloating) { - detectedContextKey = contextKey + if (existing != null && !lifecycle.failedDetectContextKeys.has(contextKey)) { + if (!isolatedInventory) { + lifecycle.detectedContextKey = contextKey const state = get() if (state.detectedAgentIds !== existing || state.isDetectingAgents) { set({ detectedAgentIds: existing, isDetectingAgents: false }) @@ -53,32 +76,37 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se } return Promise.resolve(existing) } - const requestGeneration = localDetectionGeneration + const requestGeneration = lifecycle.localDetectionGeneration const exposeInflightToLegacy = (): void => { - if (!isFloating) { - legacyDetectContextKey = contextKey + if (!isolatedInventory) { + lifecycle.legacyDetectContextKey = contextKey } - if (isFloating) { + if (isolatedInventory) { return } const state = get() - const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') + const patch = getLegacyLoadingPatch( + state, + lifecycle.detectedContextKey === contextKey, + 'detect' + ) if (patch) { set(patch) } } - const inflight = detectPromises.get(contextKey) + const inflight = lifecycle.detectPromises.get(contextKey) if (inflight) { exposeInflightToLegacy() return inflight } - if (!isFloating) { - legacyDetectContextKey = contextKey + if (!isolatedInventory) { + lifecycle.legacyDetectContextKey = contextKey } set((state) => ({ - ...(isFloating + ...(isolatedInventory ? {} - : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'detect') ?? {})), + : (getLegacyLoadingPatch(state, lifecycle.detectedContextKey === contextKey, 'detect') ?? + {})), localDetectedAgentIdsByContext: { ...state.localDetectedAgentIdsByContext, [contextKey]: existing ?? null @@ -89,18 +117,18 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se } })) const pending = window.api.preflight - .detectAgents(context) + .detectAgents(includeDisabled ? context : { ...context, enabledOnly: true }) .then((ids) => { - const typed = ids as TuiAgent[] + const typed = ids.filter(isTuiAgent) if ( - requestGeneration === localDetectionGeneration && - detectPromises.get(contextKey) === pending + requestGeneration === lifecycle.localDetectionGeneration && + lifecycle.detectPromises.get(contextKey) === pending ) { - failedDetectContextKeys.delete(contextKey) - const exposeToLegacy = legacyDetectContextKey === contextKey + lifecycle.failedDetectContextKeys.delete(contextKey) + const exposeToLegacy = lifecycle.legacyDetectContextKey === contextKey if (exposeToLegacy) { - legacyDetectContextKey = null - detectedContextKey = contextKey + lifecycle.legacyDetectContextKey = null + lifecycle.detectedContextKey = contextKey } set((state) => ({ ...(exposeToLegacy ? { detectedAgentIds: typed, isDetectingAgents: false } : {}), @@ -116,15 +144,15 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se } return typed }) - .catch(() => { + .catch((): TuiAgent[] => { if ( - requestGeneration === localDetectionGeneration && - detectPromises.get(contextKey) === pending + requestGeneration === lifecycle.localDetectionGeneration && + lifecycle.detectPromises.get(contextKey) === pending ) { - failedDetectContextKeys.add(contextKey) - const exposeToLegacy = legacyDetectContextKey === contextKey + lifecycle.failedDetectContextKeys.add(contextKey) + const exposeToLegacy = lifecycle.legacyDetectContextKey === contextKey if (exposeToLegacy) { - legacyDetectContextKey = null + lifecycle.legacyDetectContextKey = null } set((state) => ({ ...(exposeToLegacy ? { detectedAgentIds: [], isDetectingAgents: false } : {}), @@ -138,170 +166,78 @@ export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (se ) })) } - return [] as TuiAgent[] + return [] }) .finally(() => { - if (detectPromises.get(contextKey) === pending) { - detectPromises.delete(contextKey) + if (lifecycle.detectPromises.get(contextKey) === pending) { + lifecycle.detectPromises.delete(contextKey) } }) - detectPromises.set(contextKey, pending) + lifecycle.detectPromises.set(contextKey, pending) return pending }, - refreshDetectedAgents: (worktreeId?: string | null) => { - const isFloating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID - const context = getLocalAgentPreflightContext(get(), undefined, undefined, worktreeId) - const contextKey = localAgentInventoryContextKey(context) - const cached = get().localDetectedAgentIdsByContext[contextKey] - const hadUsableCache = cached != null && !failedDetectContextKeys.has(contextKey) - const requestGeneration = localDetectionGeneration - const exposeInflightToLegacy = (): void => { - if (!isFloating) { - legacyRefreshContextKey = contextKey - } - if (isFloating) { - return - } - const state = get() - const patch = getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') - if (patch) { - set(patch) - } - } - const inflight = refreshPromises.get(contextKey) - if (inflight) { - exposeInflightToLegacy() - return inflight - } - if (!isFloating) { - legacyRefreshContextKey = contextKey - } - const supersedesDetect = detectPromises.delete(contextKey) - const clearsLegacyDetect = legacyDetectContextKey === contextKey - if (clearsLegacyDetect) { - legacyDetectContextKey = null - } - set((state) => ({ - ...(isFloating - ? {} - : (getLegacyLoadingPatch(state, detectedContextKey === contextKey, 'refresh') ?? {})), - ...getSupersededDetectPatch(state, contextKey, supersedesDetect, clearsLegacyDetect), - isRefreshingLocalAgentsByContext: { - ...state.isRefreshingLocalAgentsByContext, - [contextKey]: true - } - })) - const pending = window.api.preflight - .refreshAgents(context) - .then((result) => { - const typed = result.agents as TuiAgent[] - if ( - requestGeneration === localDetectionGeneration && - refreshPromises.get(contextKey) === pending - ) { - failedDetectContextKeys.delete(contextKey) - const exposeToLegacy = legacyRefreshContextKey === contextKey - if (exposeToLegacy) { - legacyRefreshContextKey = null - detectedContextKey = contextKey - } - set((state) => ({ - ...(exposeToLegacy - ? { - detectedAgentIds: typed, - isRefreshingAgents: false, - pathSource: result.pathSource, - pathFailureReason: result.pathFailureReason - } - : {}), - localDetectedAgentIdsByContext: { - ...state.localDetectedAgentIdsByContext, - [contextKey]: typed - }, - isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( - state.isRefreshingLocalAgentsByContext, - contextKey - ) - })) - } - return typed - }) - .catch(() => { - const fallback = get().localDetectedAgentIdsByContext[contextKey] ?? [] - if ( - requestGeneration === localDetectionGeneration && - refreshPromises.get(contextKey) === pending - ) { - keptThroughFailure.add(fallback) - if (!hadUsableCache) { - failedDetectContextKeys.add(contextKey) - } - const exposeToLegacy = legacyRefreshContextKey === contextKey - if (exposeToLegacy) { - legacyRefreshContextKey = null - detectedContextKey = contextKey - } - set((state) => ({ - ...(exposeToLegacy ? { detectedAgentIds: fallback, isRefreshingAgents: false } : {}), - localDetectedAgentIdsByContext: { - ...state.localDetectedAgentIdsByContext, - [contextKey]: fallback - }, - isRefreshingLocalAgentsByContext: contextEviction.removeLocalAgentContextEntry( - state.isRefreshingLocalAgentsByContext, - contextKey - ) - })) - } - return fallback - }) - .finally(() => { - if (refreshPromises.get(contextKey) === pending) { - refreshPromises.delete(contextKey) - } - }) - refreshPromises.set(contextKey, pending) - return pending - }, + refreshAllDetectedAgents: (worktreeId?: string | null): Promise => + actions.refreshDetectedAgents(worktreeId, true), + refreshDetectedAgents: (worktreeId?: string | null, includeDisabled = false) => + refreshLocalAgentInventory(set, get, lifecycle, worktreeId, includeDisabled), clearLocalDetectedAgentContextsForProjects: (projectIds: readonly string[]) => { const eviction = contextEviction.getLocalAgentContextEviction({ projectIds, state: get(), internalContextKeys: [ - ...detectPromises.keys(), - ...refreshPromises.keys(), - ...failedDetectContextKeys + ...lifecycle.detectPromises.keys(), + ...lifecycle.refreshPromises.keys(), + ...lifecycle.failedDetectContextKeys ], - detectedContextKey, - legacyDetectContextKey, - legacyRefreshContextKey + detectedContextKey: lifecycle.detectedContextKey, + legacyDetectContextKey: lifecycle.legacyDetectContextKey, + legacyRefreshContextKey: lifecycle.legacyRefreshContextKey }) if (!eviction) { return } for (const contextKey of eviction.removedContextKeys) { - detectPromises.delete(contextKey) - refreshPromises.delete(contextKey) - failedDetectContextKeys.delete(contextKey) + lifecycle.detectPromises.delete(contextKey) + lifecycle.refreshPromises.delete(contextKey) + lifecycle.failedDetectContextKeys.delete(contextKey) } - detectedContextKey = eviction.detectedContextKey - legacyDetectContextKey = eviction.legacyDetectContextKey - legacyRefreshContextKey = eviction.legacyRefreshContextKey + lifecycle.detectedContextKey = eviction.detectedContextKey + lifecycle.legacyDetectContextKey = eviction.legacyDetectContextKey + lifecycle.legacyRefreshContextKey = eviction.legacyRefreshContextKey set(eviction.statePatch) }, clearLocalDetectedAgents: () => { - localDetectionGeneration += 1 - detectPromises.clear() - refreshPromises.clear() - failedDetectContextKeys.clear() - detectedContextKey = null - legacyDetectContextKey = null - legacyRefreshContextKey = null + nativeInventoryRequested = false + lifecycle.localDetectionGeneration += 1 + lifecycle.detectPromises.clear() + lifecycle.refreshPromises.clear() + lifecycle.failedDetectContextKeys.clear() + lifecycle.detectedContextKey = null + lifecycle.legacyDetectContextKey = null + lifecycle.legacyRefreshContextKey = null set(createEmptyLocalDetectedAgentState()) } } + store.subscribe((state, previous) => { + if (state.settings?.disabledTuiAgents === previous.settings?.disabledTuiAgents) { + return + } + if ( + !haveSameDisabledTuiAgents( + state.settings?.disabledTuiAgents, + previous.settings?.disabledTuiAgents + ) + ) { + const reacquireNativeInventory = nativeInventoryRequested + actions.clearLocalDetectedAgents() + // Native consumers can remain mounted while workspace consumers reacquire only WSL. + if (reacquireNativeInventory) { + void actions.ensureNativeDetectedAgents() + } + } + }) return actions } diff --git a/src/renderer/src/store/slices/local-detected-agent-store-state.ts b/src/renderer/src/store/slices/local-detected-agent-store-state.ts index f1461258d76..1f0a1ecb53d 100644 --- a/src/renderer/src/store/slices/local-detected-agent-store-state.ts +++ b/src/renderer/src/store/slices/local-detected-agent-store-state.ts @@ -18,6 +18,8 @@ export type LocalDetectedAgentState = { isRefreshingLocalAgentsByContext: Record pathSource: PathSource | null pathFailureReason: ShellHydrationFailureReason | null + ensureAllDetectedAgents: (worktreeId?: string | null) => Promise + refreshAllDetectedAgents: (worktreeId?: string | null) => Promise ensureNativeDetectedAgents: () => Promise ensureDetectedAgents: (worktreeId?: string | null) => Promise refreshDetectedAgents: (worktreeId?: string | null) => Promise