mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
Reference in New Issue
Block a user