diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index 275fca284ae..3a875b4facb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -18,7 +18,7 @@ sealed class KiloAppState { data object Connecting : KiloAppState() data class Loading(val progress: LoadProgress) : KiloAppState() data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState() - data class Ready(val data: AppData) : KiloAppState() + data class Ready(val data: AppData, val rev: Long = 0) : KiloAppState() data class Error(val message: String, val errors: List = emptyList()) : KiloAppState() } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index c815f95444e..88cfe1bd64f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -57,6 +57,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.net.ConnectException import java.net.SocketTimeoutException import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -118,6 +119,7 @@ class KiloBackendAppService private constructor( private var loader: Job? = null private var closed = false private val loadLock = Any() + private val rev = AtomicLong() private val _appState = MutableStateFlow(KiloAppState.Disconnected) val appState: StateFlow = _appState.asStateFlow() @@ -338,7 +340,6 @@ class KiloBackendAppService private constructor( private fun load() { synchronized(loadLock) { loader?.cancel() - eventWatcher?.cancel() loader = cs.launch { val start = System.currentTimeMillis() log.info("Application starting — loading config, profile, notifications") @@ -655,7 +656,7 @@ class KiloBackendAppService private constructor( private fun setAppReady(data: AppData) { warnings = data.warnings if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings) - _appState.value = KiloAppState.Ready(data) + _appState.value = KiloAppState.Ready(data, rev.incrementAndGet()) } private fun setAppError(message: String, errors: List) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 817c8ca4af3..6104fed10e6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -19,9 +19,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -472,23 +470,25 @@ class KiloBackendWorkspaceTest { ws.state.first { it is KiloWorkspaceState.Ready } } - // Fire rapid reloads — simulates rapid SSE disposed events + mock.providers = OPENAI_PROVIDERS_JSON + val before = mock.requestCount("/provider") + repeat(5) { ws.reload() } - // Final state must be valid Ready - withTimeout(15_000) { - while (true) { - val state = ws.state.value - if (state is KiloWorkspaceState.Ready) { - delay(300) - if (ws.state.value is KiloWorkspaceState.Ready) break - } - delay(100) + assertTrue( + mock.awaitRequestCount("/provider", before + 1), + "Workspace reload did not request providers; state=${ws.state.value}; logs=${log.messages}", + ) + + val state = withTimeout(15_000) { + ws.state.first { + it is KiloWorkspaceState.Ready && + it.providers.providers.firstOrNull()?.id == "openai" } - } + } as KiloWorkspaceState.Ready - val state = ws.state.value as KiloWorkspaceState.Ready assertEquals(1, state.providers.providers.size) + assertEquals("openai", state.providers.providers[0].id) assertEquals(1, state.agents.agents.size) } @@ -502,35 +502,29 @@ class KiloBackendWorkspaceTest { val app = setup() val initial = ready(app) - // Change providers response then fire disposed event - mock.providers = """{ - "all": [{ - "id": "openai", - "name": "OpenAI", - "source": "api", - "env": [], - "options": {}, - "models": {} - }], - "default": {}, - "connected": ["openai"] - }""" + mock.providers = OPENAI_PROVIDERS_JSON assertTrue(mock.awaitSseConnection()) + val prev = (app.appState.value as KiloAppState.Ready).rev + val before = mock.requestCount("/global/config") val reload = async(start = CoroutineStart.UNDISPATCHED) { - app.appState.drop(1).first { it is KiloAppState.Ready } + app.appState.first { it is KiloAppState.Ready && it.rev > prev } } mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + assertTrue( + mock.awaitRequestCount("/global/config", before + 1), + "global.disposed did not start app reload; state=${app.appState.value}; logs=${log.messages}", + ) withTimeout(15_000) { reload.await() } - // Get a fresh workspace — old one was stopped during reload val ws = app.workspaces.get("/test/project") assertTrue(ws !== initial) - withTimeout(15_000) { - ws.state.first { it is KiloWorkspaceState.Ready } - } - - val state = ws.state.value as KiloWorkspaceState.Ready + val state = withTimeout(15_000) { + ws.state.first { + it is KiloWorkspaceState.Ready && + it.providers.providers.firstOrNull()?.id == "openai" + } + } as KiloWorkspaceState.Ready assertEquals("openai", state.providers.providers[0].id) } @@ -572,6 +566,19 @@ class KiloBackendWorkspaceTest { "connected": ["anthropic"] }""".trimIndent() + private val OPENAI_PROVIDERS_JSON = """{ + "all": [{ + "id": "openai", + "name": "OpenAI", + "source": "api", + "env": [], + "options": {}, + "models": {} + }], + "default": {}, + "connected": ["openai"] + }""".trimIndent() + private val AGENTS_JSON = """[ {"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}} ]""".trimIndent()