perf: defer and parallelize AI agent CLI discovery at startup

get_ai_agents_status used to probe all six agent CLIs (claude_code,
codex, opencode, pi, gemini, kiro) sequentially during cold start. For
each agent missing from PATH it would fall through to three login-shell
spawns (`<shell> -lc 'command -v <agent>'`), each evaluating the user's
full zsh init. On a machine with no agents installed this added ~5 s to
cold start.

The result is not on the first-paint render path. It feeds the status
bar AI badge, the AI tab in settings, and the AI agents onboarding
prompt — all of which are gated behind aiFeaturesEnabled and the
onboarding flow, so they happily render nothing while the probe is
pending.

Three additive changes:

* useAiAgentsStatus now takes an `enabled` option. When false (AI
  features off, or running in a detached note window) the probe never
  fires. App.tsx passes `aiFeaturesEnabled && !noteWindowParams`.
* When enabled, the invoke call is deferred via requestIdleCallback
  (with setTimeout(0) fallback because WKWebView lacks the API) so it
  lands after first paint instead of during it.
* Rust get_ai_agents_status is now async and fans the six check_cli()
  calls out under tokio::task::spawn_blocking + tokio::join!. Per-agent
  check_cli() signatures stay sync, so run_agent_stream callers are
  unaffected and the per-agent unit tests need no changes.

Cold-start measurements on this machine (no agents on PATH, dev mode):

  Baseline (main, AI on):       cascade settles at t+15.5 s
  Patched, AI features off:                       t+5.0 s
  Patched, AI features on:                        t+6.0 s,
                                  AI status arrives ~1.6 s after first
                                  paint, fully off the critical path.

Fixes: https://github.com/refactoringhq/tolaria/issues/726

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elias Stiffel
2026-05-25 21:24:07 +02:00
parent 780e33bff2
commit fe1ff59879
5 changed files with 228 additions and 62 deletions

View File

@@ -81,14 +81,44 @@ impl AiAgentStreamRequest {
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
/// Probe every supported AI-agent CLI in parallel.
///
/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s
/// when the binary is missing and we fall through to the login-shell
/// fallback (`/bin/zsh -lc 'command -v <agent>'` etc., evaluating the full
/// shell startup). Running them sequentially used to add ~5 s to cold start
/// when no agents are installed. Fan them out across Tokio's blocking pool
/// so the user-perceived wall time is the slowest single probe rather than
/// the sum of all six.
///
/// A panicking probe is mapped to `installed: false` so the IPC handler
/// always returns a fully populated `AiAgentsStatus` and the frontend can
/// keep rendering.
pub async fn get_ai_agents_status() -> AiAgentsStatus {
let claude = tokio::task::spawn_blocking(availability_from_claude);
let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli);
let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli);
let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli);
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
let (claude, codex, opencode, pi, gemini, kiro) =
tokio::join!(claude, codex, opencode, pi, gemini, kiro);
AiAgentsStatus {
claude_code: availability_from_claude(),
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
kiro: crate::kiro_cli::check_cli(),
claude_code: claude.unwrap_or_else(|_| missing_availability()),
codex: codex.unwrap_or_else(|_| missing_availability()),
opencode: opencode.unwrap_or_else(|_| missing_availability()),
pi: pi.unwrap_or_else(|_| missing_availability()),
gemini: gemini.unwrap_or_else(|_| missing_availability()),
kiro: kiro.unwrap_or_else(|_| missing_availability()),
}
}
fn missing_availability() -> AiAgentAvailability {
AiAgentAvailability {
installed: false,
version: None,
}
}
@@ -245,15 +275,16 @@ mod tests {
);
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
#[tokio::test]
async fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status().await;
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
status.gemini.installed,
status.kiro.installed,
];
assert!(install_flags

View File

@@ -62,8 +62,8 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
#[cfg(desktop)]
#[tauri::command]
pub fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status()
pub async fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status().await
}
#[cfg(desktop)]

View File

