fix(preflight): skip probing agents disabled in Settings (#34832)

* fix(preflight): skip probing agents disabled in Settings

Background worktree-startup detection now passes settings.disabledTuiAgents
(which it already held for agent selection) into detectInstalledAgents, so
disabled agents are neither probed nor reported. The preflight:detectAgents
and preflight:refreshAgents handlers keep probing every agent so Settings →
Agents still reports real install status.

Fixes #34635

* test(preflight): cover disabled agent detection forwarding

* fix(preflight): skip disabled agents in background inventory

* fix(preflight): restore native inventory after settings changes

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Pr1p
2026-10-28 13:32:08 -07:00
committed by GitHub
co-authored by Jinjing
parent 7c57ad6a55
commit 8e9a08b543
24 changed files with 992 additions and 245 deletions
@@ -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') {
+17 -5
View File
@@ -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<Store, 'getSettings'>): 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(
@@ -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 })
+8 -4
View File
@@ -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<RefreshAgentsResult> {
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,
@@ -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<string>())
}))
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
})
})
})
@@ -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<string[]> {
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<string[]> {
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<string[]> {
await hydrateShellPathForAgentDetection(context)
return detectInstalledAgents(context)
return detectInstalledAgents(context, options)
}
@@ -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'] })
})
@@ -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<string[]> {
export async function detectAgentsOnHost(
host: AgentDetectionHost,
options?: InstalledAgentDetectionOptions
): Promise<string[]> {
// 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<RefreshAgentsResult> {
export async function refreshAgentsOnHost(
host: AgentDetectionHost,
options?: InstalledAgentDetectionOptions
): Promise<RefreshAgentsResult> {
if (host.kind === 'ssh') {
throw new SshHostNotServedError(host.connectionId)
}
const wsl = getPreflightWslTarget(host.context)
const refresh = async (): Promise<RefreshAgentsResult> => {
const result = await refreshShellPathAndDetectAgents(host.context)
const result = await (options
? refreshShellPathAndDetectAgents(host.context, options)
: refreshShellPathAndDetectAgents(host.context))
if (!wsl) {
await captureHostShellEnvironment()
}
@@ -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 } })
@@ -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.
@@ -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 = []
+5 -2
View File
@@ -44,10 +44,13 @@ export type PreflightRuntimeContext = {
export type PreflightApi = {
check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise<PreflightStatus>
detectAgents: (args?: PreflightRuntimeContext) => Promise<string[]>
/** Complete by default; background callers opt into host-owned enabled preferences. */
detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }) => Promise<string[]>
/** Cached in main for the process lifetime. */
zcodeInteractiveCapability: () => Promise<ZCodeInteractiveCapability>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
refreshAgents: (
args?: PreflightRuntimeContext & { enabledOnly?: boolean }
) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
wslAvailable: boolean
+4 -3
View File
@@ -26,12 +26,13 @@ export const preflightApi = {
}
linear: { connected: boolean }
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (args?: PreflightRuntimeContext): Promise<string[]> =>
detectAgents: (args?: PreflightRuntimeContext & { enabledOnly?: boolean }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
zcodeInteractiveCapability: (): Promise<ZCodeInteractiveCapability> =>
ipcRenderer.invoke('preflight:zcodeInteractiveCapability'),
refreshAgents: (args?: PreflightRuntimeContext): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
refreshAgents: (
args?: PreflightRuntimeContext & { enabledOnly?: boolean }
): Promise<RefreshAgentsResult> => ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args),
detectRemoteWindowsTerminalCapabilities: (args: {
@@ -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)
@@ -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 }]
])
})
@@ -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 })
})
})
+24 -6
View File
@@ -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<Set<string>>(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,
@@ -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
}
@@ -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: {
@@ -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<string, Promise<TuiAgent[]>>
refreshPromises: Map<string, Promise<TuiAgent[]>>
keptThroughFailure: WeakSet<TuiAgent[]>
failedDetectContextKeys: Set<string>
detectedContextKey: string | null
legacyDetectContextKey: string | null
legacyRefreshContextKey: string | null
localDetectionGeneration: number
}
export function refreshLocalAgentInventory(
set: StoreApi<AppState>['setState'],
get: StoreApi<AppState>['getState'],
lifecycle: LocalAgentInventoryLifecycle,
worktreeId?: string | null,
includeDisabled = false
): Promise<TuiAgent[]> {
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
}
@@ -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<AppState, 'isDetectingLocalAgentsByContext'>,
contextKey: string,
supersedesDetect: boolean,
contextKeys: ReadonlySet<string>,
clearsLegacyDetect: boolean
): Partial<AppState> {
return {
...(clearsLegacyDetect ? { isDetectingAgents: false } : {}),
...(supersedesDetect
? {
isDetectingLocalAgentsByContext: removeLocalAgentContextEntry(
state.isDetectingLocalAgentsByContext,
contextKey
)
}
: {})
isDetectingLocalAgentsByContext: removeLocalAgentContextEntries(
state.isDetectingLocalAgentsByContext,
contextKeys
)
}
}
@@ -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<boolean, (ids: string[]) => void>()
detectAgents.mockImplementation(
(args) =>
new Promise<string[]>((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<string[]>((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<string[]>((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<string[]>()
const fullDetection = Promise.withResolvers<string[]>()
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<string[]>((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<string[]>()
const disabledDetection = Promise.withResolvers<string[]>()
const reenabledDetection = Promise.withResolvers<string[]>()
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)
@@ -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<AppState, [], [], LocalDetectedAgentState>
export const createLocalDetectedAgentState: LocalDetectedAgentStateCreator = (set, get) => {
const detectPromises = new Map<string, Promise<TuiAgent[]>>()
const refreshPromises = new Map<string, Promise<TuiAgent[]>>()
const keptThroughFailure = new WeakSet<TuiAgent[]>()
const failedDetectContextKeys = new Set<string>()
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<TuiAgent[]> =>
actions.ensureDetectedAgents(undefined, true),
ensureDetectedAgents: (worktreeId?: string | null, nativeHost = false): Promise<TuiAgent[]> => {
const isFloating = nativeHost || worktreeId === FLOATING_TERMINAL_WORKTREE_ID
readLocalAgentInventoryOutcome(
get(),
id,
lifecycle.failedDetectContextKeys,
lifecycle.keptThroughFailure
),
ensureNativeDetectedAgents: (): Promise<TuiAgent[]> => {
nativeInventoryRequested = true
return actions.ensureDetectedAgents(undefined, true)
},
ensureAllDetectedAgents: (worktreeId?: string | null): Promise<TuiAgent[]> =>
actions.ensureDetectedAgents(worktreeId, false, true),
ensureDetectedAgents: (
worktreeId?: string | null,
nativeHost = false,
includeDisabled = false
): Promise<TuiAgent[]> => {
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<TuiAgent[]> =>
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
}
@@ -18,6 +18,8 @@ export type LocalDetectedAgentState = {
isRefreshingLocalAgentsByContext: Record<string, boolean>
pathSource: PathSource | null
pathFailureReason: ShellHydrationFailureReason | null
ensureAllDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
refreshAllDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
ensureNativeDetectedAgents: () => Promise<TuiAgent[]>
ensureDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>
refreshDetectedAgents: (worktreeId?: string | null) => Promise<TuiAgent[]>