Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion apps/desktop/src/sdk/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,61 @@
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<string, unknown>),
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()
})

it('ignores blank profile names', () => {
host.warmProfile(' ')

expect(warmMocks.openGatewayForProfile).not.toHaveBeenCalled()
})
})

describe('host.state turn flags', () => {
afterEach(() => {
setActiveSessionId(null)
Expand Down
22 changes: 14 additions & 8 deletions apps/desktop/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
newSessionInAgent,
newSessionInProfile,
normalizeProfileKey,
prewarmProfileBackend,
refreshProfiles,
selectProfile,
setActiveProfile,
Expand Down Expand Up @@ -643,20 +644,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
Expand Down