@@ -330,7 +330,9 @@ function App() {
onVaultReady: handleOnboardingVaultReady,
registerVault: registerVaultSelection,
}, vaultSwitcher.loaded)
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsStatus = useAiAgentsStatus({
enabled: aiFeaturesEnabled && !noteWindowParams,
})
const aiAgentsOnboarding = useAiAgentsOnboarding(aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams)
// Onboarding can briefly own the vault path for a newly created/opened vault

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useAiAgentsStatus } from './useAiAgentsStatus'
vi.mock('@tauri-apps/api/core', () => ({
@@ -13,53 +13,132 @@ vi.mock('../mock-tauri', () => ({
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
function installedStatusResponse() {
return {
claude_code: { installed: true, version: '1.0.20' },
codex: { installed: false, version: null },
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
gemini: { installed: true, version: '0.5.1' },
}
}
describe('useAiAgentsStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves agent statuses', async () => {
mockInvoke.mockImplementation((command: string) => {
if (command === 'get_ai_agents_status') {
return Promise.resolve({
claude_code: { installed: true, version: '1.0.20' },
codex: { installed: false, version: null },
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
gemini: { installed: true, version: '0.5.1' },
})
}
return Promise.resolve(null)
describe('with real timers', () => {
it('starts in checking state and resolves agent statuses', async () => {
mockInvoke.mockImplementation((command: string) => {
if (command === 'get_ai_agents_status') {
return Promise.resolve(installedStatusResponse())
}
return Promise.resolve(null)
})
const { result } = renderHook(() => useAiAgentsStatus())
expect(result.current.claude_code.status).toBe('checking')
expect(result.current.codex.status).toBe('checking')
expect(result.current.opencode.status).toBe('checking')
expect(result.current.pi.status).toBe('checking')
expect(result.current.gemini.status).toBe('checking')
// The probe is deferred via requestIdleCallback / setTimeout(0). With
// real timers the deferred callback fires on the next event-loop tick.
await waitFor(() => {
expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
expect(result.current.codex).toEqual({ status: 'missing', version: null })
expect(result.current.opencode).toEqual({ status: 'installed', version: '0.3.1' })
expect(result.current.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(result.current.gemini).toEqual({ status: 'installed', version: '0.5.1' })
})
})
const { result } = renderHook(() => useAiAgentsStatus())
it('falls back to missing when the status call fails', async () => {
mockInvoke.mockRejectedValue(new Error('failed'))
expect(result.current.claude_code.status).toBe('checking')
expect(result.current.codex.status).toBe('checking')
expect(result.current.opencode.status).toBe('checking')
expect(result.current.pi.status).toBe('checking')
expect(result.current.gemini.status).toBe('checking')
const { result } = renderHook(() => useAiAgentsStatus())
await waitFor(() => {
expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' })
expect(result.current.codex).toEqual({ status: 'missing', version: null })
expect(result.current.opencode).toEqual({ status: 'installed', version: '0.3.1' })
expect(result.current.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(result.current.gemini).toEqual({ status: 'installed', version: '0.5.1' })
await waitFor(() => {
expect(result.current.claude_code.status).toBe('missing')
expect(result.current.codex.status).toBe('missing')
expect(result.current.opencode.status).toBe('missing')
expect(result.current.pi.status).toBe('missing')
expect(result.current.gemini.status).toBe('missing')
})
})
})
it('falls back to missing when the status call fails', async () => {
mockInvoke.mockRejectedValue(new Error('failed'))
describe('deferral and gating (fake timers)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
const { result } = renderHook(() => useAiAgentsStatus())
afterEach(() => {
vi.useRealTimers()
})
await waitFor(() => {
expect(result.current.claude_code.status).toBe('missing')
expect(result.current.codex.status).toBe('missing')
expect(result.current.opencode.status).toBe('missing')
expect(result.current.pi.status).toBe('missing')
expect(result.current.gemini.status).toBe('missing')
it('does not invoke the probe when disabled', async () => {
mockInvoke.mockResolvedValue(installedStatusResponse())
const { result } = renderHook(() => useAiAgentsStatus({ enabled: false }))
await vi.runAllTimersAsync()
expect(mockInvoke).not.toHaveBeenCalled()
// Status stays in initial 'checking' state because no consumer renders it
// when the gate is off, so it does not need to be reset.
expect(result.current.claude_code.status).toBe('checking')
})
it('defers the probe until the next idle callback / timeout tick', async () => {
mockInvoke.mockResolvedValue(installedStatusResponse())
renderHook(() => useAiAgentsStatus({ enabled: true }))
// Immediately after mount, the probe should not have fired yet — it is
// queued behind requestIdleCallback (or setTimeout(0) in jsdom/WebKit).
expect(mockInvoke).not.toHaveBeenCalled()
await act(async () => {
await vi.runAllTimersAsync()
})
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
})
it('fires a fresh probe when enabled toggles from false to true', async () => {
mockInvoke.mockResolvedValue(installedStatusResponse())
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) => useAiAgentsStatus({ enabled }),
{ initialProps: { enabled: false } },
)
await vi.runAllTimersAsync()
expect(mockInvoke).not.toHaveBeenCalled()
rerender({ enabled: true })
await act(async () => {
await vi.runAllTimersAsync()
})
expect(mockInvoke).toHaveBeenCalledTimes(1)
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
})
it('cancels the deferred probe when the hook unmounts before idle fires', async () => {
mockInvoke.mockResolvedValue(installedStatusResponse())
const { unmount } = renderHook(() => useAiAgentsStatus({ enabled: true }))
// Unmount before the idle callback runs.
unmount()
await vi.runAllTimersAsync()
expect(mockInvoke).not.toHaveBeenCalled()
})
})
})

View File

@@ -11,30 +11,84 @@ import {
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
interface UseAiAgentsStatusOptions {
/**
* When false, the hook stays in its initial state and never calls the
* Tauri probe. Used to skip the ~1 s discovery cost when AI features are
* disabled or when running in a detached note window where the result is
* never rendered.
*
* Defaults to true to preserve existing behaviour for callers that pass
* no options.
*/
enabled?: boolean
}
type IdleHandle = number
function tauriCall<T>(command: string): Promise<T> {
return isTauri() ? invoke<T>(command) : mockInvoke<T>(command)
}
export function useAiAgentsStatus(): AiAgentsStatus {
function scheduleIdle(callback: () => void): IdleHandle {
const requestIdle = (typeof window !== 'undefined' ? window.requestIdleCallback : undefined)
if (typeof requestIdle === 'function') {
return requestIdle(callback) as IdleHandle
}
return setTimeout(callback, 0) as unknown as IdleHandle
}
function cancelIdle(handle: IdleHandle): void {
const cancelIdleFn = (typeof window !== 'undefined' ? window.cancelIdleCallback : undefined)
if (typeof cancelIdleFn === 'function') {
cancelIdleFn(handle)
}
// Always also clearTimeout — in environments without requestIdleCallback the
// handle was created by setTimeout, and clearTimeout silently no-ops on
// unknown handles, so it is safe to call unconditionally.
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
}
export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsStatus {
const enabled = options?.enabled ?? true
const [statuses, setStatuses] = useState<AiAgentsStatus>(createCheckingAiAgentsStatus())
useEffect(() => {
if (!enabled) {
// Skip the probe entirely. Status is intentionally NOT reset — last-known
// results stay in memory across enabled/disabled toggles so that a brief
// disable does not blank out the badge.
return
}
let cancelled = false
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
.then((result) => {
if (!cancelled) {
setStatuses(normalizeAiAgentsStatus(result))
}
})
.catch(() => {
if (!cancelled) {
setStatuses(createMissingAiAgentsStatus())
}
})
const fire = () => {
if (cancelled) return
return () => { cancelled = true }
}, [])
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
.then((result) => {
if (!cancelled) {
setStatuses(normalizeAiAgentsStatus(result))
}
})
.catch(() => {
if (!cancelled) {
setStatuses(createMissingAiAgentsStatus())
}
})
}
// Defer the probe so it does not run on the cold-start critical path.
// requestIdleCallback is unavailable in WKWebView (Tauri's macOS web view),
// so fall back to setTimeout(0).
const handle = scheduleIdle(fire)
return () => {
cancelled = true
cancelIdle(handle)
}
}, [enabled])
return statuses
}