diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 865a585c6e..1d3e7fcf2a 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -1,10 +1,55 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' import { host } from '@/sdk' import { setActiveSessionId, setAwaitingResponse, setBusy } from '@/store/session' import { clearAllSessionStates, publishSessionState } from '@/store/session-states' +// The warm path must route through the guarded prewarm resolver, not dial the +// gateway directly: gateway.ts's openSecondaryCount and pool-limits' cap atom +// are the two signals prewarmProfileBackend consults, so mocking them lets the +// tests observe the guard's decision through the ONLY side effect that matters +// — whether openGatewayForProfile was dialed. +const warmMocks = vi.hoisted(() => ({ + openGatewayForProfile: vi.fn(async (_profile: string) => undefined), + openSecondaryCount: vi.fn(() => 0) +})) + +vi.mock('@/store/gateway', async importOriginal => ({ + ...((await importOriginal()) as Record), + openGatewayForProfile: warmMocks.openGatewayForProfile, + openSecondaryCount: warmMocks.openSecondaryCount +})) + +vi.mock('@/store/pool-limits', async () => { + const { atom } = await import('nanostores') + + return { $poolLimits: atom({ idleMs: 600_000, maxBackends: 3 }) } +}) + +describe('host.warmProfile pool-saturation contract', () => { + beforeEach(() => { + warmMocks.openGatewayForProfile.mockClear() + warmMocks.openSecondaryCount.mockReturnValue(0) + }) + + it('dials through the guarded path when a pool slot is free', () => { + warmMocks.openSecondaryCount.mockReturnValue(2) + + host.warmProfile('warm-free-slot') + + expect(warmMocks.openGatewayForProfile).toHaveBeenCalledWith('warm-free-slot') + }) + + it('skips the speculative spawn when every pool slot is occupied', () => { + warmMocks.openSecondaryCount.mockReturnValue(3) + + host.warmProfile('warm-saturated') + + expect(warmMocks.openGatewayForProfile).not.toHaveBeenCalled() + }) +}) + describe('host.state turn flags', () => { afterEach(() => { setActiveSessionId(null) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 9acf23ded4..1ff9e720c5 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -67,6 +67,7 @@ import { newSessionInAgent, newSessionInProfile, normalizeProfileKey, + prewarmProfileBackend, refreshProfiles, selectProfile, setActiveProfile, @@ -666,20 +667,25 @@ export const host = { }, /** Pre-dial a profile's gateway socket in the background — pool-only, no - * activation, no navigation, no scope change (openGatewayForProfile; it - * already no-ops for shared-remote routes and the primary). Roster UIs - * call this after mount so the FIRST click on an agent doesn't pay the - * whole backend spawn + socket dial latency. Fire-and-forget: failures - * are swallowed — the click path re-runs its own ensure and surfaces - * errors properly. */ + * activation, no navigation, no scope change. Delegates to + * prewarmProfileBackend so plugin surfaces get the SAME pool-saturation + * guard, hover dwell, and per-profile throttle as the built-in rail + * (#91545): a pointer sweep across a plugin roster (bot-row's + * onPointerEnter fires with no dwell of its own) previously spawned at + * pointer speed, filled the local backend pool past maxBackends, and left + * the next profile's spawn queued until the 30s slot timeout — observed + * as a profile surface that hangs forever while every other profile + * renders. It already no-ops for shared-remote routes and the primary. + * Fire-and-forget: failures are swallowed — the click path re-runs its + * own ensure and surfaces errors properly. */ warmProfile: (profile: string): void => { const name = (profile ?? '').trim() - if (!name || name === $activeGatewayProfile.get()) { + if (!name) { return } - void openGatewayForProfile(name).catch(() => undefined) + prewarmProfileBackend(name) }, /** Delete a profile THROUGH the desktop's teardown-routed REST path — the