From c178fedaf7af35bd349147c05564363cfab260f8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 12:19:13 -0400 Subject: [PATCH 01/13] feat(jetbrains): add project data loading from CLI server Load providers, agents, commands, and skills when the CLI server is ready. Adds shared DTOs, KiloProjectRpcApi, and a frontend service so the webview can reactively consume project-scoped data. Also fixes FixGeneratedApiTask @InputDirectory -> @OutputDirectory to prevent stale file references after empty wrapper deletion. --- .../backend/KiloBackendProjectService.kt | 22 -- .../project/KiloBackendProjectService.kt | 283 ++++++++++++++++++ .../backend/project/KiloProjectState.kt | 86 ++++++ .../backend/rpc/KiloProjectRpcApiImpl.kt | 145 +++++++++ .../backend/rpc/KiloProjectRpcApiProvider.kt | 15 + .../main/resources/kilo.jetbrains.backend.xml | 1 + .../src/main/kotlin/FixGeneratedApiTask.kt | 4 +- .../ai/kilocode/client/KiloProjectService.kt | 57 ++++ .../ai/kilocode/rpc/KiloProjectRpcApi.kt | 30 ++ .../kotlin/ai/kilocode/rpc/dto/AgentDto.kt | 22 ++ .../kotlin/ai/kilocode/rpc/dto/CommandDto.kt | 11 + .../kilocode/rpc/dto/KiloProjectStateDto.kt | 30 ++ .../kotlin/ai/kilocode/rpc/dto/ProviderDto.kt | 30 ++ .../kotlin/ai/kilocode/rpc/dto/SkillDto.kt | 10 + 14 files changed, 722 insertions(+), 24 deletions(-) delete mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt deleted file mode 100644 index c4e2a4ea713..00000000000 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendProjectService.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ai.kilocode.backend - -import com.intellij.openapi.components.Service -import com.intellij.openapi.project.Project -import kotlinx.coroutines.CoroutineScope - -/** - * Project-level backend service that provides the project's working - * directory for scoping CLI API calls. - * - * Currently a thin shell — will hold project-scoped data loading - * (providers, agents, config, etc.) in the future. - */ -@Service(Service.Level.PROJECT) -class KiloBackendProjectService( - private val project: Project, - private val cs: CoroutineScope, -) { - /** Project working directory sent as the `directory` parameter. */ - val directory: String - get() = project.basePath ?: "" -} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt new file mode 100644 index 00000000000..0f4233f5c46 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt @@ -0,0 +1,283 @@ +package ai.kilocode.backend.project + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.util.IntellijLog +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.Agent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference + +/** + * Project-level backend service that loads project-scoped data + * from the CLI server when the app reaches [KiloAppState.Ready]. + * + * Watches [KiloBackendAppService.appState] and triggers parallel + * data fetches for providers, agents, commands, and skills. + * Progress is tracked via [KiloProjectLoadProgress] and emitted as + * [KiloProjectState.Loading] → [KiloProjectState.Ready] or [KiloProjectState.Error]. + * + * Each fetch is retried up to [MAX_RETRIES] times, matching the + * pattern in [KiloBackendAppService]. + */ +@Service(Service.Level.PROJECT) +class KiloBackendProjectService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private const val MAX_RETRIES = 3 + private const val RETRY_DELAY_MS = 1000L + } + + /** Project working directory sent as the `directory` parameter. */ + val directory: String + get() = project.basePath ?: "" + + private val log: KiloLog = IntellijLog(KiloBackendProjectService::class.java) + + private val _state = MutableStateFlow(KiloProjectState.Pending) + val state: StateFlow = _state.asStateFlow() + + private var loader: Job? = null + + init { + cs.launch { + service().appState.collect { state -> + when (state) { + is KiloAppState.Ready -> load() + is KiloAppState.Disconnected, + is KiloAppState.Connecting -> { + loader?.cancel() + _state.value = KiloProjectState.Pending + } + is KiloAppState.Error -> { + loader?.cancel() + _state.value = KiloProjectState.Pending + } + is KiloAppState.Loading -> { /* wait for Ready */ } + } + } + } + } + + /** Force a full reload of all project data. */ + suspend fun reload() { + load() + } + + /** + * Launch all project data fetches in parallel. + * + * Each resource is retried up to [MAX_RETRIES] times. + * Progress is tracked via [AtomicReference] and emitted + * as [KiloProjectState.Loading]. + */ + private fun load() { + loader?.cancel() + loader = cs.launch { + val dir = directory + val api = service().api + if (api == null) { + _state.value = KiloProjectState.Error("CLI server not connected") + return@launch + } + + log.info("Loading project data for $dir") + val progress = AtomicReference(KiloProjectLoadProgress()) + _state.value = KiloProjectState.Loading(progress.get()) + + val errors = mutableListOf() + + try { + coroutineScope { + launch { + val result = fetchWithRetry("providers") { fetchProviders(api, dir) } + if (result != null) { + progress.updateAndGet { it.copy(providers = true) } + .also { _state.value = KiloProjectState.Loading(it) } + } else { + synchronized(errors) { errors.add("providers") } + throw LoadFailure("providers") + } + } + launch { + val result = fetchWithRetry("agents") { fetchAgents(api, dir) } + if (result != null) { + progress.updateAndGet { it.copy(agents = true) } + .also { _state.value = KiloProjectState.Loading(it) } + } else { + synchronized(errors) { errors.add("agents") } + throw LoadFailure("agents") + } + } + launch { + val result = fetchWithRetry("commands") { fetchCommands(api, dir) } + if (result != null) { + progress.updateAndGet { it.copy(commands = true) } + .also { _state.value = KiloProjectState.Loading(it) } + } else { + synchronized(errors) { errors.add("commands") } + throw LoadFailure("commands") + } + } + launch { + val result = fetchWithRetry("skills") { fetchSkills(api, dir) } + if (result != null) { + progress.updateAndGet { it.copy(skills = true) } + .also { _state.value = KiloProjectState.Loading(it) } + } else { + synchronized(errors) { errors.add("skills") } + throw LoadFailure("skills") + } + } + } + + // All succeeded — assemble Ready state + _state.value = KiloProjectState.Ready( + providers = providers!!, + agents = agents!!, + commands = commands!!, + skills = skills!!, + ) + log.info("Project data loaded for $dir") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Project data load failed for $dir: ${e.message}") + _state.value = KiloProjectState.Error( + "Failed to load: ${synchronized(errors) { errors.joinToString() }}" + ) + } + } + } + + // ------ cached results from each fetch ------ + + @Volatile private var providers: ProviderData? = null + @Volatile private var agents: AgentData? = null + @Volatile private var commands: List? = null + @Volatile private var skills: List? = null + + // ------ individual fetch methods ------ + + private fun fetchProviders(api: DefaultApi, dir: String): ProviderData? = + try { + val response = api.providerList(directory = dir) + val mapped = response.all.map { p -> + ProviderInfo( + id = p.id, + name = p.name, + source = p.api, + models = p.models.mapValues { (_, m) -> + ModelInfo( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.isFree ?: false, + status = m.status?.value, + ) + }, + ) + } + ProviderData( + providers = mapped, + connected = response.connected, + defaults = response.default, + ).also { providers = it } + } catch (e: Exception) { + log.warn("Providers fetch failed: ${e.message}", e) + null + } + + private fun fetchAgents(api: DefaultApi, dir: String): AgentData? = + try { + val response = api.appAgents(directory = dir) + val mapped = response.map(::mapAgent) + val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } + val default = visible.firstOrNull()?.name ?: "code" + AgentData( + agents = visible.map(::mapAgent), + all = mapped, + default = default, + ).also { agents = it } + } catch (e: Exception) { + log.warn("Agents fetch failed: ${e.message}", e) + null + } + + private fun fetchCommands(api: DefaultApi, dir: String): List? = + try { + api.commandList(directory = dir).map { c -> + CommandInfo( + name = c.name, + description = c.description, + source = c.source?.value, + hints = c.hints, + ) + }.also { commands = it } + } catch (e: Exception) { + log.warn("Commands fetch failed: ${e.message}", e) + null + } + + private fun fetchSkills(api: DefaultApi, dir: String): List? = + try { + api.appSkills(directory = dir).map { s -> + SkillInfo( + name = s.name, + description = s.description, + location = s.location, + ) + }.also { skills = it } + } catch (e: Exception) { + log.warn("Skills fetch failed: ${e.message}", e) + null + } + + // ------ helpers ------ + + private fun mapAgent(a: Agent) = AgentInfo( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode.value, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + + private suspend fun fetchWithRetry( + name: String, + block: () -> T?, + ): T? { + repeat(MAX_RETRIES) { attempt -> + val result = block() + if (result != null) return result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } + } + log.error("$name: all $MAX_RETRIES attempts failed") + return null + } + + private class LoadFailure(resource: String) : Exception("Failed to load $resource") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt new file mode 100644 index 00000000000..d3718c1428d --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt @@ -0,0 +1,86 @@ +package ai.kilocode.backend.project + +/** + * Full project data lifecycle state, combining connection readiness + * with project-scoped data loading progress. + * + * Only populated after [KiloAppState.Ready][ai.kilocode.backend.app.KiloAppState.Ready] + * — the CLI server must be connected and global data loaded before + * project data can be fetched. + */ +sealed class KiloProjectState { + data object Pending : KiloProjectState() + data class Loading(val progress: KiloProjectLoadProgress) : KiloProjectState() + data class Ready( + val providers: ProviderData, + val agents: AgentData, + val commands: List, + val skills: List, + ) : KiloProjectState() + data class Error(val message: String) : KiloProjectState() +} + +/** + * Tracks which project data fetches have completed during + * the [KiloProjectState.Loading] phase. + */ +data class KiloProjectLoadProgress( + val providers: Boolean = false, + val agents: Boolean = false, + val commands: Boolean = false, + val skills: Boolean = false, +) + +data class ProviderData( + val providers: List, + val connected: List, + val defaults: Map, +) + +data class ProviderInfo( + val id: String, + val name: String, + val source: String?, + val models: Map, +) + +data class ModelInfo( + val id: String, + val name: String, + val attachment: Boolean, + val reasoning: Boolean, + val temperature: Boolean, + val toolCall: Boolean, + val free: Boolean, + val status: String?, +) + +data class AgentData( + val agents: List, + val all: List, + val default: String, +) + +data class AgentInfo( + val name: String, + val displayName: String?, + val description: String?, + val mode: String, + val native: Boolean?, + val hidden: Boolean?, + val color: String?, + val deprecated: Boolean?, +) + +data class CommandInfo( + val name: String, + val description: String?, + val source: String?, + val hints: List, +) + +data class SkillInfo( + val name: String, + val description: String, + val location: String, +) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt new file mode 100644 index 00000000000..c7ddaddbfe2 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt @@ -0,0 +1,145 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.project.AgentData +import ai.kilocode.backend.project.AgentInfo +import ai.kilocode.backend.project.CommandInfo +import ai.kilocode.backend.project.KiloBackendProjectService +import ai.kilocode.backend.project.KiloProjectLoadProgress +import ai.kilocode.backend.project.KiloProjectState +import ai.kilocode.backend.project.ModelInfo +import ai.kilocode.backend.project.ProviderData +import ai.kilocode.backend.project.ProviderInfo +import ai.kilocode.backend.project.SkillInfo +import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.KiloProjectLoadProgressDto +import ai.kilocode.rpc.dto.KiloProjectStateDto +import ai.kilocode.rpc.dto.KiloProjectStatusDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import ai.kilocode.rpc.dto.SkillDto +import com.intellij.openapi.components.service +import com.intellij.openapi.project.ProjectManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * Backend implementation of [KiloProjectRpcApi]. + * + * Looks up the correct [KiloBackendProjectService] by directory + * and maps its internal domain model to shared DTOs. + */ +class KiloProjectRpcApiImpl : KiloProjectRpcApi { + + override suspend fun state(directory: String): Flow { + val svc = find(directory) + ?: return flowOf(KiloProjectStateDto( + status = KiloProjectStatusDto.ERROR, + error = "Project not found for directory: $directory", + )) + return svc.state + .map(::dto) + .distinctUntilChanged() + } + + override suspend fun reload(directory: String) { + find(directory)?.reload() + } + + private fun find(directory: String): KiloBackendProjectService? { + val project = ProjectManager.getInstance().openProjects + .find { it.basePath == directory } + ?: return null + return project.service() + } + + // ------ mapping: domain model → DTO ------ + + private fun dto(state: KiloProjectState): KiloProjectStateDto = + when (state) { + KiloProjectState.Pending -> KiloProjectStateDto(KiloProjectStatusDto.PENDING) + is KiloProjectState.Loading -> KiloProjectStateDto( + status = KiloProjectStatusDto.LOADING, + progress = progress(state.progress), + ) + is KiloProjectState.Ready -> KiloProjectStateDto( + status = KiloProjectStatusDto.READY, + providers = providers(state.providers), + agents = agents(state.agents), + commands = state.commands.map(::command), + skills = state.skills.map(::skill), + ) + is KiloProjectState.Error -> KiloProjectStateDto( + status = KiloProjectStatusDto.ERROR, + error = state.message, + ) + } + + private fun progress(p: KiloProjectLoadProgress) = KiloProjectLoadProgressDto( + providers = p.providers, + agents = p.agents, + commands = p.commands, + skills = p.skills, + ) + + private fun providers(d: ProviderData) = ProvidersDto( + providers = d.providers.map(::provider), + connected = d.connected, + defaults = d.defaults, + ) + + private fun provider(p: ProviderInfo) = ProviderDto( + id = p.id, + name = p.name, + source = p.source, + models = p.models.mapValues { (_, m) -> model(m) }, + ) + + private fun model(m: ModelInfo) = ModelDto( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.free, + status = m.status, + ) + + private fun agents(d: AgentData) = AgentsDto( + agents = d.agents.map(::agent), + all = d.all.map(::agent), + default = d.default, + ) + + private fun agent(a: AgentInfo) = AgentDto( + name = a.name, + displayName = a.displayName, + description = a.description, + mode = a.mode, + native = a.native, + hidden = a.hidden, + color = a.color, + deprecated = a.deprecated, + ) + + private fun command(c: CommandInfo) = CommandDto( + name = c.name, + description = c.description, + source = c.source, + hints = c.hints, + ) + + private fun skill(s: SkillInfo) = SkillDto( + name = s.name, + description = s.description, + location = s.location, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt new file mode 100644 index 00000000000..ae30ed6887c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloProjectRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloProjectRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloProjectRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index a9d0c8f5e9b..0cf5923076c 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -7,5 +7,6 @@ + diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 4e291139a26..6f9ebb2f898 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -1,6 +1,6 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty -import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import java.io.File @@ -22,7 +22,7 @@ import java.io.File * Replaced with `kotlinx.serialization.json.JsonElement`. */ abstract class FixGeneratedApiTask : DefaultTask() { - @get:InputDirectory + @get:OutputDirectory abstract val generated: DirectoryProperty @TaskAction diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt new file mode 100644 index 00000000000..b67fa04ee0b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt @@ -0,0 +1,57 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client + +import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.dto.KiloProjectStateDto +import ai.kilocode.rpc.dto.KiloProjectStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Project-level frontend service that provides reactive access + * to project-scoped data (providers, agents, commands, skills). + * + * Communicates with the backend via [KiloProjectRpcApi]. The flow + * is collected eagerly so that data is available as soon as the + * backend finishes loading. + */ +@Service(Service.Level.PROJECT) +class KiloProjectService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private val LOG = Logger.getInstance(KiloProjectService::class.java) + private val init = KiloProjectStateDto(KiloProjectStatusDto.PENDING) + } + + private val directory: String get() = project.basePath ?: "" + + val state: StateFlow = flow { + durable { + KiloProjectRpcApi.getInstance() + .state(directory) + .collect { emit(it) } + } + }.stateIn(cs, SharingStarted.Eagerly, init) + + /** Trigger a full reload of all project data. */ + fun reload() { + cs.launch { + try { + durable { KiloProjectRpcApi.getInstance().reload(directory) } + } catch (e: Exception) { + LOG.warn("project data reload failed", e) + } + } + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt new file mode 100644 index 00000000000..c179d8cf1e9 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.KiloProjectStateDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * Project-level RPC API exposed from backend to frontend. + * + * Operations are scoped to a specific project directory. + * The CLI backend is app-scoped, but each call routes to the + * correct [KiloBackendProjectService] by directory lookup. + */ +@Rpc +interface KiloProjectRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloProjectRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + /** Observe project state loading progress. */ + suspend fun state(directory: String): Flow + + /** Trigger a full reload of project data. */ + suspend fun reload(directory: String) +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt new file mode 100644 index 00000000000..6c345eac1db --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/AgentDto.kt @@ -0,0 +1,22 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class AgentDto( + val name: String, + val displayName: String? = null, + val description: String? = null, + val mode: String, + val native: Boolean? = null, + val hidden: Boolean? = null, + val color: String? = null, + val deprecated: Boolean? = null, +) + +@Serializable +data class AgentsDto( + val agents: List, + val all: List, + val default: String, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt new file mode 100644 index 00000000000..367c0c4b89a --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt @@ -0,0 +1,11 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandDto( + val name: String, + val description: String? = null, + val source: String? = null, + val hints: List = emptyList(), +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt new file mode 100644 index 00000000000..5568bf9a742 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +enum class KiloProjectStatusDto { + PENDING, + LOADING, + READY, + ERROR, +} + +@Serializable +data class KiloProjectLoadProgressDto( + val providers: Boolean = false, + val agents: Boolean = false, + val commands: Boolean = false, + val skills: Boolean = false, +) + +@Serializable +data class KiloProjectStateDto( + val status: KiloProjectStatusDto, + val progress: KiloProjectLoadProgressDto? = null, + val providers: ProvidersDto? = null, + val agents: AgentsDto? = null, + val commands: List = emptyList(), + val skills: List = emptyList(), + val error: String? = null, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt new file mode 100644 index 00000000000..5223473de67 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ProviderDto.kt @@ -0,0 +1,30 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ModelDto( + val id: String, + val name: String, + val attachment: Boolean = false, + val reasoning: Boolean = false, + val temperature: Boolean = false, + val toolCall: Boolean = false, + val free: Boolean = false, + val status: String? = null, +) + +@Serializable +data class ProviderDto( + val id: String, + val name: String, + val source: String? = null, + val models: Map = emptyMap(), +) + +@Serializable +data class ProvidersDto( + val providers: List, + val connected: List, + val defaults: Map, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt new file mode 100644 index 00000000000..e5559393af5 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SkillDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SkillDto( + val name: String, + val description: String, + val location: String, +) From 702459f019437c1dba845d4a938bdf10663e7865 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 13:44:23 -0400 Subject: [PATCH 02/13] refactor(jetbrains): remove init{} from services, add project service tests Defer initialization from constructors to explicit methods per IntelliJ guidelines. KiloBackendAppService watcher moves into ensureWatcher() called from connect(). KiloBackendProjectService gets start() + a create() test factory with injectable deps. Adds 25 tests: 11 for project service lifecycle, error handling, and parallel load safety; 14 for project-level API model serialization. --- .../backend/app/KiloBackendAppService.kt | 9 +- .../project/KiloBackendProjectService.kt | 158 +++---- .../backend/rpc/KiloProjectRpcApiImpl.kt | 1 + .../backend/KiloBackendProjectServiceTest.kt | 393 ++++++++++++++++++ .../backend/ProjectModelSerializationTest.kt | 250 +++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 14 + 6 files changed, 753 insertions(+), 72 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt 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 93fcb6f104c..1aa1699b5ae 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 @@ -76,6 +76,7 @@ class KiloBackendAppService private constructor( cs.launch { reconnect() } }, log = log) + private var watcher: Job? = null private var router: Job? = null private var loader: Job? = null @@ -98,6 +99,7 @@ class KiloBackendAppService private constructor( mutex.withLock { val current = _appState.value if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return + ensureWatcher() connection.connect() } } @@ -135,8 +137,9 @@ class KiloBackendAppService private constructor( } } - init { - cs.launch { + private fun ensureWatcher() { + if (watcher?.isActive == true) return + watcher = cs.launch { connection.state.collect { next -> when (next) { ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected @@ -344,6 +347,8 @@ class KiloBackendAppService private constructor( } override fun dispose() { + watcher?.cancel() + watcher = null clear() connection.dispose() server.dispose() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt index 0f4233f5c46..5f84fe4822d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt @@ -24,38 +24,58 @@ import java.util.concurrent.atomic.AtomicReference * Project-level backend service that loads project-scoped data * from the CLI server when the app reaches [KiloAppState.Ready]. * - * Watches [KiloBackendAppService.appState] and triggers parallel - * data fetches for providers, agents, commands, and skills. - * Progress is tracked via [KiloProjectLoadProgress] and emitted as - * [KiloProjectState.Loading] → [KiloProjectState.Ready] or [KiloProjectState.Error]. + * Construction is side-effect-free — call [start] to begin + * watching [KiloBackendAppService.appState] and loading data. + * [start] is idempotent and safe to call multiple times. * * Each fetch is retried up to [MAX_RETRIES] times, matching the * pattern in [KiloBackendAppService]. */ @Service(Service.Level.PROJECT) -class KiloBackendProjectService( - private val project: Project, +class KiloBackendProjectService private constructor( + val directory: String, private val cs: CoroutineScope, + private val appState: () -> StateFlow, + private val api: () -> DefaultApi?, + private val log: KiloLog, ) { + /** IntelliJ service injection entry point. */ + constructor(project: Project, cs: CoroutineScope) : this( + directory = project.basePath ?: "", + cs = cs, + appState = { service().appState }, + api = { service().api }, + log = IntellijLog(KiloBackendProjectService::class.java), + ) + companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L + + /** Test factory — no IntelliJ deps needed. */ + internal fun create( + dir: String, + cs: CoroutineScope, + appState: () -> StateFlow, + api: () -> DefaultApi?, + log: KiloLog, + ) = KiloBackendProjectService(dir, cs, appState, api, log) } - /** Project working directory sent as the `directory` parameter. */ - val directory: String - get() = project.basePath ?: "" - - private val log: KiloLog = IntellijLog(KiloBackendProjectService::class.java) - private val _state = MutableStateFlow(KiloProjectState.Pending) val state: StateFlow = _state.asStateFlow() + private var watcher: Job? = null private var loader: Job? = null - init { - cs.launch { - service().appState.collect { state -> + /** + * Begin watching app state and loading project data when ready. + * Idempotent — safe to call multiple times. + */ + fun start() { + if (watcher?.isActive == true) return + watcher = cs.launch { + appState().collect { state -> when (state) { is KiloAppState.Ready -> load() is KiloAppState.Disconnected, @@ -81,16 +101,16 @@ class KiloBackendProjectService( /** * Launch all project data fetches in parallel. * - * Each resource is retried up to [MAX_RETRIES] times. - * Progress is tracked via [AtomicReference] and emitted - * as [KiloProjectState.Loading]. + * Cancels any in-flight load first. Each resource is retried + * up to [MAX_RETRIES] times. Progress is tracked via + * [AtomicReference] and emitted as [KiloProjectState.Loading]. */ private fun load() { loader?.cancel() loader = cs.launch { val dir = directory - val api = service().api - if (api == null) { + val client = api() + if (client == null) { _state.value = KiloProjectState.Error("CLI server not connected") return@launch } @@ -99,13 +119,18 @@ class KiloBackendProjectService( val progress = AtomicReference(KiloProjectLoadProgress()) _state.value = KiloProjectState.Loading(progress.get()) + var prov: ProviderData? = null + var ag: AgentData? = null + var cmd: List? = null + var sk: List? = null val errors = mutableListOf() try { coroutineScope { launch { - val result = fetchWithRetry("providers") { fetchProviders(api, dir) } + val result = fetchWithRetry("providers") { fetchProviders(client, dir) } if (result != null) { + prov = result progress.updateAndGet { it.copy(providers = true) } .also { _state.value = KiloProjectState.Loading(it) } } else { @@ -114,8 +139,9 @@ class KiloBackendProjectService( } } launch { - val result = fetchWithRetry("agents") { fetchAgents(api, dir) } + val result = fetchWithRetry("agents") { fetchAgents(client, dir) } if (result != null) { + ag = result progress.updateAndGet { it.copy(agents = true) } .also { _state.value = KiloProjectState.Loading(it) } } else { @@ -124,8 +150,9 @@ class KiloBackendProjectService( } } launch { - val result = fetchWithRetry("commands") { fetchCommands(api, dir) } + val result = fetchWithRetry("commands") { fetchCommands(client, dir) } if (result != null) { + cmd = result progress.updateAndGet { it.copy(commands = true) } .also { _state.value = KiloProjectState.Loading(it) } } else { @@ -134,8 +161,9 @@ class KiloBackendProjectService( } } launch { - val result = fetchWithRetry("skills") { fetchSkills(api, dir) } + val result = fetchWithRetry("skills") { fetchSkills(client, dir) } if (result != null) { + sk = result progress.updateAndGet { it.copy(skills = true) } .also { _state.value = KiloProjectState.Loading(it) } } else { @@ -145,12 +173,11 @@ class KiloBackendProjectService( } } - // All succeeded — assemble Ready state _state.value = KiloProjectState.Ready( - providers = providers!!, - agents = agents!!, - commands = commands!!, - skills = skills!!, + providers = prov!!, + agents = ag!!, + commands = cmd!!, + skills = sk!!, ) log.info("Project data loaded for $dir") } catch (e: CancellationException) { @@ -164,87 +191,78 @@ class KiloBackendProjectService( } } - // ------ cached results from each fetch ------ - - @Volatile private var providers: ProviderData? = null - @Volatile private var agents: AgentData? = null - @Volatile private var commands: List? = null - @Volatile private var skills: List? = null - // ------ individual fetch methods ------ - private fun fetchProviders(api: DefaultApi, dir: String): ProviderData? = + private fun fetchProviders(client: DefaultApi, dir: String): ProviderData? = try { - val response = api.providerList(directory = dir) - val mapped = response.all.map { p -> - ProviderInfo( - id = p.id, - name = p.name, - source = p.api, - models = p.models.mapValues { (_, m) -> - ModelInfo( - id = m.id, - name = m.name, - attachment = m.attachment, - reasoning = m.reasoning, - temperature = m.temperature, - toolCall = m.toolCall, - free = m.isFree ?: false, - status = m.status?.value, - ) - }, - ) - } + val response = client.providerList(directory = dir) ProviderData( - providers = mapped, + providers = response.all.map { p -> + ProviderInfo( + id = p.id, + name = p.name, + source = p.api, + models = p.models.mapValues { (_, m) -> + ModelInfo( + id = m.id, + name = m.name, + attachment = m.attachment, + reasoning = m.reasoning, + temperature = m.temperature, + toolCall = m.toolCall, + free = m.isFree ?: false, + status = m.status?.value, + ) + }, + ) + }, connected = response.connected, defaults = response.default, - ).also { providers = it } + ) } catch (e: Exception) { log.warn("Providers fetch failed: ${e.message}", e) null } - private fun fetchAgents(api: DefaultApi, dir: String): AgentData? = + private fun fetchAgents(client: DefaultApi, dir: String): AgentData? = try { - val response = api.appAgents(directory = dir) + val response = client.appAgents(directory = dir) val mapped = response.map(::mapAgent) val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } - val default = visible.firstOrNull()?.name ?: "code" AgentData( agents = visible.map(::mapAgent), all = mapped, - default = default, - ).also { agents = it } + default = visible.firstOrNull()?.name ?: "code", + ) } catch (e: Exception) { log.warn("Agents fetch failed: ${e.message}", e) null } - private fun fetchCommands(api: DefaultApi, dir: String): List? = + private fun fetchCommands(client: DefaultApi, dir: String): List? = try { - api.commandList(directory = dir).map { c -> + client.commandList(directory = dir).map { c -> CommandInfo( name = c.name, description = c.description, source = c.source?.value, hints = c.hints, ) - }.also { commands = it } + } } catch (e: Exception) { log.warn("Commands fetch failed: ${e.message}", e) null } - private fun fetchSkills(api: DefaultApi, dir: String): List? = + private fun fetchSkills(client: DefaultApi, dir: String): List? = try { - api.appSkills(directory = dir).map { s -> + client.appSkills(directory = dir).map { s -> SkillInfo( name = s.name, description = s.description, location = s.location, ) - }.also { skills = it } + } } catch (e: Exception) { log.warn("Skills fetch failed: ${e.message}", e) null diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt index c7ddaddbfe2..eff162c319f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt @@ -44,6 +44,7 @@ class KiloProjectRpcApiImpl : KiloProjectRpcApi { status = KiloProjectStatusDto.ERROR, error = "Project not found for directory: $directory", )) + svc.start() return svc.state .map(::dto) .distinctUntilChanged() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt new file mode 100644 index 00000000000..206a79fe9f4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt @@ -0,0 +1,393 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.project.KiloBackendProjectService +import ai.kilocode.backend.project.KiloProjectState +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendProjectServiceTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + private fun setup(): Pair { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log) + val project = KiloBackendProjectService.create( + dir = "/test/project", + cs = scope, + appState = { app.appState }, + api = { app.api }, + log = log, + ) + return app to project + } + + // ------ Lifecycle ------ + + @Test + fun `full lifecycle reaches Ready`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + val ready = project.state.value as KiloProjectState.Ready + assertEquals(1, ready.providers.providers.size) + assertEquals("anthropic", ready.providers.providers[0].id) + assertEquals(listOf("anthropic"), ready.providers.connected) + assertEquals(1, ready.agents.agents.size) + assertEquals("code", ready.agents.default) + assertEquals(1, ready.commands.size) + assertEquals("clear", ready.commands[0].name) + assertEquals(1, ready.skills.size) + assertEquals("test-skill", ready.skills[0].name) + } + + @Test + fun `stays Pending when app is not Ready`() = runBlocking { + val (_, project) = setup() + project.start() + + // Give some time for any unexpected transitions + delay(500) + assertIs(project.state.value) + } + + @Test + fun `transitions to Pending when app disconnects`() = runBlocking { + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + app.dispose() + + withTimeout(5_000) { + project.state.first { it is KiloProjectState.Pending } + } + assertIs(project.state.value) + } + + @Test + fun `loading tracks progress through Loading state`() = runBlocking { + val (app, project) = setup() + val states = mutableListOf() + + val collector = scope.launch { + project.state.collect { states.add(it) } + } + + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + collector.cancel() + + assertTrue(states.any { it is KiloProjectState.Loading }) + assertTrue(states.any { it is KiloProjectState.Ready }) + } + + // ------ Error handling ------ + + @Test + fun `providers failure retries then transitions to Error`() = runBlocking { + mock.providersStatus = 500 + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Error } + } + + val err = project.state.value as KiloProjectState.Error + assertTrue(err.message.contains("providers")) + } + + @Test + fun `agents failure retries then transitions to Error`() = runBlocking { + mock.agentsStatus = 500 + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Error } + } + + val err = project.state.value as KiloProjectState.Error + assertTrue(err.message.contains("agents")) + } + + @Test + fun `commands failure transitions to Error`() = runBlocking { + mock.commandsStatus = 500 + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Error } + } + + val err = project.state.value as KiloProjectState.Error + assertTrue(err.message.contains("commands")) + } + + @Test + fun `skills failure transitions to Error`() = runBlocking { + mock.skillsStatus = 500 + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Error } + } + + val err = project.state.value as KiloProjectState.Error + assertTrue(err.message.contains("skills")) + } + + @Test + fun `partial failure reports all failed resources`() = runBlocking { + mock.providersStatus = 500 + mock.skillsStatus = 500 + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Error } + } + + val err = project.state.value as KiloProjectState.Error + // At least one of the failed resources should be mentioned + assertTrue(err.message.contains("providers") || err.message.contains("skills")) + } + + // ------ Parallel/competing initialization ------ + + @Test + fun `start is idempotent`() = runBlocking { + val (app, project) = setup() + project.start() + project.start() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + assertIs(project.state.value) + } + + @Test + fun `reload during load produces valid final state`() = runBlocking { + val (app, project) = setup() + project.start() + app.connect() + + // Wait for app to be ready so project load begins + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + + // Trigger overlapping reloads + project.reload() + project.reload() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + // The final state should be a valid Ready, not corrupted + assertIs(project.state.value) + } + + @Test + fun `rapid app state changes settle to correct state`() = runBlocking { + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + // Simulate rapid disconnect/reconnect by restarting + app.restart() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + assertIs(project.state.value) + } + + // ------ Data mapping ------ + + @Test + fun `providers response maps models correctly`() = runBlocking { + mock.providers = PROVIDERS_JSON + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + val ready = project.state.value as KiloProjectState.Ready + val provider = ready.providers.providers[0] + assertEquals("anthropic", provider.id) + assertEquals("Anthropic", provider.name) + val model = provider.models["claude-4"] + assertNotNull(model) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.toolCall) + } + + @Test + fun `agents response filters hidden and subagent`() = runBlocking { + mock.agents = """[ + {"name":"code","mode":"primary","permission":[],"options":{}}, + {"name":"helper","mode":"subagent","permission":[],"options":{}}, + {"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}} + ]""" + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + val ready = project.state.value as KiloProjectState.Ready + // Only "code" is visible (non-subagent, non-hidden) + assertEquals(1, ready.agents.agents.size) + assertEquals("code", ready.agents.agents[0].name) + // All 3 in the full list + assertEquals(3, ready.agents.all.size) + assertEquals("code", ready.agents.default) + } + + @Test + fun `commands response maps source`() = runBlocking { + mock.commands = """[ + {"name":"clear","template":"","hints":[],"source":"command"}, + {"name":"mcp-tool","template":"","hints":["tool"],"source":"mcp"} + ]""" + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + val ready = project.state.value as KiloProjectState.Ready + assertEquals(2, ready.commands.size) + assertEquals("command", ready.commands[0].source) + assertEquals("mcp", ready.commands[1].source) + assertEquals(listOf("tool"), ready.commands[1].hints) + } + + @Test + fun `empty responses produce empty Ready`() = runBlocking { + // defaults are already empty + val (app, project) = setup() + project.start() + app.connect() + + withTimeout(15_000) { + project.state.first { it is KiloProjectState.Ready } + } + + val ready = project.state.value as KiloProjectState.Ready + assertTrue(ready.providers.providers.isEmpty()) + assertTrue(ready.agents.all.isEmpty()) + assertTrue(ready.commands.isEmpty()) + assertTrue(ready.skills.isEmpty()) + assertEquals("code", ready.agents.default) // fallback + } + + companion object { + private val PROVIDERS_JSON = """{ + "all": [{ + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "release_date": "2025-05-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 200000, "output": 16000}, + "options": {} + } + } + }], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""".trimIndent() + + private val AGENTS_JSON = """[ + {"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}} + ]""".trimIndent() + + private val COMMANDS_JSON = """[ + {"name":"clear","description":"Clear conversation","template":"","hints":[],"source":"command"} + ]""".trimIndent() + + private val SKILLS_JSON = """[ + {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} + ]""".trimIndent() + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt new file mode 100644 index 00000000000..99fc7894a1c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt @@ -0,0 +1,250 @@ +package ai.kilocode.backend + +import ai.kilocode.jetbrains.api.infrastructure.Serializer +import ai.kilocode.jetbrains.api.model.Agent +import ai.kilocode.jetbrains.api.model.AppSkills200ResponseInner +import ai.kilocode.jetbrains.api.model.Command +import ai.kilocode.jetbrains.api.model.ProviderList200Response +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies that project-level API model classes serialize/deserialize + * correctly using the production [Serializer.kotlinxSerializationJson]. + */ +class ProjectModelSerializationTest { + + private val json = Serializer.kotlinxSerializationJson + + // ------ ProviderList200Response ------ + + @Test + fun `empty provider list`() { + val src = """{"all":[],"default":{},"connected":[]}""" + val obj = json.decodeFromString(src) + assertTrue(obj.all.isEmpty()) + assertTrue(obj.default.isEmpty()) + assertTrue(obj.connected.isEmpty()) + } + + @Test + fun `provider list with models`() { + val src = """{ + "all": [{ + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "release_date": "2025-05-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 200000, "output": 16000}, + "options": {} + } + } + }], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""" + val obj = json.decodeFromString(src) + assertEquals(1, obj.all.size) + assertEquals("anthropic", obj.all[0].id) + assertEquals("Anthropic", obj.all[0].name) + val model = obj.all[0].models["claude-4"] + assertNotNull(model) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.toolCall) + assertEquals("anthropic/claude-4", obj.default["code"]) + assertEquals(listOf("anthropic"), obj.connected) + } + + @Test + fun `provider list with isFree and status`() { + val src = """{ + "all": [{ + "id": "free-provider", + "name": "Free", + "env": [], + "models": { + "free-model": { + "id": "free-model", + "name": "Free Model", + "release_date": "2025-01-01", + "attachment": false, + "reasoning": false, + "temperature": false, + "tool_call": false, + "isFree": true, + "status": "beta", + "limit": {"context": 8000, "output": 4000}, + "options": {} + } + } + }], + "default": {}, + "connected": [] + }""" + val obj = json.decodeFromString(src) + val model = obj.all[0].models["free-model"]!! + assertEquals(true, model.isFree) + assertEquals( + ai.kilocode.jetbrains.api.model.ProviderList200ResponseAllInnerModelsValue.Status.BETA, + model.status, + ) + } + + @Test + fun `provider list ignores unknown fields`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "future_field": "value" + }""" + val obj = json.decodeFromString(src) + assertTrue(obj.all.isEmpty()) + } + + // ------ Agent ------ + + @Test + fun `agent with all modes`() { + for (mode in listOf("primary", "subagent", "all")) { + val src = """{"name":"test","mode":"$mode","permission":[],"options":{}}""" + val obj = json.decodeFromString(src) + assertEquals("test", obj.name) + assertEquals(mode, obj.mode.value) + } + } + + @Test + fun `agent with optional fields`() { + val src = """{ + "name": "code", + "displayName": "Code Agent", + "description": "Writes code", + "mode": "primary", + "native": true, + "hidden": false, + "color": "#FF5733", + "deprecated": false, + "permission": [], + "options": {}, + "steps": 5 + }""" + val obj = json.decodeFromString(src) + assertEquals("Code Agent", obj.displayName) + assertEquals("Writes code", obj.description) + assertEquals(true, obj.native) + assertEquals(false, obj.hidden) + assertEquals("#FF5733", obj.color) + assertEquals(5, obj.steps) + } + + @Test + fun `agent minimal`() { + val src = """{"name":"ask","mode":"primary","permission":[],"options":{}}""" + val obj = json.decodeFromString(src) + assertEquals("ask", obj.name) + assertNull(obj.displayName) + assertNull(obj.hidden) + } + + @Test + fun `agent list`() { + val src = """[ + {"name":"code","mode":"primary","permission":[],"options":{}}, + {"name":"helper","mode":"subagent","hidden":true,"permission":[],"options":{}} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertEquals(Agent.Mode.PRIMARY, list[0].mode) + assertEquals(Agent.Mode.SUBAGENT, list[1].mode) + assertEquals(true, list[1].hidden) + } + + // ------ Command ------ + + @Test + fun `command with source enum`() { + for ((src, expected) in listOf( + "command" to Command.Source.COMMAND, + "mcp" to Command.Source.MCP, + "skill" to Command.Source.SKILL, + )) { + val json = """{"name":"test","template":"","hints":[],"source":"$src"}""" + val obj = this.json.decodeFromString(json) + assertEquals(expected, obj.source) + } + } + + @Test + fun `command with hints`() { + val src = """{ + "name": "clear", + "description": "Clear the conversation", + "template": "", + "hints": ["conversation", "reset"], + "source": "command" + }""" + val obj = json.decodeFromString(src) + assertEquals("clear", obj.name) + assertEquals("Clear the conversation", obj.description) + assertEquals(listOf("conversation", "reset"), obj.hints) + } + + @Test + fun `command list`() { + val src = """[ + {"name":"clear","template":"","hints":[]}, + {"name":"compact","description":"Compact context","template":"","hints":["context"],"source":"command"} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertNull(list[0].source) + assertEquals(Command.Source.COMMAND, list[1].source) + } + + // ------ AppSkills200ResponseInner ------ + + @Test + fun `skill roundtrip`() { + val src = """{ + "name": "vscode-visual-regression", + "description": "Write visual regression tests", + "location": "file:///path/to/SKILL.md", + "content": "# Skill content" + }""" + val obj = json.decodeFromString(src) + assertEquals("vscode-visual-regression", obj.name) + assertEquals("Write visual regression tests", obj.description) + assertEquals("file:///path/to/SKILL.md", obj.location) + assertEquals("# Skill content", obj.content) + } + + @Test + fun `skill list`() { + val src = """[ + {"name":"s1","description":"d1","location":"l1","content":"c1"}, + {"name":"s2","description":"d2","location":"l2","content":"c2"} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + } + + @Test + fun `empty skill list`() { + val list = json.decodeFromString>("[]") + assertTrue(list.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 45f1c1e9a44..198d4e78a49 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -34,6 +34,16 @@ class MockCliServer : AutoCloseable { @Volatile var configStatus = 200 @Volatile var notificationsStatus = 200 + // Project-scoped REST responses + @Volatile var providers = """{"all":[],"default":{},"connected":[]}""" + @Volatile var agents = "[]" + @Volatile var commands = "[]" + @Volatile var skills = "[]" + @Volatile var providersStatus = 200 + @Volatile var agentsStatus = 200 + @Volatile var commandsStatus = 200 + @Volatile var skillsStatus = 200 + private val executor = Executors.newCachedThreadPool { r -> Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true } } @@ -150,6 +160,10 @@ class MockCliServer : AutoCloseable { } } path == "/global/event" -> handleSse(output) + path == "/provider" || path.startsWith("/provider?") -> respond(output, providersStatus, providers) + path == "/agent" || path.startsWith("/agent?") -> respond(output, agentsStatus, agents) + path == "/command" || path.startsWith("/command?") -> respond(output, commandsStatus, commands) + path == "/skill" || path.startsWith("/skill?") -> respond(output, skillsStatus, skills) else -> respond(output, 404, """{"error":"Not found"}""") } } catch (_: SocketException) { From c4c9f94aea555bca58308b448d35d0fe8d006e30 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 15:21:50 -0400 Subject: [PATCH 03/13] feat(jetbrains): add session management with app service lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add KiloBackendSessionManager owned by KiloBackendAppService — started after Ready with a pre-connected API client, stopped on disconnect. Includes session CRUD, live SSE status tracking, worktree directory overrides, and KiloSessionRpcApi for frontend consumption. Also adds SSE event watching to both app and project services with per-event logging, and improves startup log messages. --- .../backend/app/KiloBackendAppService.kt | 75 +++-- .../backend/app/KiloBackendSessionManager.kt | 186 ++++++++++++ .../project/KiloBackendProjectService.kt | 41 ++- .../backend/rpc/KiloSessionRpcApiImpl.kt | 46 +++ .../backend/rpc/KiloSessionRpcApiProvider.kt | 15 + .../main/resources/kilo.jetbrains.backend.xml | 1 + .../backend/KiloBackendSessionManagerTest.kt | 273 ++++++++++++++++++ .../backend/SessionModelSerializationTest.kt | 165 +++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 27 +- .../ai/kilocode/client/KiloSessionService.kt | 115 ++++++++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 48 +++ .../kotlin/ai/kilocode/rpc/dto/SessionDto.kt | 41 +++ 12 files changed, 1010 insertions(+), 23 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt 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 1aa1699b5ae..fea9c9d4a00 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 @@ -77,7 +77,7 @@ class KiloBackendAppService private constructor( }, log = log) private var watcher: Job? = null - private var router: Job? = null + private var eventWatcher: Job? = null private var loader: Job? = null private val _appState = MutableStateFlow(KiloAppState.Disconnected) @@ -86,6 +86,8 @@ class KiloBackendAppService private constructor( val events: SharedFlow get() = connection.events val api: DefaultApi? get() = connection.api + val sessions = KiloBackendSessionManager(cs, log) + @Volatile var profile: KiloProfile200Response? = null private set @@ -164,7 +166,7 @@ class KiloBackendAppService private constructor( private fun load() { loader?.cancel() loader = cs.launch { - log.info("Loading global data") + log.info("Application starting — loading config, profile, notifications") val progress = AtomicReference(LoadProgress()) _appState.value = KiloAppState.Loading(progress.get()) @@ -223,12 +225,13 @@ class KiloBackendAppService private constructor( notifications = notifications, ) ) - log.info("Global data loaded — app is Ready") - ensureRouter() + log.info("Application started — config, profile, notifications loaded") + sessions.start(connection.api!!, connection.events) + startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e } catch (e: Exception) { - log.warn("Global data load failed: ${e.message}") + log.warn("Application start failed: ${e.message}") _appState.value = KiloAppState.Error( message = "Failed to load required data", errors = synchronized(errors) { errors.toList() }, @@ -315,31 +318,67 @@ class KiloBackendAppService private constructor( return last } - private fun ensureRouter() { - if (router?.isActive == true) return - router = cs.launch { + /** + * Watch global SSE events to keep app state in sync with the CLI server. + * + * - `global.config.updated` — the project config changed on disk or via CLI. + * Re-fetches config and updates [KiloAppState.Ready] data in-place. + * + * - `global.disposed` — the CLI server's global context was torn down + * (e.g. during a restart). Triggers a full reload to re-populate all data. + * + * - `server.instance.disposed` — a specific server instance was disposed. + * Same effect as `global.disposed` — triggers a full reload so downstream + * project services pick up the new state. + * + * Idempotent — only one watcher runs at a time. + */ + private fun startWatchingGlobalSseEvents() { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events (config.updated, disposed)") + eventWatcher = cs.launch { connection.events.collect { event -> when (event.type) { - "global.config.updated" -> launch { - val result = fetchConfig() - if (result.value != null) { - config = result.value - val current = _appState.value - if (current is KiloAppState.Ready) { - _appState.value = current.copy( - data = current.data.copy(config = result.value) - ) + // Config changed on disk or via CLI — re-fetch and update in-place + "global.config.updated" -> { + log.info("SSE global.config.updated — reloading config") + launch { + val result = fetchConfig() + if (result.value != null) { + config = result.value + val current = _appState.value + if (current is KiloAppState.Ready) { + _appState.value = current.copy( + data = current.data.copy(config = result.value) + ) + } + log.info("Config reloaded successfully") } } } + // CLI server context torn down — full reload needed. + // Project services watch appState and will reload when + // the app transitions back to Ready. + "global.disposed" -> { + log.info("SSE global.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } + // Server instance disposed — same effect as global.disposed + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } } } } } private fun clear() { + sessions.stop() loader?.cancel() - router?.cancel() + eventWatcher?.cancel() profile = null config = null notifications = emptyList() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt new file mode 100644 index 00000000000..485811147c6 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -0,0 +1,186 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.SessionStatus +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.SessionSummaryDto +import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Session gateway that handles session CRUD and live status tracking + * across all directories (workspace roots and worktrees). + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after the CLI server reaches [KiloAppState.Ready] and + * [stop] on disconnect. The API client is guaranteed non-null between + * start/stop — no defensive null checks in CRUD methods. + * + * SSE `session.status` events are consumed directly from the events + * flow passed to [start], keeping the live [statuses] map current. + */ +class KiloBackendSessionManager( + private val cs: CoroutineScope, + private val log: KiloLog, +) { + companion object { + private val FIELD_RE = ConcurrentHashMap() + + /** Extract a top-level string field from JSON without a full parser. */ + internal fun extractField(json: String, field: String): String? { + val re = FIELD_RE.getOrPut(field) { + Regex(""""$field"\s*:\s*"([^"]+)"""") + } + return re.find(json)?.groupValues?.get(1) + } + + /** Extract a string field nested one level deep. */ + internal fun extractNested(json: String, outer: String, inner: String): String? { + val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") + .find(json)?.groupValues?.get(1) ?: return null + return extractField("{$block}", inner) + } + } + + /** Per-session directory overrides (sessionId → worktree path). */ + private val directories = ConcurrentHashMap() + + private val _statuses = MutableStateFlow>(emptyMap()) + val statuses: StateFlow> = _statuses.asStateFlow() + + private var client: DefaultApi? = null + private var watcher: Job? = null + + /** + * Activate the session manager with a connected API client and SSE stream. + * Called by [KiloBackendAppService] after [KiloAppState.Ready]. + */ + fun start(api: DefaultApi, events: SharedFlow) { + client = api + if (watcher?.isActive == true) return + watcher = cs.launch { + events.collect { event -> + if (event.type == "session.status") { + handleStatus(event.data) + } + } + } + log.info("Session manager started") + } + + /** + * Deactivate the session manager. Called by [KiloBackendAppService] on disconnect. + */ + fun stop() { + watcher?.cancel() + watcher = null + client = null + _statuses.value = emptyMap() + log.info("Session manager stopped") + } + + private fun requireClient(): DefaultApi = + client ?: throw IllegalStateException("Session manager not started") + + // ------ session CRUD ------ + + /** List root sessions for a directory and include current statuses. */ + fun list(dir: String): SessionListDto { + val raw = requireClient().sessionList(directory = dir, roots = true) + val mapped = raw.map(::dto) + val ids = mapped.map { it.id }.toSet() + val relevant = _statuses.value.filterKeys { it in ids } + return SessionListDto(mapped, relevant) + } + + /** Create a new session in the given directory. */ + fun create(dir: String): SessionDto = + dto(requireClient().sessionCreate(directory = dir)) + + /** + * Get a single session by ID. + * + * Uses the session list endpoint and filters by ID since + * [DefaultApi] does not expose the single-session GET. + */ + fun get(id: String, dir: String): SessionDto { + val all = requireClient().sessionList(directory = dir) + val raw = all.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Session $id not found") + return dto(raw) + } + + /** Delete a session. */ + fun delete(id: String, dir: String) { + requireClient().sessionDelete(sessionID = id, directory = dir) + directories.remove(id) + } + + /** Seed status map from the server for a specific directory. */ + fun seed(dir: String) { + try { + val raw = requireClient().sessionStatus(directory = dir) + val mapped = raw.mapValues { (_, v) -> statusDto(v) } + _statuses.value = _statuses.value + mapped + log.info("Seeded ${mapped.size} session statuses for $dir") + } catch (e: Exception) { + log.warn("Session status seed failed: ${e.message}", e) + } + } + + // ------ worktree directory management ------ + + fun setDirectory(id: String, dir: String) { + directories[id] = dir + } + + fun getDirectory(id: String, fallback: String): String = + directories[id] ?: fallback + + // ------ SSE event handling ------ + + private fun handleStatus(data: String) { + val id = extractField(data, "sessionID") ?: return + val type = extractNested(data, "status", "type") ?: "idle" + val msg = extractNested(data, "status", "message") + _statuses.value = _statuses.value + (id to SessionStatusDto(type, msg)) + } + + // ------ mapping ------ + + private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto( + id = s.id, + projectID = s.projectID, + directory = s.directory, + parentID = s.parentID, + title = s.title, + version = s.version, + time = SessionTimeDto( + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + ), + summary = s.summary?.let { + SessionSummaryDto( + additions = it.additions.toInt(), + deletions = it.deletions.toInt(), + files = it.files.toInt(), + ) + }, + ) + + private fun statusDto(s: SessionStatus) = SessionStatusDto( + type = s.type.value, + message = s.message.ifBlank { null }, + ) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt index 5f84fe4822d..89bdba83dd7 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.project import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.SseEvent import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi @@ -15,6 +16,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -37,6 +39,7 @@ class KiloBackendProjectService private constructor( private val cs: CoroutineScope, private val appState: () -> StateFlow, private val api: () -> DefaultApi?, + private val events: () -> SharedFlow, private val log: KiloLog, ) { /** IntelliJ service injection entry point. */ @@ -45,6 +48,7 @@ class KiloBackendProjectService private constructor( cs = cs, appState = { service().appState }, api = { service().api }, + events = { service().events }, log = IntellijLog(KiloBackendProjectService::class.java), ) @@ -59,7 +63,8 @@ class KiloBackendProjectService private constructor( appState: () -> StateFlow, api: () -> DefaultApi?, log: KiloLog, - ) = KiloBackendProjectService(dir, cs, appState, api, log) + events: () -> SharedFlow = { MutableStateFlow(SseEvent("", "")) }, + ) = KiloBackendProjectService(dir, cs, appState, api, events, log) } private val _state = MutableStateFlow(KiloProjectState.Pending) @@ -67,6 +72,7 @@ class KiloBackendProjectService private constructor( private var watcher: Job? = null private var loader: Job? = null + private var eventWatcher: Job? = null /** * Begin watching app state and loading project data when ready. @@ -180,6 +186,7 @@ class KiloBackendProjectService private constructor( skills = sk!!, ) log.info("Project data loaded for $dir") + startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -191,6 +198,38 @@ class KiloBackendProjectService private constructor( } } + /** + * Watch global SSE events that invalidate project-scoped data. + * + * - `global.disposed` — the CLI server's global context was torn down. + * All cached providers, agents, commands, and skills are stale — reload. + * + * - `server.instance.disposed` — a specific server instance was disposed. + * Same effect — reload project data to pick up the new state. + * + * Idempotent — only one watcher runs at a time. + */ + private fun startWatchingGlobalSseEvents() { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events for project $directory") + eventWatcher = cs.launch { + events().collect { event -> + when (event.type) { + // CLI server context torn down — all project data is stale + "global.disposed" -> { + log.info("SSE global.disposed — reloading project data for $directory") + load() + } + // Server instance disposed — same effect, reload project data + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — reloading project data for $directory") + load() + } + } + } + } + } + // ------ individual fetch methods ------ private fun fetchProviders(client: DefaultApi, dir: String): ProviderData? = diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt new file mode 100644 index 00000000000..68ebf36bce6 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -0,0 +1,46 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.components.service +import kotlinx.coroutines.flow.Flow + +/** + * Backend implementation of [KiloSessionRpcApi]. + * + * Delegates to [KiloBackendSessionManager] obtained from + * [KiloBackendAppService.sessions]. The manager is guaranteed + * to be started when the app is in [KiloAppState.Ready]. + */ +class KiloSessionRpcApiImpl : KiloSessionRpcApi { + + private val manager: KiloBackendSessionManager + get() = service().sessions + + override suspend fun list(directory: String): SessionListDto = + manager.list(directory) + + override suspend fun create(directory: String): SessionDto = + manager.create(directory) + + override suspend fun get(id: String, directory: String): SessionDto = + manager.get(id, directory) + + override suspend fun delete(id: String, directory: String) = + manager.delete(id, directory) + + override suspend fun statuses(): Flow> = + manager.statuses + + override suspend fun setDirectory(id: String, directory: String) = + manager.setDirectory(id, directory) + + override suspend fun getDirectory(id: String, fallback: String): String = + manager.getDirectory(id, fallback) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt new file mode 100644 index 00000000000..404162cf088 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiProvider.kt @@ -0,0 +1,15 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.KiloSessionRpcApi +import com.intellij.platform.rpc.backend.RemoteApiProvider +import fleet.rpc.remoteApiDescriptor + +internal class KiloSessionRpcApiProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + KiloSessionRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index 0cf5923076c..1a88a88350a 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -8,5 +8,6 @@ + diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt new file mode 100644 index 00000000000..62884026948 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt @@ -0,0 +1,273 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendSessionManagerTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + private fun setup(): KiloBackendAppService { + return KiloBackendAppService.create(scope, FakeCliServer(mock), log) + } + + private suspend fun ready(app: KiloBackendAppService) { + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + } + + // ------ Lifecycle ------ + + @Test + fun `session manager starts when app reaches Ready`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/d","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + val app = setup() + ready(app) + + // Manager was started by app service — CRUD works without manual start() + val result = app.sessions.list("/d") + assertEquals(1, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + } + + @Test + fun `session manager throws when not started`() = runBlocking { + val app = setup() + // Don't connect — manager is not started + + assertFailsWith { + app.sessions.list("/test") + } + } + + @Test + fun `session manager stops on app disconnect`() = runBlocking { + val app = setup() + ready(app) + + // Verify it works + app.sessions.list("/test") + + // Dispose triggers clear() which calls sessions.stop() + app.dispose() + + assertFailsWith { + app.sessions.list("/test") + } + } + + // ------ Session list ------ + + @Test + fun `list returns sessions from server`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/test","title":"Session 1","version":"1","time":{"created":1000,"updated":2000}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/test","title":"Session 2","version":"1","time":{"created":3000,"updated":4000}} + ]""" + val app = setup() + ready(app) + + val result = app.sessions.list("/test") + assertEquals(2, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + assertEquals("Session 1", result.sessions[0].title) + assertEquals(1000.0, result.sessions[0].time.created) + assertEquals("ses_2", result.sessions[1].id) + } + + @Test + fun `list includes session statuses`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/d","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + mock.sessionStatuses = """{"ses_1":{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}}""" + val app = setup() + ready(app) + + app.sessions.seed("/d") + val result = app.sessions.list("/d") + assertEquals(1, result.sessions.size) + assertEquals("busy", result.statuses["ses_1"]?.type) + } + + // ------ Session create ------ + + @Test + fun `create returns new session`() = runBlocking { + mock.sessionCreate = """{ + "id": "ses_new", + "slug": "new", + "projectID": "prj_test", + "directory": "/test", + "title": "New Session", + "version": "1.0.0", + "time": {"created": 5000, "updated": 5000} + }""" + val app = setup() + ready(app) + + val session = app.sessions.create("/test") + assertEquals("ses_new", session.id) + assertEquals("New Session", session.title) + assertEquals("/test", session.directory) + } + + // ------ Session delete ------ + + @Test + fun `delete removes directory override`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.setDirectory("ses_1", "/worktree/path") + assertEquals("/worktree/path", app.sessions.getDirectory("ses_1", "/default")) + + app.sessions.delete("ses_1", "/test") + assertEquals("/default", app.sessions.getDirectory("ses_1", "/default")) + } + + // ------ Worktree directory management ------ + + @Test + fun `directory override returns worktree path`() = runBlocking { + val app = setup() + app.sessions.setDirectory("ses_abc", "/worktree/feature") + assertEquals("/worktree/feature", app.sessions.getDirectory("ses_abc", "/workspace")) + } + + @Test + fun `directory without override returns fallback`() = runBlocking { + val app = setup() + assertEquals("/workspace", app.sessions.getDirectory("ses_unknown", "/workspace")) + } + + // ------ SSE status tracking ------ + + @Test + fun `SSE session status events update status map`() = runBlocking { + val app = setup() + ready(app) + + mock.awaitSseConnection() + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_live","status":{"type":"busy","attempt":0,"message":"processing","next":0,"requestID":""}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it.containsKey("ses_live") } + } + + val status = app.sessions.statuses.value["ses_live"] + assertNotNull(status) + assertEquals("busy", status.type) + } + + @Test + fun `SSE status updates replace previous status`() = runBlocking { + val app = setup() + ready(app) + + mock.awaitSseConnection() + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_x","status":{"type":"busy"}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it["ses_x"]?.type == "busy" } + } + + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"ses_x","status":{"type":"idle"}}}""", + ) + + withTimeout(5_000) { + app.sessions.statuses.first { it["ses_x"]?.type == "idle" } + } + + assertEquals("idle", app.sessions.statuses.value["ses_x"]?.type) + } + + @Test + fun `seed populates status map from server`() = runBlocking { + mock.sessionStatuses = """{ + "ses_a": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""}, + "ses_b": {"type":"busy","attempt":0,"message":"","next":0,"requestID":""} + }""" + val app = setup() + ready(app) + + app.sessions.seed("/test") + assertEquals("idle", app.sessions.statuses.value["ses_a"]?.type) + assertEquals("busy", app.sessions.statuses.value["ses_b"]?.type) + } + + @Test + fun `statuses cleared on stop`() = runBlocking { + mock.sessionStatuses = """{"ses_1":{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}}""" + val app = setup() + ready(app) + + app.sessions.seed("/test") + assertTrue(app.sessions.statuses.value.isNotEmpty()) + + app.sessions.stop() + assertTrue(app.sessions.statuses.value.isEmpty()) + } + + // ------ Session with summary ------ + + @Test + fun `session summary maps correctly`() = runBlocking { + mock.sessions = """[{ + "id": "ses_sum", + "slug": "sum", + "projectID": "prj", + "directory": "/d", + "title": "With Summary", + "version": "1", + "time": {"created": 1, "updated": 1}, + "summary": {"additions": 42, "deletions": 7, "files": 3} + }]""" + val app = setup() + ready(app) + + val result = app.sessions.list("/d") + val session = result.sessions[0] + assertNotNull(session.summary) + assertEquals(42, session.summary!!.additions) + assertEquals(7, session.summary!!.deletions) + assertEquals(3, session.summary!!.files) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt new file mode 100644 index 00000000000..765433497b1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt @@ -0,0 +1,165 @@ +package ai.kilocode.backend + +import ai.kilocode.jetbrains.api.infrastructure.Serializer +import ai.kilocode.jetbrains.api.model.Session +import ai.kilocode.jetbrains.api.model.SessionStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies that session-related API model classes serialize/deserialize + * correctly using the production [Serializer.kotlinxSerializationJson]. + */ +class SessionModelSerializationTest { + + private val json = Serializer.kotlinxSerializationJson + + @Test + fun `Session minimal`() { + val src = """{ + "id": "ses_abc", + "slug": "test-session", + "projectID": "prj_123", + "directory": "/test/project", + "title": "My Session", + "version": "1.0.0", + "time": {"created": 1000.0, "updated": 2000.0} + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_abc", obj.id) + assertEquals("test-session", obj.slug) + assertEquals("prj_123", obj.projectID) + assertEquals("/test/project", obj.directory) + assertEquals("My Session", obj.title) + assertEquals(1000.0, obj.time.created) + assertEquals(2000.0, obj.time.updated) + assertNull(obj.parentID) + assertNull(obj.summary) + } + + @Test + fun `Session with summary`() { + val src = """{ + "id": "ses_1", + "slug": "s", + "projectID": "prj_1", + "directory": "/d", + "title": "T", + "version": "1.0.0", + "time": {"created": 1.0, "updated": 2.0}, + "summary": {"additions": 10, "deletions": 5, "files": 3} + }""" + val obj = json.decodeFromString(src) + assertNotNull(obj.summary) + assertEquals(10.0, obj.summary!!.additions) + assertEquals(5.0, obj.summary!!.deletions) + assertEquals(3.0, obj.summary!!.files) + } + + @Test + fun `Session with parentID and archived`() { + val src = """{ + "id": "ses_child", + "slug": "child", + "projectID": "prj_1", + "directory": "/d", + "title": "Fork", + "version": "1.0.0", + "time": {"created": 1.0, "updated": 2.0, "archived": 3000.0}, + "parentID": "ses_parent" + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_parent", obj.parentID) + assertEquals(3000.0, obj.time.archived) + } + + @Test + fun `Session list`() { + val src = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/d","title":"A","version":"1","time":{"created":1,"updated":1}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/d","title":"B","version":"1","time":{"created":2,"updated":2}} + ]""" + val list = json.decodeFromString>(src) + assertEquals(2, list.size) + assertEquals("ses_1", list[0].id) + assertEquals("ses_2", list[1].id) + } + + @Test + fun `empty session list`() { + val list = json.decodeFromString>("[]") + assertTrue(list.isEmpty()) + } + + @Test + fun `Session ignores unknown fields`() { + val src = """{ + "id": "ses_x", + "slug": "x", + "projectID": "prj_x", + "directory": "/x", + "title": "X", + "version": "1", + "time": {"created": 1, "updated": 1}, + "unknown_field": "value", + "nested": {"a": 1} + }""" + val obj = json.decodeFromString(src) + assertEquals("ses_x", obj.id) + } + + // ------ SessionStatus ------ + + @Test + fun `SessionStatus idle`() { + val src = """{"type":"idle","attempt":0,"message":"","next":0,"requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.IDLE, obj.type) + } + + @Test + fun `SessionStatus busy`() { + val src = """{"type":"busy","attempt":0,"message":"","next":0,"requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.BUSY, obj.type) + } + + @Test + fun `SessionStatus retry`() { + val src = """{"type":"retry","attempt":2,"message":"Rate limited","next":1500,"requestID":"req_1"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.RETRY, obj.type) + assertEquals(2.0, obj.attempt) + assertEquals("Rate limited", obj.message) + assertEquals(1500.0, obj.next) + } + + @Test + fun `SessionStatus offline`() { + val src = """{"type":"offline","attempt":0,"message":"Waiting for response","next":0,"requestID":"req_abc"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.OFFLINE, obj.type) + assertEquals("req_abc", obj.requestID) + } + + @Test + fun `SessionStatus map`() { + val src = """{ + "ses_1": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""}, + "ses_2": {"type":"busy","attempt":0,"message":"","next":0,"requestID":""} + }""" + val map = json.decodeFromString>(src) + assertEquals(2, map.size) + assertEquals(SessionStatus.Type.IDLE, map["ses_1"]!!.type) + assertEquals(SessionStatus.Type.BUSY, map["ses_2"]!!.type) + } + + @Test + fun `empty status map`() { + val map = json.decodeFromString>("{}") + assertTrue(map.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 198d4e78a49..7dcfb3772c7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -44,6 +44,16 @@ class MockCliServer : AutoCloseable { @Volatile var commandsStatus = 200 @Volatile var skillsStatus = 200 + // Session REST responses + @Volatile var sessions = "[]" + @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" + @Volatile var sessionStatuses = "{}" + @Volatile var sessionsStatus = 200 + @Volatile var sessionCreateStatus = 200 + @Volatile var sessionGetStatus = 200 + @Volatile var sessionDeleteStatus = 200 + @Volatile var sessionStatusesStatus = 200 + private val executor = Executors.newCachedThreadPool { r -> Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true } } @@ -138,6 +148,7 @@ class MockCliServer : AutoCloseable { val line = input.readLine() ?: return val parts = line.split(" ") if (parts.size < 2) return + val method = parts[0] val path = parts[1] // Read all headers @@ -147,6 +158,7 @@ class MockCliServer : AutoCloseable { } val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream())) + val bare = path.substringBefore("?") when { path == "/global/health" -> respond(output, 200, health) @@ -160,10 +172,17 @@ class MockCliServer : AutoCloseable { } } path == "/global/event" -> handleSse(output) - path == "/provider" || path.startsWith("/provider?") -> respond(output, providersStatus, providers) - path == "/agent" || path.startsWith("/agent?") -> respond(output, agentsStatus, agents) - path == "/command" || path.startsWith("/command?") -> respond(output, commandsStatus, commands) - path == "/skill" || path.startsWith("/skill?") -> respond(output, skillsStatus, skills) + bare == "/provider" -> respond(output, providersStatus, providers) + bare == "/agent" -> respond(output, agentsStatus, agents) + bare == "/command" -> respond(output, commandsStatus, commands) + bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) + bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) + bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) + bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> + respond(output, sessionGetStatus, sessionCreate) + bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> + respond(output, sessionDeleteStatus, "true") else -> respond(output, 404, """{"error":"Not found"}""") } } catch (_: SocketException) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt new file mode 100644 index 00000000000..1dee6d12d99 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt @@ -0,0 +1,115 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client + +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Project-level frontend service for session management. + * + * Provides session CRUD, active session tracking, and live + * status updates via [KiloSessionRpcApi]. All operations are + * scoped to the project's [directory] by default, with support + * for per-session worktree directory overrides. + */ +@Service(Service.Level.PROJECT) +class KiloSessionService( + private val project: Project, + private val cs: CoroutineScope, +) { + companion object { + private val LOG = Logger.getInstance(KiloSessionService::class.java) + } + + private val directory: String get() = project.basePath ?: "" + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val _active = MutableStateFlow(null) + val active: StateFlow = _active.asStateFlow() + + /** Live session status map from SSE events. */ + val statuses: StateFlow> = flow { + durable { + KiloSessionRpcApi.getInstance() + .statuses() + .collect { emit(it) } + } + }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + + /** Refresh the session list from the server. */ + fun refresh() { + cs.launch { + try { + val result = durable { KiloSessionRpcApi.getInstance().list(directory) } + _sessions.value = result.sessions + } catch (e: Exception) { + LOG.warn("session list failed", e) + } + } + } + + /** Create a new session and make it active. */ + fun create() { + cs.launch { + try { + val session = durable { KiloSessionRpcApi.getInstance().create(directory) } + _active.value = session + refresh() + } catch (e: Exception) { + LOG.warn("session create failed", e) + } + } + } + + /** Select an existing session as active. */ + fun select(id: String) { + cs.launch { + try { + val session = durable { KiloSessionRpcApi.getInstance().get(id, directory) } + _active.value = session + } catch (e: Exception) { + LOG.warn("session select failed", e) + } + } + } + + /** Delete a session. Clears active if it was the deleted one. */ + fun delete(id: String) { + cs.launch { + try { + durable { KiloSessionRpcApi.getInstance().delete(id, directory) } + if (_active.value?.id == id) _active.value = null + refresh() + } catch (e: Exception) { + LOG.warn("session delete failed", e) + } + } + } + + /** Register a worktree directory override for a session. */ + fun setDirectory(id: String, dir: String) { + cs.launch { + try { + durable { KiloSessionRpcApi.getInstance().setDirectory(id, dir) } + } catch (e: Exception) { + LOG.warn("setDirectory failed", e) + } + } + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt new file mode 100644 index 00000000000..accadf58fc2 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -0,0 +1,48 @@ +package ai.kilocode.rpc + +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow + +/** + * Session management RPC API exposed from backend to frontend. + * + * App-scoped — manages sessions across all directories (workspace + * roots and worktrees). Each call takes a [directory] parameter to + * scope the operation, matching the CLI server's directory-based + * routing. + */ +@Rpc +interface KiloSessionRpcApi : RemoteApi { + companion object { + suspend fun getInstance(): KiloSessionRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + /** List root sessions for a directory. */ + suspend fun list(directory: String): SessionListDto + + /** Create a new session in the given directory. */ + suspend fun create(directory: String): SessionDto + + /** Get a single session by ID. */ + suspend fun get(id: String, directory: String): SessionDto + + /** Delete a session. */ + suspend fun delete(id: String, directory: String) + + /** Observe live session status changes. */ + suspend fun statuses(): Flow> + + /** Register a worktree directory override for a session. */ + suspend fun setDirectory(id: String, directory: String) + + /** Get the effective directory for a session (worktree or fallback). */ + suspend fun getDirectory(id: String, fallback: String): String +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt new file mode 100644 index 00000000000..47958e66bce --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -0,0 +1,41 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SessionDto( + val id: String, + val projectID: String, + val directory: String, + val parentID: String? = null, + val title: String, + val version: String, + val time: SessionTimeDto, + val summary: SessionSummaryDto? = null, +) + +@Serializable +data class SessionTimeDto( + val created: Double, + val updated: Double, + val archived: Double? = null, +) + +@Serializable +data class SessionSummaryDto( + val additions: Int, + val deletions: Int, + val files: Int, +) + +@Serializable +data class SessionStatusDto( + val type: String, + val message: String? = null, +) + +@Serializable +data class SessionListDto( + val sessions: List, + val statuses: Map, +) From 6499ab5c22eecac339ab43025455e6d1110cd51f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 17:06:15 -0400 Subject: [PATCH 04/13] refactor(jetbrains): introduce workspace package, rename Project -> Workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move KiloBackendWorkspace, KiloBackendWorkspaceManager, and KiloBackendProjectService into backend/workspace/ package. Rename KiloProjectState to KiloWorkspaceState and shared DTOs accordingly (KiloWorkspaceStateDto, KiloWorkspaceStatusDto, KiloWorkspaceLoadProgressDto). Project is a private case of workspace — all directory-scoped types now live in the workspace package. --- .../backend/app/KiloBackendAppService.kt | 4 + .../backend/rpc/KiloProjectRpcApiImpl.kt | 75 ++-- .../backend/rpc/KiloSessionRpcApiImpl.kt | 27 +- .../workspace/KiloBackendProjectService.kt | 27 ++ .../KiloBackendWorkspace.kt} | 195 ++++----- .../workspace/KiloBackendWorkspaceManager.kt | 73 ++++ .../KiloWorkspaceState.kt} | 24 +- .../backend/KiloBackendProjectServiceTest.kt | 393 ----------------- .../backend/KiloBackendWorkspaceTest.kt | 400 ++++++++++++++++++ .../ai/kilocode/client/KiloProjectService.kt | 8 +- .../ai/kilocode/rpc/KiloProjectRpcApi.kt | 16 +- ...ctStateDto.kt => KiloWorkspaceStateDto.kt} | 10 +- 12 files changed, 653 insertions(+), 599 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{project/KiloBackendProjectService.kt => workspace/KiloBackendWorkspace.kt} (56%) create mode 100644 packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{project/KiloProjectState.kt => workspace/KiloWorkspaceState.kt} (72%) delete mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt rename packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/{KiloProjectStateDto.kt => KiloWorkspaceStateDto.kt} (71%) 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 fea9c9d4a00..78f68e2a4af 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 @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.infrastructure.ClientError import ai.kilocode.jetbrains.api.infrastructure.ClientException @@ -87,6 +88,7 @@ class KiloBackendAppService private constructor( val api: DefaultApi? get() = connection.api val sessions = KiloBackendSessionManager(cs, log) + val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) @Volatile var profile: KiloProfile200Response? = null private set @@ -227,6 +229,7 @@ class KiloBackendAppService private constructor( ) log.info("Application started — config, profile, notifications loaded") sessions.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.events) startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e @@ -376,6 +379,7 @@ class KiloBackendAppService private constructor( } private fun clear() { + workspaces.stop() sessions.stop() loader?.cancel() eventWatcher?.cancel() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt index eff162c319f..28c6ce534e8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt @@ -2,88 +2,77 @@ package ai.kilocode.backend.rpc -import ai.kilocode.backend.project.AgentData -import ai.kilocode.backend.project.AgentInfo -import ai.kilocode.backend.project.CommandInfo -import ai.kilocode.backend.project.KiloBackendProjectService -import ai.kilocode.backend.project.KiloProjectLoadProgress -import ai.kilocode.backend.project.KiloProjectState -import ai.kilocode.backend.project.ModelInfo -import ai.kilocode.backend.project.ProviderData -import ai.kilocode.backend.project.ProviderInfo -import ai.kilocode.backend.project.SkillInfo +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.workspace.AgentData +import ai.kilocode.backend.workspace.AgentInfo +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager +import ai.kilocode.backend.workspace.KiloWorkspaceLoadProgress +import ai.kilocode.backend.workspace.KiloWorkspaceState +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo +import ai.kilocode.backend.workspace.SkillInfo import ai.kilocode.rpc.KiloProjectRpcApi import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.CommandDto -import ai.kilocode.rpc.dto.KiloProjectLoadProgressDto -import ai.kilocode.rpc.dto.KiloProjectStateDto -import ai.kilocode.rpc.dto.KiloProjectStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service -import com.intellij.openapi.project.ProjectManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** * Backend implementation of [KiloProjectRpcApi]. * - * Looks up the correct [KiloBackendProjectService] by directory - * and maps its internal domain model to shared DTOs. + * Routes through the [KiloBackendWorkspaceManager] to get a workspace + * for the given directory. No [ProjectManager] dependency — any + * directory (including worktrees) can get a workspace. */ class KiloProjectRpcApiImpl : KiloProjectRpcApi { - override suspend fun state(directory: String): Flow { - val svc = find(directory) - ?: return flowOf(KiloProjectStateDto( - status = KiloProjectStatusDto.ERROR, - error = "Project not found for directory: $directory", - )) - svc.start() - return svc.state + private val manager: KiloBackendWorkspaceManager + get() = service().workspaces + + override suspend fun state(directory: String): Flow = + manager.get(directory).state .map(::dto) .distinctUntilChanged() - } override suspend fun reload(directory: String) { - find(directory)?.reload() - } - - private fun find(directory: String): KiloBackendProjectService? { - val project = ProjectManager.getInstance().openProjects - .find { it.basePath == directory } - ?: return null - return project.service() + manager.get(directory).reload() } // ------ mapping: domain model → DTO ------ - private fun dto(state: KiloProjectState): KiloProjectStateDto = + private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = when (state) { - KiloProjectState.Pending -> KiloProjectStateDto(KiloProjectStatusDto.PENDING) - is KiloProjectState.Loading -> KiloProjectStateDto( - status = KiloProjectStatusDto.LOADING, + KiloWorkspaceState.Pending -> KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + is KiloWorkspaceState.Loading -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.LOADING, progress = progress(state.progress), ) - is KiloProjectState.Ready -> KiloProjectStateDto( - status = KiloProjectStatusDto.READY, + is KiloWorkspaceState.Ready -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.READY, providers = providers(state.providers), agents = agents(state.agents), commands = state.commands.map(::command), skills = state.skills.map(::skill), ) - is KiloProjectState.Error -> KiloProjectStateDto( - status = KiloProjectStatusDto.ERROR, + is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, error = state.message, ) } - private fun progress(p: KiloProjectLoadProgress) = KiloProjectLoadProgressDto( + private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( providers = p.providers, agents = p.agents, commands = p.commands, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 68ebf36bce6..2cd29fbe98a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto @@ -14,33 +15,37 @@ import kotlinx.coroutines.flow.Flow /** * Backend implementation of [KiloSessionRpcApi]. * - * Delegates to [KiloBackendSessionManager] obtained from - * [KiloBackendAppService.sessions]. The manager is guaranteed - * to be started when the app is in [KiloAppState.Ready]. + * Session CRUD routes through the [KiloBackendWorkspaceManager] to + * get the correct workspace for a directory. Status tracking and + * worktree directory management go directly to the + * [KiloBackendSessionManager]. */ class KiloSessionRpcApiImpl : KiloSessionRpcApi { - private val manager: KiloBackendSessionManager + private val workspaces: KiloBackendWorkspaceManager + get() = service().workspaces + + private val sessions: KiloBackendSessionManager get() = service().sessions override suspend fun list(directory: String): SessionListDto = - manager.list(directory) + workspaces.get(directory).sessions() override suspend fun create(directory: String): SessionDto = - manager.create(directory) + workspaces.get(directory).createSession() override suspend fun get(id: String, directory: String): SessionDto = - manager.get(id, directory) + sessions.get(id, directory) override suspend fun delete(id: String, directory: String) = - manager.delete(id, directory) + workspaces.get(directory).deleteSession(id) override suspend fun statuses(): Flow> = - manager.statuses + sessions.statuses override suspend fun setDirectory(id: String, directory: String) = - manager.setDirectory(id, directory) + sessions.setDirectory(id, directory) override suspend fun getDirectory(id: String, fallback: String): String = - manager.getDirectory(id, fallback) + sessions.getDirectory(id, fallback) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt new file mode 100644 index 00000000000..173c35f9d5f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendProjectService.kt @@ -0,0 +1,27 @@ +package ai.kilocode.backend.workspace + +import ai.kilocode.backend.app.KiloBackendAppService +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope + +/** + * Per-IntelliJ-Project adapter that maps [Project.getBasePath] to a + * [KiloBackendWorkspace] from the app-level workspace manager. + * + * This is a thin accessor — all data loading, SSE watching, session + * access, and retry logic live in [KiloBackendWorkspace]. The frontend + * uses this service to get the workspace for the current IDE project. + */ +@Service(Service.Level.PROJECT) +class KiloBackendProjectService( + private val project: Project, + @Suppress("unused") private val cs: CoroutineScope, +) { + val directory: String get() = project.basePath ?: "" + + /** The workspace for this project's directory. */ + val workspace: KiloBackendWorkspace + get() = service().workspaces.get(directory) +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt similarity index 56% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 89bdba83dd7..5dbf8fde8ad 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloBackendProjectService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -1,15 +1,12 @@ -package ai.kilocode.backend.project +package ai.kilocode.backend.workspace -import ai.kilocode.backend.app.KiloAppState -import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.app.SseEvent -import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -23,107 +20,43 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicReference /** - * Project-level backend service that loads project-scoped data - * from the CLI server when the app reaches [KiloAppState.Ready]. + * Single entry point for all directory-scoped data: project catalog + * (providers, agents, commands, skills) and session access. * - * Construction is side-effect-free — call [start] to begin - * watching [KiloBackendAppService.appState] and loading data. - * [start] is idempotent and safe to call multiple times. + * **Not an IntelliJ service** — a plain class created by + * [KiloBackendWorkspaceManager] for each directory. Receives a + * pre-connected [DefaultApi] — no null checks needed. * - * Each fetch is retried up to [MAX_RETRIES] times, matching the - * pattern in [KiloBackendAppService]. + * Session operations delegate to [KiloBackendSessionManager] with + * this workspace's [directory], so the frontend only needs one + * object per directory. */ -@Service(Service.Level.PROJECT) -class KiloBackendProjectService private constructor( +class KiloBackendWorkspace( val directory: String, private val cs: CoroutineScope, - private val appState: () -> StateFlow, - private val api: () -> DefaultApi?, - private val events: () -> SharedFlow, + private val api: DefaultApi, + private val events: SharedFlow, + private val sessions: KiloBackendSessionManager, private val log: KiloLog, ) { - /** IntelliJ service injection entry point. */ - constructor(project: Project, cs: CoroutineScope) : this( - directory = project.basePath ?: "", - cs = cs, - appState = { service().appState }, - api = { service().api }, - events = { service().events }, - log = IntellijLog(KiloBackendProjectService::class.java), - ) - companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L - - /** Test factory — no IntelliJ deps needed. */ - internal fun create( - dir: String, - cs: CoroutineScope, - appState: () -> StateFlow, - api: () -> DefaultApi?, - log: KiloLog, - events: () -> SharedFlow = { MutableStateFlow(SseEvent("", "")) }, - ) = KiloBackendProjectService(dir, cs, appState, api, events, log) } - private val _state = MutableStateFlow(KiloProjectState.Pending) - val state: StateFlow = _state.asStateFlow() + private val _state = MutableStateFlow(KiloWorkspaceState.Pending) + val state: StateFlow = _state.asStateFlow() - private var watcher: Job? = null private var loader: Job? = null private var eventWatcher: Job? = null - /** - * Begin watching app state and loading project data when ready. - * Idempotent — safe to call multiple times. - */ - fun start() { - if (watcher?.isActive == true) return - watcher = cs.launch { - appState().collect { state -> - when (state) { - is KiloAppState.Ready -> load() - is KiloAppState.Disconnected, - is KiloAppState.Connecting -> { - loader?.cancel() - _state.value = KiloProjectState.Pending - } - is KiloAppState.Error -> { - loader?.cancel() - _state.value = KiloProjectState.Pending - } - is KiloAppState.Loading -> { /* wait for Ready */ } - } - } - } - } - - /** Force a full reload of all project data. */ - suspend fun reload() { - load() - } - - /** - * Launch all project data fetches in parallel. - * - * Cancels any in-flight load first. Each resource is retried - * up to [MAX_RETRIES] times. Progress is tracked via - * [AtomicReference] and emitted as [KiloProjectState.Loading]. - */ - private fun load() { + /** Load project data (providers, agents, commands, skills). */ + fun load() { loader?.cancel() loader = cs.launch { - val dir = directory - val client = api() - if (client == null) { - _state.value = KiloProjectState.Error("CLI server not connected") - return@launch - } - - log.info("Loading project data for $dir") - val progress = AtomicReference(KiloProjectLoadProgress()) - _state.value = KiloProjectState.Loading(progress.get()) + log.info("Loading workspace data for $directory") + val progress = AtomicReference(KiloWorkspaceLoadProgress()) + _state.value = KiloWorkspaceState.Loading(progress.get()) var prov: ProviderData? = null var ag: AgentData? = null @@ -134,44 +67,44 @@ class KiloBackendProjectService private constructor( try { coroutineScope { launch { - val result = fetchWithRetry("providers") { fetchProviders(client, dir) } + val result = fetchWithRetry("providers") { fetchProviders() } if (result != null) { prov = result progress.updateAndGet { it.copy(providers = true) } - .also { _state.value = KiloProjectState.Loading(it) } + .also { _state.value = KiloWorkspaceState.Loading(it) } } else { synchronized(errors) { errors.add("providers") } throw LoadFailure("providers") } } launch { - val result = fetchWithRetry("agents") { fetchAgents(client, dir) } + val result = fetchWithRetry("agents") { fetchAgents() } if (result != null) { ag = result progress.updateAndGet { it.copy(agents = true) } - .also { _state.value = KiloProjectState.Loading(it) } + .also { _state.value = KiloWorkspaceState.Loading(it) } } else { synchronized(errors) { errors.add("agents") } throw LoadFailure("agents") } } launch { - val result = fetchWithRetry("commands") { fetchCommands(client, dir) } + val result = fetchWithRetry("commands") { fetchCommands() } if (result != null) { cmd = result progress.updateAndGet { it.copy(commands = true) } - .also { _state.value = KiloProjectState.Loading(it) } + .also { _state.value = KiloWorkspaceState.Loading(it) } } else { synchronized(errors) { errors.add("commands") } throw LoadFailure("commands") } } launch { - val result = fetchWithRetry("skills") { fetchSkills(client, dir) } + val result = fetchWithRetry("skills") { fetchSkills() } if (result != null) { sk = result progress.updateAndGet { it.copy(skills = true) } - .also { _state.value = KiloProjectState.Loading(it) } + .also { _state.value = KiloWorkspaceState.Loading(it) } } else { synchronized(errors) { errors.add("skills") } throw LoadFailure("skills") @@ -179,50 +112,66 @@ class KiloBackendProjectService private constructor( } } - _state.value = KiloProjectState.Ready( + _state.value = KiloWorkspaceState.Ready( providers = prov!!, agents = ag!!, commands = cmd!!, skills = sk!!, ) - log.info("Project data loaded for $dir") + log.info("Workspace data loaded for $directory") startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e } catch (e: Exception) { - log.warn("Project data load failed for $dir: ${e.message}") - _state.value = KiloProjectState.Error( + log.warn("Workspace data load failed for $directory: ${e.message}") + _state.value = KiloWorkspaceState.Error( "Failed to load: ${synchronized(errors) { errors.joinToString() }}" ) } } } + /** Force a full reload of workspace data. */ + fun reload() { + load() + } + + /** Stop all background work. */ + fun stop() { + loader?.cancel() + eventWatcher?.cancel() + _state.value = KiloWorkspaceState.Pending + } + + // ------ session access (delegates to session manager) ------ + + fun sessions(): SessionListDto = sessions.list(directory) + fun createSession(): SessionDto = sessions.create(directory) + fun deleteSession(id: String) = sessions.delete(id, directory) + fun seedStatuses() = sessions.seed(directory) + + // ------ SSE watching ------ + /** - * Watch global SSE events that invalidate project-scoped data. + * Watch global SSE events that invalidate workspace data. * - * - `global.disposed` — the CLI server's global context was torn down. - * All cached providers, agents, commands, and skills are stale — reload. - * - * - `server.instance.disposed` — a specific server instance was disposed. - * Same effect — reload project data to pick up the new state. + * - `global.disposed` — CLI server context torn down, all data stale. + * - `server.instance.disposed` — server instance disposed, reload. * * Idempotent — only one watcher runs at a time. */ private fun startWatchingGlobalSseEvents() { if (eventWatcher?.isActive == true) return - log.info("Started watching global SSE events for project $directory") + log.info("Started watching global SSE events for workspace $directory") eventWatcher = cs.launch { - events().collect { event -> + events.collect { event -> when (event.type) { - // CLI server context torn down — all project data is stale "global.disposed" -> { - log.info("SSE global.disposed — reloading project data for $directory") + log.info("SSE global.disposed — reloading workspace data for $directory") load() } - // Server instance disposed — same effect, reload project data "server.instance.disposed" -> { - log.info("SSE server.instance.disposed — reloading project data for $directory") + log.info("SSE server.instance.disposed — reloading workspace data for $directory") load() } } @@ -230,11 +179,11 @@ class KiloBackendProjectService private constructor( } } - // ------ individual fetch methods ------ + // ------ fetch methods ------ - private fun fetchProviders(client: DefaultApi, dir: String): ProviderData? = + private fun fetchProviders(): ProviderData? = try { - val response = client.providerList(directory = dir) + val response = api.providerList(directory = directory) ProviderData( providers = response.all.map { p -> ProviderInfo( @@ -263,9 +212,9 @@ class KiloBackendProjectService private constructor( null } - private fun fetchAgents(client: DefaultApi, dir: String): AgentData? = + private fun fetchAgents(): AgentData? = try { - val response = client.appAgents(directory = dir) + val response = api.appAgents(directory = directory) val mapped = response.map(::mapAgent) val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } AgentData( @@ -278,9 +227,9 @@ class KiloBackendProjectService private constructor( null } - private fun fetchCommands(client: DefaultApi, dir: String): List? = + private fun fetchCommands(): List? = try { - client.commandList(directory = dir).map { c -> + api.commandList(directory = directory).map { c -> CommandInfo( name = c.name, description = c.description, @@ -293,9 +242,9 @@ class KiloBackendProjectService private constructor( null } - private fun fetchSkills(client: DefaultApi, dir: String): List? = + private fun fetchSkills(): List? = try { - client.appSkills(directory = dir).map { s -> + api.appSkills(directory = directory).map { s -> SkillInfo( name = s.name, description = s.description, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt new file mode 100644 index 00000000000..074331c8a8a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -0,0 +1,73 @@ +package ai.kilocode.backend.workspace + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.SseEvent +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.jetbrains.api.client.DefaultApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Manages [KiloBackendWorkspace] instances by directory path. + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after [KiloAppState.Ready] and [stop] on disconnect. + * + * Workspaces are created on demand via [get] — the first call for a + * directory creates the workspace and triggers data loading. Subsequent + * calls return the cached instance. Worktree directories are just + * another path — no special handling needed. + */ +class KiloBackendWorkspaceManager( + private val cs: CoroutineScope, + private val sessions: KiloBackendSessionManager, + private val log: KiloLog, +) { + private val workspaces = ConcurrentHashMap() + + private var api: DefaultApi? = null + private var events: SharedFlow? = null + + /** + * Activate with a connected API client and SSE stream. + * Called by [KiloBackendAppService] after [KiloAppState.Ready]. + * Clears any stale workspaces from a previous connection. + */ + fun start(api: DefaultApi, events: SharedFlow) { + stop() + this.api = api + this.events = events + log.info("Workspace manager started") + } + + /** + * Deactivate all workspaces. Called by [KiloBackendAppService] on disconnect. + */ + fun stop() { + workspaces.values.forEach { it.stop() } + workspaces.clear() + api = null + events = null + log.info("Workspace manager stopped") + } + + /** + * Get or create a workspace for a directory. + * The workspace loads data immediately upon creation. + */ + fun get(dir: String): KiloBackendWorkspace { + val client = api ?: throw IllegalStateException("Workspace manager not started") + val ev = events!! + return workspaces.getOrPut(dir) { + log.info("Creating workspace for $dir") + KiloBackendWorkspace(dir, cs, client, ev, sessions, log).also { it.load() } + } + } + + /** Remove a workspace (e.g. when a worktree is deleted). */ + fun remove(dir: String) { + workspaces.remove(dir)?.stop() + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt similarity index 72% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index d3718c1428d..7a4ad30b029 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/project/KiloProjectState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -1,30 +1,30 @@ -package ai.kilocode.backend.project +package ai.kilocode.backend.workspace /** - * Full project data lifecycle state, combining connection readiness - * with project-scoped data loading progress. + * Workspace data lifecycle state, combining connection readiness + * with directory-scoped data loading progress. * * Only populated after [KiloAppState.Ready][ai.kilocode.backend.app.KiloAppState.Ready] * — the CLI server must be connected and global data loaded before - * project data can be fetched. + * workspace data can be fetched. */ -sealed class KiloProjectState { - data object Pending : KiloProjectState() - data class Loading(val progress: KiloProjectLoadProgress) : KiloProjectState() +sealed class KiloWorkspaceState { + data object Pending : KiloWorkspaceState() + data class Loading(val progress: KiloWorkspaceLoadProgress) : KiloWorkspaceState() data class Ready( val providers: ProviderData, val agents: AgentData, val commands: List, val skills: List, - ) : KiloProjectState() - data class Error(val message: String) : KiloProjectState() + ) : KiloWorkspaceState() + data class Error(val message: String) : KiloWorkspaceState() } /** - * Tracks which project data fetches have completed during - * the [KiloProjectState.Loading] phase. + * Tracks which workspace data fetches have completed during + * the [KiloWorkspaceState.Loading] phase. */ -data class KiloProjectLoadProgress( +data class KiloWorkspaceLoadProgress( val providers: Boolean = false, val agents: Boolean = false, val commands: Boolean = false, diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt deleted file mode 100644 index 206a79fe9f4..00000000000 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendProjectServiceTest.kt +++ /dev/null @@ -1,393 +0,0 @@ -package ai.kilocode.backend - -import ai.kilocode.backend.app.KiloAppState -import ai.kilocode.backend.app.KiloBackendAppService -import ai.kilocode.backend.project.KiloBackendProjectService -import ai.kilocode.backend.project.KiloProjectState -import ai.kilocode.backend.testing.FakeCliServer -import ai.kilocode.backend.testing.MockCliServer -import ai.kilocode.backend.testing.TestLog -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class KiloBackendProjectServiceTest { - - private val mock = MockCliServer() - private val log = TestLog() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - @AfterTest - fun tearDown() { - scope.cancel() - mock.close() - } - - private fun setup(): Pair { - val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log) - val project = KiloBackendProjectService.create( - dir = "/test/project", - cs = scope, - appState = { app.appState }, - api = { app.api }, - log = log, - ) - return app to project - } - - // ------ Lifecycle ------ - - @Test - fun `full lifecycle reaches Ready`() = runBlocking { - mock.providers = PROVIDERS_JSON - mock.agents = AGENTS_JSON - mock.commands = COMMANDS_JSON - mock.skills = SKILLS_JSON - - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - val ready = project.state.value as KiloProjectState.Ready - assertEquals(1, ready.providers.providers.size) - assertEquals("anthropic", ready.providers.providers[0].id) - assertEquals(listOf("anthropic"), ready.providers.connected) - assertEquals(1, ready.agents.agents.size) - assertEquals("code", ready.agents.default) - assertEquals(1, ready.commands.size) - assertEquals("clear", ready.commands[0].name) - assertEquals(1, ready.skills.size) - assertEquals("test-skill", ready.skills[0].name) - } - - @Test - fun `stays Pending when app is not Ready`() = runBlocking { - val (_, project) = setup() - project.start() - - // Give some time for any unexpected transitions - delay(500) - assertIs(project.state.value) - } - - @Test - fun `transitions to Pending when app disconnects`() = runBlocking { - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - app.dispose() - - withTimeout(5_000) { - project.state.first { it is KiloProjectState.Pending } - } - assertIs(project.state.value) - } - - @Test - fun `loading tracks progress through Loading state`() = runBlocking { - val (app, project) = setup() - val states = mutableListOf() - - val collector = scope.launch { - project.state.collect { states.add(it) } - } - - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - collector.cancel() - - assertTrue(states.any { it is KiloProjectState.Loading }) - assertTrue(states.any { it is KiloProjectState.Ready }) - } - - // ------ Error handling ------ - - @Test - fun `providers failure retries then transitions to Error`() = runBlocking { - mock.providersStatus = 500 - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Error } - } - - val err = project.state.value as KiloProjectState.Error - assertTrue(err.message.contains("providers")) - } - - @Test - fun `agents failure retries then transitions to Error`() = runBlocking { - mock.agentsStatus = 500 - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Error } - } - - val err = project.state.value as KiloProjectState.Error - assertTrue(err.message.contains("agents")) - } - - @Test - fun `commands failure transitions to Error`() = runBlocking { - mock.commandsStatus = 500 - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Error } - } - - val err = project.state.value as KiloProjectState.Error - assertTrue(err.message.contains("commands")) - } - - @Test - fun `skills failure transitions to Error`() = runBlocking { - mock.skillsStatus = 500 - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Error } - } - - val err = project.state.value as KiloProjectState.Error - assertTrue(err.message.contains("skills")) - } - - @Test - fun `partial failure reports all failed resources`() = runBlocking { - mock.providersStatus = 500 - mock.skillsStatus = 500 - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Error } - } - - val err = project.state.value as KiloProjectState.Error - // At least one of the failed resources should be mentioned - assertTrue(err.message.contains("providers") || err.message.contains("skills")) - } - - // ------ Parallel/competing initialization ------ - - @Test - fun `start is idempotent`() = runBlocking { - val (app, project) = setup() - project.start() - project.start() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - assertIs(project.state.value) - } - - @Test - fun `reload during load produces valid final state`() = runBlocking { - val (app, project) = setup() - project.start() - app.connect() - - // Wait for app to be ready so project load begins - withTimeout(10_000) { - app.appState.first { it is KiloAppState.Ready } - } - - // Trigger overlapping reloads - project.reload() - project.reload() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - // The final state should be a valid Ready, not corrupted - assertIs(project.state.value) - } - - @Test - fun `rapid app state changes settle to correct state`() = runBlocking { - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - // Simulate rapid disconnect/reconnect by restarting - app.restart() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - assertIs(project.state.value) - } - - // ------ Data mapping ------ - - @Test - fun `providers response maps models correctly`() = runBlocking { - mock.providers = PROVIDERS_JSON - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - val ready = project.state.value as KiloProjectState.Ready - val provider = ready.providers.providers[0] - assertEquals("anthropic", provider.id) - assertEquals("Anthropic", provider.name) - val model = provider.models["claude-4"] - assertNotNull(model) - assertEquals("Claude 4", model.name) - assertTrue(model.attachment) - assertTrue(model.reasoning) - assertTrue(model.toolCall) - } - - @Test - fun `agents response filters hidden and subagent`() = runBlocking { - mock.agents = """[ - {"name":"code","mode":"primary","permission":[],"options":{}}, - {"name":"helper","mode":"subagent","permission":[],"options":{}}, - {"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}} - ]""" - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - val ready = project.state.value as KiloProjectState.Ready - // Only "code" is visible (non-subagent, non-hidden) - assertEquals(1, ready.agents.agents.size) - assertEquals("code", ready.agents.agents[0].name) - // All 3 in the full list - assertEquals(3, ready.agents.all.size) - assertEquals("code", ready.agents.default) - } - - @Test - fun `commands response maps source`() = runBlocking { - mock.commands = """[ - {"name":"clear","template":"","hints":[],"source":"command"}, - {"name":"mcp-tool","template":"","hints":["tool"],"source":"mcp"} - ]""" - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - val ready = project.state.value as KiloProjectState.Ready - assertEquals(2, ready.commands.size) - assertEquals("command", ready.commands[0].source) - assertEquals("mcp", ready.commands[1].source) - assertEquals(listOf("tool"), ready.commands[1].hints) - } - - @Test - fun `empty responses produce empty Ready`() = runBlocking { - // defaults are already empty - val (app, project) = setup() - project.start() - app.connect() - - withTimeout(15_000) { - project.state.first { it is KiloProjectState.Ready } - } - - val ready = project.state.value as KiloProjectState.Ready - assertTrue(ready.providers.providers.isEmpty()) - assertTrue(ready.agents.all.isEmpty()) - assertTrue(ready.commands.isEmpty()) - assertTrue(ready.skills.isEmpty()) - assertEquals("code", ready.agents.default) // fallback - } - - companion object { - private val PROVIDERS_JSON = """{ - "all": [{ - "id": "anthropic", - "name": "Anthropic", - "env": ["ANTHROPIC_API_KEY"], - "models": { - "claude-4": { - "id": "claude-4", - "name": "Claude 4", - "release_date": "2025-05-01", - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "limit": {"context": 200000, "output": 16000}, - "options": {} - } - } - }], - "default": {"code": "anthropic/claude-4"}, - "connected": ["anthropic"] - }""".trimIndent() - - private val AGENTS_JSON = """[ - {"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}} - ]""".trimIndent() - - private val COMMANDS_JSON = """[ - {"name":"clear","description":"Clear conversation","template":"","hints":[],"source":"command"} - ]""".trimIndent() - - private val SKILLS_JSON = """[ - {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} - ]""".trimIndent() - } -} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt new file mode 100644 index 00000000000..6f462aa861a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt @@ -0,0 +1,400 @@ +package ai.kilocode.backend + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.workspace.KiloBackendWorkspace +import ai.kilocode.backend.workspace.KiloWorkspaceState +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloBackendWorkspaceTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() { + scope.cancel() + mock.close() + } + + private fun setup(): KiloBackendAppService = + KiloBackendAppService.create(scope, FakeCliServer(mock), log) + + private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace { + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + return app.workspaces.get("/test/project") + } + + // ------ Workspace manager lifecycle ------ + + @Test + fun `workspace manager throws when not started`() = runBlocking { + val app = setup() + assertFailsWith { + app.workspaces.get("/test") + } + } + + @Test + fun `get creates workspace on demand after Ready`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals("anthropic", state.providers.providers[0].id) + } + + @Test + fun `same directory returns same workspace instance`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + val ws1 = app.workspaces.get("/test") + val ws2 = app.workspaces.get("/test") + assertTrue(ws1 === ws2) + } + + @Test + fun `different directories return different workspaces`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + val ws1 = app.workspaces.get("/project-a") + val ws2 = app.workspaces.get("/project-b") + assertTrue(ws1 !== ws2) + assertEquals("/project-a", ws1.directory) + assertEquals("/project-b", ws2.directory) + } + + @Test + fun `workspaces stopped on app disconnect`() = runBlocking { + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + app.dispose() + + // Workspace state should be Pending (stopped) + assertIs(ws.state.value) + + // Manager should throw since app is disconnected + assertFailsWith { + app.workspaces.get("/test/project") + } + } + + // ------ Workspace data loading ------ + + @Test + fun `full lifecycle reaches Ready`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals(listOf("anthropic"), state.providers.connected) + assertEquals(1, state.agents.agents.size) + assertEquals("code", state.agents.default) + assertEquals(1, state.commands.size) + assertEquals("clear", state.commands[0].name) + assertEquals(1, state.skills.size) + assertEquals("test-skill", state.skills[0].name) + } + + @Test + fun `workspace reaches Ready after creation`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + // get() creates workspace and starts loading immediately + val ws = app.workspaces.get("/test") + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + assertIs(ws.state.value) + } + + // ------ Error handling ------ + + @Test + fun `providers failure retries then transitions to Error`() = runBlocking { + mock.providersStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("providers")) + } + + @Test + fun `agents failure retries then transitions to Error`() = runBlocking { + mock.agentsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("agents")) + } + + @Test + fun `commands failure transitions to Error`() = runBlocking { + mock.commandsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("commands")) + } + + @Test + fun `skills failure transitions to Error`() = runBlocking { + mock.skillsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("skills")) + } + + @Test + fun `partial failure reports failed resources`() = runBlocking { + mock.providersStatus = 500 + mock.skillsStatus = 500 + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + assertTrue(err.message.contains("providers") || err.message.contains("skills")) + } + + // ------ Reload ------ + + @Test + fun `reload during load produces valid final state`() = runBlocking { + val app = setup() + val ws = ready(app) + + ws.reload() + ws.reload() + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + assertIs(ws.state.value) + } + + // ------ Data mapping ------ + + @Test + fun `providers response maps models correctly`() = runBlocking { + mock.providers = PROVIDERS_JSON + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + val provider = state.providers.providers[0] + assertEquals("anthropic", provider.id) + assertEquals("Anthropic", provider.name) + val model = provider.models["claude-4"] + assertNotNull(model) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.toolCall) + } + + @Test + fun `agents response filters hidden and subagent`() = runBlocking { + mock.agents = """[ + {"name":"code","mode":"primary","permission":[],"options":{}}, + {"name":"helper","mode":"subagent","permission":[],"options":{}}, + {"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}} + ]""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.agents.agents.size) + assertEquals("code", state.agents.agents[0].name) + assertEquals(3, state.agents.all.size) + assertEquals("code", state.agents.default) + } + + @Test + fun `commands response maps source`() = runBlocking { + mock.commands = """[ + {"name":"clear","template":"","hints":[],"source":"command"}, + {"name":"mcp-tool","template":"","hints":["tool"],"source":"mcp"} + ]""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(2, state.commands.size) + assertEquals("command", state.commands[0].source) + assertEquals("mcp", state.commands[1].source) + assertEquals(listOf("tool"), state.commands[1].hints) + } + + @Test + fun `empty responses produce empty Ready`() = runBlocking { + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertTrue(state.providers.providers.isEmpty()) + assertTrue(state.agents.all.isEmpty()) + assertTrue(state.commands.isEmpty()) + assertTrue(state.skills.isEmpty()) + assertEquals("code", state.agents.default) + } + + // ------ Session access through workspace ------ + + @Test + fun `workspace exposes sessions for its directory`() = runBlocking { + mock.sessions = """[ + {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}} + ]""" + val app = setup() + val ws = ready(app) + + val result = ws.sessions() + assertEquals(1, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + } + + @Test + fun `workspace creates session in its directory`() = runBlocking { + mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" + val app = setup() + val ws = ready(app) + + val session = ws.createSession() + assertEquals("ses_new", session.id) + assertEquals("/test/project", session.directory) + } + + companion object { + private val PROVIDERS_JSON = """{ + "all": [{ + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "release_date": "2025-05-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 200000, "output": 16000}, + "options": {} + } + } + }], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""".trimIndent() + + private val AGENTS_JSON = """[ + {"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}} + ]""".trimIndent() + + private val COMMANDS_JSON = """[ + {"name":"clear","description":"Clear conversation","template":"","hints":[],"source":"command"} + ]""".trimIndent() + + private val SKILLS_JSON = """[ + {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} + ]""".trimIndent() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt index b67fa04ee0b..22914195e8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt @@ -3,8 +3,8 @@ package ai.kilocode.client import ai.kilocode.rpc.KiloProjectRpcApi -import ai.kilocode.rpc.dto.KiloProjectStateDto -import ai.kilocode.rpc.dto.KiloProjectStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project @@ -31,12 +31,12 @@ class KiloProjectService( ) { companion object { private val LOG = Logger.getInstance(KiloProjectService::class.java) - private val init = KiloProjectStateDto(KiloProjectStatusDto.PENDING) + private val init = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) } private val directory: String get() = project.basePath ?: "" - val state: StateFlow = flow { + val state: StateFlow = flow { durable { KiloProjectRpcApi.getInstance() .state(directory) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt index c179d8cf1e9..b75a96c5559 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt @@ -1,6 +1,6 @@ package ai.kilocode.rpc -import ai.kilocode.rpc.dto.KiloProjectStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -8,11 +8,11 @@ import fleet.rpc.remoteApiDescriptor import kotlinx.coroutines.flow.Flow /** - * Project-level RPC API exposed from backend to frontend. + * Workspace-level RPC API exposed from backend to frontend. * - * Operations are scoped to a specific project directory. - * The CLI backend is app-scoped, but each call routes to the - * correct [KiloBackendProjectService] by directory lookup. + * Operations are scoped to a specific directory (workspace root + * or worktree). Each call routes to a [KiloBackendWorkspace] + * via the workspace manager. */ @Rpc interface KiloProjectRpcApi : RemoteApi { @@ -22,9 +22,9 @@ interface KiloProjectRpcApi : RemoteApi { } } - /** Observe project state loading progress. */ - suspend fun state(directory: String): Flow + /** Observe workspace state loading progress. */ + suspend fun state(directory: String): Flow - /** Trigger a full reload of project data. */ + /** Trigger a full reload of workspace data. */ suspend fun reload(directory: String) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt similarity index 71% rename from packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt rename to packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 5568bf9a742..c1dd8585db5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloProjectStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -3,7 +3,7 @@ package ai.kilocode.rpc.dto import kotlinx.serialization.Serializable @Serializable -enum class KiloProjectStatusDto { +enum class KiloWorkspaceStatusDto { PENDING, LOADING, READY, @@ -11,7 +11,7 @@ enum class KiloProjectStatusDto { } @Serializable -data class KiloProjectLoadProgressDto( +data class KiloWorkspaceLoadProgressDto( val providers: Boolean = false, val agents: Boolean = false, val commands: Boolean = false, @@ -19,9 +19,9 @@ data class KiloProjectLoadProgressDto( ) @Serializable -data class KiloProjectStateDto( - val status: KiloProjectStatusDto, - val progress: KiloProjectLoadProgressDto? = null, +data class KiloWorkspaceStateDto( + val status: KiloWorkspaceStatusDto, + val progress: KiloWorkspaceLoadProgressDto? = null, val providers: ProvidersDto? = null, val agents: AgentsDto? = null, val commands: List = emptyList(), From 43cdbe9de365fbf45bb092fd3a41755048302aaa Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 17:32:36 -0400 Subject: [PATCH 05/13] feat(jetbrains): extract KiloWelcomeUi with app + workspace status Move all status rendering from KiloToolWindowFactory into a reusable KiloWelcomeUi component that watches both app and workspace state. Shows two-section progress (App: config/notifications/profile, Workspace: providers/agents/commands/skills) and keeps all details visible after loading completes instead of replacing them with just 'Connected'. --- .../kilocode/client/KiloToolWindowFactory.kt | 155 +-------- .../ai/kilocode/client/KiloWelcomeUi.kt | 299 ++++++++++++++++++ .../resources/messages/KiloBundle.properties | 4 + 3 files changed, 317 insertions(+), 141 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 71330ca5424..8fd8f0c017d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,160 +1,33 @@ package ai.kilocode.client -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.LoadProgressDto -import ai.kilocode.rpc.dto.ProfileStatusDto -import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.openapi.util.IconLoader -import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.ui.components.JBLabel import com.intellij.ui.content.ContentFactory -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.SwingConstants +import kotlinx.coroutines.CoroutineScope +/** + * Creates the Kilo Code tool window content. + * + * Wires [KiloAppService] and [KiloProjectService] into a + * [KiloWelcomeUi] panel that shows app + workspace initialization + * status. All UI logic lives in [KiloWelcomeUi]. + */ class KiloToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val svc = service() - val mgr = ToolWindowManager.getInstance(project) - val icon = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", KiloToolWindowFactory::class.java), - ).apply { - horizontalAlignment = SwingConstants.CENTER - alignmentX = JPanel.CENTER_ALIGNMENT - } + val app = service() + val workspace = project.service() + val scope = project.service() + val ui = KiloWelcomeUi(app, workspace, scope) - val status = JBLabel( - KiloBundle.message("toolwindow.status.disconnected"), - SwingConstants.CENTER, - ).apply { - alignmentX = JPanel.CENTER_ALIGNMENT - font = JBUI.Fonts.label(13f) - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - } - - val detail = JBLabel("", SwingConstants.CENTER).apply { - alignmentX = JPanel.CENTER_ALIGNMENT - font = JBUI.Fonts.smallFont() - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - setCopyable(true) - } - - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - add(icon) - add(Box.createVerticalStrut(JBUI.scale(16))) - add(status) - add(Box.createVerticalStrut(JBUI.scale(6))) - add(detail) - } - - val panel = JPanel(GridBagLayout()).apply { - isOpaque = false - add(body, GridBagConstraints()) - } - - val content = ContentFactory.getInstance().createContent(panel, "", false) - val ui = Disposer.newDisposable() - val job = svc.watch { state -> - mgr.invokeLater { - status.text = title(state) - detail.text = details(state) - detail.isVisible = detail.text.isNotEmpty() - } - } - Disposer.register(ui, Disposable { job.cancel() }) + val content = ContentFactory.getInstance().createContent(ui, "", false) content.setDisposer(ui) toolWindow.contentManager.addContent(content) + ActionManager.getInstance().getAction("Kilo.Settings")?.let { toolWindow.setTitleActions(listOf(it)) } - svc.connect() - } - - private fun title(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected") - KiloAppStatusDto.ERROR -> KiloBundle.message( - "toolwindow.status.error", - state.error ?: KiloBundle.message("toolwindow.error.unknown"), - ) - } - - private fun details(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.LOADING -> progress(state.progress) - KiloAppStatusDto.READY -> ready(state) - KiloAppStatusDto.ERROR -> errors(state) - else -> "" - } - - private fun progress(p: LoadProgressDto?): String { - if (p == null) return "" - val lines = mutableListOf() - lines.add(item("Config", p.config)) - lines.add(item("Notifications", p.notifications)) - lines.add(profile(p.profile)) - return "${lines.joinToString("
")}" - } - - private fun item(name: String, loaded: Boolean): String = - if (loaded) "$CHECK $name" else "$DOTS $name" - - private fun profile(status: ProfileStatusDto): String = - when (status) { - ProfileStatusDto.PENDING -> "$DOTS Profile" - ProfileStatusDto.LOADED -> "$CHECK Profile" - ProfileStatusDto.NOT_LOGGED_IN -> "$DASH Profile (not logged in)" - } - - private fun ready(state: KiloAppStateDto): String { - val svc = service() - val ver = svc.version - val lines = mutableListOf() - if (ver != null) lines.add("CLI: $ver") - val p = state.progress - if (p != null && p.profile == ProfileStatusDto.NOT_LOGGED_IN) { - lines.add("Profile: not logged in") - } - return if (lines.isEmpty()) "" else "${lines.joinToString("
")}" - } - - private fun errors(state: KiloAppStateDto): String { - if (state.errors.isEmpty()) return "" - val lines = state.errors.map(::formatError) - return "Failed to load:
${lines.joinToString("
")}" - } - - private fun formatError(err: LoadErrorDto): String { - val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: "" - return if (suffix.isEmpty()) "$CROSS ${err.resource}" - else "$CROSS ${err.resource}: $suffix" - } - - companion object { - private const val CHECK = "\u2713" - private const val CROSS = "\u2717" - private const val DOTS = "\u2026" - private const val DASH = "\u2013" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt new file mode 100644 index 00000000000..ed1e60990df --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt @@ -0,0 +1,299 @@ +package ai.kilocode.client + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import ai.kilocode.rpc.dto.LoadProgressDto +import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.text.HtmlBuilder +import com.intellij.openapi.util.text.HtmlChunk +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Welcome panel that shows app initialization progress and workspace + * loading status. Watches both [KiloAppService] and [KiloProjectService] + * state flows and renders a unified two-section status view. + * + * When connected, all details stay visible — the user always sees + * what was loaded, the CLI version, and workspace data counts. + */ +class KiloWelcomeUi( + private val app: KiloAppService, + private val workspace: KiloProjectService, + private val cs: CoroutineScope, +) : JPanel(GridBagLayout()), Disposable { + + private val icon = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", KiloWelcomeUi::class.java), + ).apply { + horizontalAlignment = SwingConstants.CENTER + alignmentX = CENTER_ALIGNMENT + } + + private val status = JBLabel( + KiloBundle.message("toolwindow.status.disconnected"), + SwingConstants.CENTER, + ).apply { + alignmentX = CENTER_ALIGNMENT + font = JBUI.Fonts.label(13f) + foreground = UIUtil.getContextHelpForeground() + setAllowAutoWrapping(true) + } + + private val appDetail = JBLabel("", SwingConstants.CENTER).apply { + alignmentX = CENTER_ALIGNMENT + font = JBUI.Fonts.smallFont() + foreground = UIUtil.getContextHelpForeground() + setAllowAutoWrapping(true) + setCopyable(true) + } + + private val wsDetail = JBLabel("", SwingConstants.CENTER).apply { + alignmentX = CENTER_ALIGNMENT + font = JBUI.Fonts.smallFont() + foreground = UIUtil.getContextHelpForeground() + setAllowAutoWrapping(true) + setCopyable(true) + } + + private var appJob: Job? = null + private var wsJob: Job? = null + + init { + isOpaque = false + + val body = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(icon) + add(Box.createVerticalStrut(JBUI.scale(16))) + add(status) + add(Box.createVerticalStrut(JBUI.scale(6))) + add(appDetail) + add(Box.createVerticalStrut(JBUI.scale(6))) + add(wsDetail) + } + + add(body, GridBagConstraints()) + + appJob = app.watch { state -> + update { renderApp(state) } + } + + wsJob = cs.launch { + workspace.state.collect { state -> + update { renderWorkspace(state) } + } + } + + app.connect() + } + + override fun dispose() { + appJob?.cancel() + wsJob?.cancel() + } + + // ------ rendering ------ + + private fun renderApp(state: KiloAppStateDto) { + status.text = title(state) + val html = appHtml(state) + appDetail.text = html + appDetail.isVisible = html.isNotEmpty() + } + + private fun renderWorkspace(state: KiloWorkspaceStateDto) { + // Hide workspace section entirely when app isn't ready + val appReady = app.state.value.status == KiloAppStatusDto.READY + if (!appReady && state.status == KiloWorkspaceStatusDto.PENDING) { + wsDetail.isVisible = false + return + } + val html = wsHtml(state) + wsDetail.text = html + wsDetail.isVisible = html.isNotEmpty() + } + + // ------ app formatting ------ + + private fun title(state: KiloAppStateDto): String = + when (state.status) { + KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") + KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") + KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") + KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected") + KiloAppStatusDto.ERROR -> KiloBundle.message( + "toolwindow.status.error", + state.error ?: KiloBundle.message("toolwindow.error.unknown"), + ) + } + + private fun appHtml(state: KiloAppStateDto): String = + when (state.status) { + KiloAppStatusDto.LOADING -> appProgress(state.progress) + KiloAppStatusDto.READY -> appReady(state) + KiloAppStatusDto.ERROR -> appErrors(state) + else -> "" + } + + private fun appProgress(p: LoadProgressDto?): String { + if (p == null) return "" + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.app"))) + .br() + .append(item("Config", p.config)) + .br() + .append(item("Notifications", p.notifications)) + .br() + .append(profile(p.profile)) + return builder.wrapWithHtmlBody().toString() + } + + private fun appReady(state: KiloAppStateDto): String { + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.app"))) + + // Show final loading state + val p = state.progress + if (p != null) { + builder.br() + .append(item("Config", p.config)) + .br() + .append(item("Notifications", p.notifications)) + .br() + .append(profile(p.profile)) + } + + val ver = app.version + if (ver != null) { + builder.br().append(HtmlChunk.text("CLI: $ver")) + } + + return builder.wrapWithHtmlBody().toString() + } + + private fun appErrors(state: KiloAppStateDto): String { + if (state.errors.isEmpty()) return "" + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.app"))) + .br() + .append(HtmlChunk.text("Failed to load:")) + state.errors.forEach { err -> + builder.br().append(formatError(err)) + } + return builder.wrapWithHtmlBody().toString() + } + + // ------ workspace formatting ------ + + private fun wsHtml(state: KiloWorkspaceStateDto): String = + when (state.status) { + KiloWorkspaceStatusDto.PENDING -> wsPending() + KiloWorkspaceStatusDto.LOADING -> wsProgress(state.progress) + KiloWorkspaceStatusDto.READY -> wsReady(state) + KiloWorkspaceStatusDto.ERROR -> wsError(state) + } + + private fun wsPending(): String { + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.workspace"))) + .br() + .append(HtmlChunk.text("$DOTS ${KiloBundle.message("toolwindow.workspace.pending")}")) + return builder.wrapWithHtmlBody().toString() + } + + private fun wsProgress(p: KiloWorkspaceLoadProgressDto?): String { + if (p == null) return "" + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.workspace"))) + .br() + .append(item("Providers", p.providers)) + .br() + .append(item("Agents", p.agents)) + .br() + .append(item("Commands", p.commands)) + .br() + .append(item("Skills", p.skills)) + return builder.wrapWithHtmlBody().toString() + } + + private fun wsReady(state: KiloWorkspaceStateDto): String { + val prov = state.providers?.providers?.size ?: 0 + val agents = state.agents?.all?.size ?: 0 + val commands = state.commands.size + val skills = state.skills.size + + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.workspace"))) + .br() + .append(HtmlChunk.text("$CHECK Providers ($prov)")) + .br() + .append(HtmlChunk.text("$CHECK Agents ($agents)")) + .br() + .append(HtmlChunk.text("$CHECK Commands ($commands)")) + .br() + .append(HtmlChunk.text("$CHECK Skills ($skills)")) + return builder.wrapWithHtmlBody().toString() + } + + private fun wsError(state: KiloWorkspaceStateDto): String { + val msg = state.error ?: "Unknown error" + val builder = HtmlBuilder() + .append(section(KiloBundle.message("toolwindow.section.workspace"))) + .br() + .append(HtmlChunk.text("$CROSS $msg")) + return builder.wrapWithHtmlBody().toString() + } + + // ------ shared helpers ------ + + private fun section(name: String): HtmlChunk = + HtmlChunk.text("$DASH $DASH $name $DASH $DASH").bold() + + private fun item(name: String, loaded: Boolean): HtmlChunk = + HtmlChunk.text(if (loaded) "$CHECK $name" else "$DOTS $name") + + private fun profile(status: ProfileStatusDto): HtmlChunk = + when (status) { + ProfileStatusDto.PENDING -> HtmlChunk.text("$DOTS Profile") + ProfileStatusDto.LOADED -> HtmlChunk.text("$CHECK Profile") + ProfileStatusDto.NOT_LOGGED_IN -> HtmlChunk.text("$DASH Profile (not logged in)") + } + + private fun formatError(err: LoadErrorDto): HtmlChunk { + val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: "" + val text = if (suffix.isEmpty()) "$CROSS ${err.resource}" + else "$CROSS ${err.resource}: $suffix" + return HtmlChunk.text(text) + } + + private fun update(block: () -> Unit) { + ApplicationManager.getApplication().invokeLater(block) + } + + companion object { + private const val CHECK = "\u2713" + private const val CROSS = "\u2717" + private const val DOTS = "\u2026" + private const val DASH = "\u2013" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index acd30c58c95..abffce3b604 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,6 +11,10 @@ toolwindow.status.loading.short=Loading\u2026 toolwindow.status.disconnected.short=Disconnected toolwindow.status.error.short=Error +toolwindow.section.app=App +toolwindow.section.workspace=Workspace +toolwindow.workspace.pending=Waiting for connection\u2026 + action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings action.Kilo.SettingsGroup.text=Settings From f3bf57d8467ca4cab4cd4a58c2ba78b41d8c9b34 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 18:56:37 -0400 Subject: [PATCH 06/13] fix(jetbrains): resolve race conditions in initialization and add missing env vars Fix 6 race conditions in the JetBrains plugin backend: - Synchronize load() cancel-and-replace in AppService and Workspace (RC-1, RC-5, RC-6) - Use atomic StateFlow.update for session status map (RC-2) - Replace getOrPut with computeIfAbsent for workspace creation (RC-3) - Reset heartbeat timestamp on SSE reconnect (RC-4) - Start sub-managers before advertising Ready state Add 8 missing env vars (KILOCODE_FEATURE, KILO_EDITOR_NAME, KILO_APP_VERSION, KILO_MACHINE_ID, KILO_DISABLE_CLAUDE_CODE, KILOCODE_EDITOR_NAME) for telemetry and gateway attribution parity with the VS Code extension. Add 11 new tests covering concurrency, lifecycle, and reconnection scenarios. --- .../backend/app/KiloBackendAppService.kt | 191 +++++++++--------- .../backend/app/KiloBackendCliManager.kt | 45 +++++ .../app/KiloBackendConnectionService.kt | 3 + .../backend/app/KiloBackendSessionManager.kt | 5 +- .../backend/workspace/KiloBackendWorkspace.kt | 42 ++-- .../workspace/KiloBackendWorkspaceManager.kt | 6 +- .../backend/KiloBackendAppServiceTest.kt | 80 ++++++++ .../backend/KiloBackendSessionManagerTest.kt | 64 ++++++ .../backend/KiloBackendWorkspaceTest.kt | 106 ++++++++++ .../backend/KiloConnectionServiceTest.kt | 45 +++++ .../kilocode/backend/testing/MockCliServer.kt | 21 ++ 11 files changed, 494 insertions(+), 114 deletions(-) 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 78f68e2a4af..bc6041ff66c 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -80,6 +81,7 @@ class KiloBackendAppService private constructor( private var watcher: Job? = null private var eventWatcher: Job? = null private var loader: Job? = null + private val loadLock = Any() private val _appState = MutableStateFlow(KiloAppState.Disconnected) val appState: StateFlow = _appState.asStateFlow() @@ -166,60 +168,65 @@ class KiloBackendAppService private constructor( * On failure of required data, transitions to [KiloAppState.Error]. */ private fun load() { - loader?.cancel() - loader = cs.launch { - log.info("Application starting — loading config, profile, notifications") - val progress = AtomicReference(LoadProgress()) - _appState.value = KiloAppState.Loading(progress.get()) + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + loader = cs.launch { + log.info("Application starting — loading config, profile, notifications") + val progress = AtomicReference(LoadProgress()) + _appState.value = KiloAppState.Loading(progress.get()) - val errors = mutableListOf() + val errors = mutableListOf() - try { - coroutineScope { - launch { - try { - val result = fetchProfile() - progress.updateAndGet { it.copy(profile = result) } - .also { _appState.value = KiloAppState.Loading(it) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val err = LoadError( - resource = "profile", - status = (e as? ClientException)?.statusCode - ?: (e as? ServerException)?.statusCode, - detail = e.message, - ) - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) + try { + coroutineScope { + launch { + try { + val result = fetchProfile() + progress.updateAndGet { it.copy(profile = result) } + .also { _appState.value = KiloAppState.Loading(it) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val err = LoadError( + resource = "profile", + status = (e as? ClientException)?.statusCode + ?: (e as? ServerException)?.statusCode, + detail = e.message, + ) + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) + } + } + launch { + val result = fetchWithRetry("config") { fetchConfig() } + if (result.value != null) { + config = result.value + progress.updateAndGet { it.copy(config = true) } + .also { _appState.value = KiloAppState.Loading(it) } + } else { + val err = result.error!! + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) + } + } + launch { + val result = fetchWithRetry("notifications") { fetchNotifications() } + if (result.value != null) { + notifications = result.value + progress.updateAndGet { it.copy(notifications = true) } + .also { _appState.value = KiloAppState.Loading(it) } + } else { + val err = result.error!! + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) + } } } - launch { - val result = fetchWithRetry("config") { fetchConfig() } - if (result.value != null) { - config = result.value - progress.updateAndGet { it.copy(config = true) } - .also { _appState.value = KiloAppState.Loading(it) } - } else { - val err = result.error!! - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) - } - } - launch { - val result = fetchWithRetry("notifications") { fetchNotifications() } - if (result.value != null) { - notifications = result.value - progress.updateAndGet { it.copy(notifications = true) } - .also { _appState.value = KiloAppState.Loading(it) } - } else { - val err = result.error!! - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) - } - } - } + ensureActive() + sessions.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.events) _appState.value = KiloAppState.Ready( AppData( profile = profile, @@ -228,17 +235,16 @@ class KiloBackendAppService private constructor( ) ) log.info("Application started — config, profile, notifications loaded") - sessions.start(connection.api!!, connection.events) - workspaces.start(connection.api!!, connection.events) startWatchingGlobalSseEvents() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn("Application start failed: ${e.message}") - _appState.value = KiloAppState.Error( - message = "Failed to load required data", - errors = synchronized(errors) { errors.toList() }, - ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Application start failed: ${e.message}") + _appState.value = KiloAppState.Error( + message = "Failed to load required data", + errors = synchronized(errors) { errors.toList() }, + ) + } } } } @@ -337,41 +343,38 @@ class KiloBackendAppService private constructor( * Idempotent — only one watcher runs at a time. */ private fun startWatchingGlobalSseEvents() { - if (eventWatcher?.isActive == true) return - log.info("Started watching global SSE events (config.updated, disposed)") - eventWatcher = cs.launch { - connection.events.collect { event -> - when (event.type) { - // Config changed on disk or via CLI — re-fetch and update in-place - "global.config.updated" -> { - log.info("SSE global.config.updated — reloading config") - launch { - val result = fetchConfig() - if (result.value != null) { - config = result.value - val current = _appState.value - if (current is KiloAppState.Ready) { - _appState.value = current.copy( - data = current.data.copy(config = result.value) - ) + synchronized(loadLock) { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events (config.updated, disposed)") + eventWatcher = cs.launch { + connection.events.collect { event -> + when (event.type) { + "global.config.updated" -> { + log.info("SSE global.config.updated — reloading config") + launch { + val result = fetchConfig() + if (result.value != null) { + config = result.value + val current = _appState.value + if (current is KiloAppState.Ready) { + _appState.value = current.copy( + data = current.data.copy(config = result.value) + ) + } + log.info("Config reloaded successfully") } - log.info("Config reloaded successfully") } } - } - // CLI server context torn down — full reload needed. - // Project services watch appState and will reload when - // the app transitions back to Ready. - "global.disposed" -> { - log.info("SSE global.disposed — triggering full application reload") - val current = _appState.value - if (current is KiloAppState.Ready) load() - } - // Server instance disposed — same effect as global.disposed - "server.instance.disposed" -> { - log.info("SSE server.instance.disposed — triggering full application reload") - val current = _appState.value - if (current is KiloAppState.Ready) load() + "global.disposed" -> { + log.info("SSE global.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — triggering full application reload") + val current = _appState.value + if (current is KiloAppState.Ready) load() + } } } } @@ -379,10 +382,12 @@ class KiloBackendAppService private constructor( } private fun clear() { + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + } workspaces.stop() sessions.stop() - loader?.cancel() - eventWatcher?.cancel() profile = null config = null notifications = emptyList() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt index 83890ed30c4..52980482c68 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt @@ -2,7 +2,10 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager +import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import kotlinx.coroutines.Dispatchers @@ -12,6 +15,7 @@ import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.security.SecureRandom +import java.util.UUID import java.util.concurrent.TimeUnit /** @@ -145,6 +149,9 @@ class KiloBackendCliManager( put("KILO_ENABLE_QUESTION_TOOL", "true") put("KILO_PLATFORM", "jetbrains") put("KILO_APP_NAME", "kilo-code") + put("KILO_DISABLE_CLAUDE_CODE", "true") + put("KILOCODE_FEATURE", "jetbrains-plugin") + ideEnv().forEach { (k, v) -> put(k, v) } } val cmd = listOf(cli.absolutePath, "serve", "--port", "0") @@ -275,6 +282,44 @@ class KiloBackendCliManager( return "$os-$arch" } + /** + * Collect IDE-specific env vars for telemetry and gateway attribution. + * Catches all exceptions since these are best-effort — missing values + * won't prevent the CLI from starting. + */ + private fun ideEnv(): Map = buildMap { + runCatching { + val info = ApplicationInfo.getInstance() + val name = info.fullApplicationName + val build = info.build.asString() + put("KILO_EDITOR_NAME", name) + put("KILOCODE_EDITOR_NAME", "$name $build") + }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } + + runCatching { + val version = PluginManagerCore + .getPlugin(PluginId.getId("ai.kilocode"))?.version + if (version != null) put("KILO_APP_VERSION", version) + }.onFailure { log.info("Could not read plugin version: ${it.message}") } + + runCatching { + put("KILO_MACHINE_ID", machineId()) + }.onFailure { log.info("Could not read machine ID: ${it.message}") } + } + + /** + * Persistent machine ID stored in the IntelliJ system directory. + * Generated once and reused across restarts. + */ + private fun machineId(): String { + val file = File(PathManager.getSystemPath(), "kilo/machine-id") + if (file.exists()) return file.readText().trim() + val id = UUID.randomUUID().toString() + file.parentFile.mkdirs() + file.writeText(id) + return id + } + private fun generatePassword(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index 23bd1bc10db..dbcfc2fe774 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -197,6 +197,9 @@ class KiloConnectionService( .readTimeout(0, TimeUnit.MILLISECONDS) .build() ) + // Reset heartbeat timestamp before connecting so the watcher + // doesn't fire against a stale timestamp from the old connection. + lastEvent.set(System.currentTimeMillis()) source.set(factory.newEventSource(request, listener)) log.info("SSE: connecting to port $port") } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 485811147c6..330cb46c61b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -131,7 +132,7 @@ class KiloBackendSessionManager( try { val raw = requireClient().sessionStatus(directory = dir) val mapped = raw.mapValues { (_, v) -> statusDto(v) } - _statuses.value = _statuses.value + mapped + _statuses.update { it + mapped } log.info("Seeded ${mapped.size} session statuses for $dir") } catch (e: Exception) { log.warn("Session status seed failed: ${e.message}", e) @@ -153,7 +154,7 @@ class KiloBackendSessionManager( val id = extractField(data, "sessionID") ?: return val type = extractNested(data, "status", "type") ?: "idle" val msg = extractNested(data, "status", "message") - _statuses.value = _statuses.value + (id to SessionStatusDto(type, msg)) + _statuses.update { it + (id to SessionStatusDto(type, msg)) } } // ------ mapping ------ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 5dbf8fde8ad..9f69228a851 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -49,11 +50,14 @@ class KiloBackendWorkspace( private var loader: Job? = null private var eventWatcher: Job? = null + private val loadLock = Any() /** Load project data (providers, agents, commands, skills). */ fun load() { - loader?.cancel() - loader = cs.launch { + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + loader = cs.launch { log.info("Loading workspace data for $directory") val progress = AtomicReference(KiloWorkspaceLoadProgress()) _state.value = KiloWorkspaceState.Loading(progress.get()) @@ -119,6 +123,7 @@ class KiloBackendWorkspace( skills = sk!!, ) log.info("Workspace data loaded for $directory") + ensureActive() startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e @@ -128,6 +133,7 @@ class KiloBackendWorkspace( "Failed to load: ${synchronized(errors) { errors.joinToString() }}" ) } + } } } @@ -138,8 +144,10 @@ class KiloBackendWorkspace( /** Stop all background work. */ fun stop() { - loader?.cancel() - eventWatcher?.cancel() + synchronized(loadLock) { + loader?.cancel() + eventWatcher?.cancel() + } _state.value = KiloWorkspaceState.Pending } @@ -161,18 +169,20 @@ class KiloBackendWorkspace( * Idempotent — only one watcher runs at a time. */ private fun startWatchingGlobalSseEvents() { - if (eventWatcher?.isActive == true) return - log.info("Started watching global SSE events for workspace $directory") - eventWatcher = cs.launch { - events.collect { event -> - when (event.type) { - "global.disposed" -> { - log.info("SSE global.disposed — reloading workspace data for $directory") - load() - } - "server.instance.disposed" -> { - log.info("SSE server.instance.disposed — reloading workspace data for $directory") - load() + synchronized(loadLock) { + if (eventWatcher?.isActive == true) return + log.info("Started watching global SSE events for workspace $directory") + eventWatcher = cs.launch { + events.collect { event -> + when (event.type) { + "global.disposed" -> { + log.info("SSE global.disposed — reloading workspace data for $directory") + load() + } + "server.instance.disposed" -> { + log.info("SSE server.instance.disposed — reloading workspace data for $directory") + load() + } } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 074331c8a8a..c69dd734539 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -60,9 +60,9 @@ class KiloBackendWorkspaceManager( fun get(dir: String): KiloBackendWorkspace { val client = api ?: throw IllegalStateException("Workspace manager not started") val ev = events!! - return workspaces.getOrPut(dir) { - log.info("Creating workspace for $dir") - KiloBackendWorkspace(dir, cs, client, ev, sessions, log).also { it.load() } + return workspaces.computeIfAbsent(dir) { d -> + log.info("Creating workspace for $d") + KiloBackendWorkspace(d, cs, client, ev, sessions, log).also { it.load() } } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt index 23c09ec4482..620420241cb 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt @@ -246,4 +246,84 @@ class KiloBackendAppServiceTest { assertEquals("updated", svc.config?.model) } + + // ------ Concurrency & lifecycle tests ------ + + @Test + fun `rapid disposed events produce single valid Ready`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + mock.awaitSseConnection() + + // Fire rapid global.disposed events to trigger concurrent load() calls + repeat(5) { + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + } + + // Wait for the app to settle back to Ready + withTimeout(15_000) { + // Allow transient Loading states, wait for final Ready + while (true) { + val state = svc.appState.value + if (state is KiloAppState.Ready) { + // Verify it's stable + delay(500) + if (svc.appState.value is KiloAppState.Ready) break + } + delay(100) + } + } + + assertIs(svc.appState.value) + assertNotNull(svc.config) + } + + @Test + fun `restart lifecycle transitions correctly`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + // Restart should tear down and reconnect + svc.restart() + + // Should transition back to Ready after restart + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + assertNotNull(svc.config) + } + + @Test + fun `reconnect after SSE close restores Ready state`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + // Close SSE to trigger reconnect path + mock.closeSse() + + // Should eventually recover to Connected/Ready through reconnect + // (connection service reconnects SSE if process is alive — but + // FakeCliServer returns no process, so it delegates to onReconnect + // which calls reconnect() under mutex) + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertIs(svc.appState.value) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt index 62884026948..506a2b2e424 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlin.test.AfterTest @@ -246,6 +248,68 @@ class KiloBackendSessionManagerTest { assertTrue(app.sessions.statuses.value.isEmpty()) } + // ------ Concurrency ------ + + @Test + fun `concurrent status updates are not lost`() = runBlocking { + mock.sessionStatuses = "{}" + val app = setup() + ready(app) + + // Seed statuses from multiple coroutines while SSE events arrive + val ids = (1..20).map { "ses_concurrent_$it" } + val half = ids.size / 2 + + // First half: seed via SSE events + mock.awaitSseConnection() + ids.take(half).forEach { id -> + mock.pushEvent( + "session.status", + """{"type":"session.status","properties":{"sessionID":"$id","status":{"type":"busy"}}}""", + ) + } + + // Second half: seed via server-side status endpoint + val statusJson = ids.drop(half).joinToString(",") { id -> + """"$id":{"type":"idle","attempt":0,"message":"","next":0,"requestID":""}""" + } + mock.sessionStatuses = "{$statusJson}" + app.sessions.seed("/test") + + // Wait for all statuses to be present + withTimeout(10_000) { + while (true) { + val statuses = app.sessions.statuses.value + val found = ids.count { it in statuses } + if (found == ids.size) break + delay(100) + } + } + + val statuses = app.sessions.statuses.value + ids.forEach { id -> assertTrue(id in statuses, "Missing status for $id") } + } + + @Test + fun `start after stop re-activates`() = runBlocking { + val app = setup() + ready(app) + + // Verify it works + app.sessions.list("/test") + + // Stop and restart + app.sessions.stop() + assertFailsWith { app.sessions.list("/test") } + + // Re-start (simulate what app service does on reconnect) + app.sessions.start(app.api!!, app.events) + + // CRUD should work again + val result = app.sessions.list("/test") + assertNotNull(result) + } + // ------ Session with summary ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt index 6f462aa861a..12147a8997f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt @@ -10,6 +10,8 @@ import ai.kilocode.backend.testing.TestLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -361,6 +363,110 @@ class KiloBackendWorkspaceTest { assertEquals("/test/project", session.directory) } + // ------ Concurrency tests ------ + + @Test + fun `concurrent get for same directory returns same instance`() = runBlocking { + val app = setup() + app.connect() + withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } } + + // Launch many concurrent get() calls for the same directory + val results = (1..10).map { + async(Dispatchers.Default) { + app.workspaces.get("/same/dir") + } + }.awaitAll() + + // All must return the exact same instance + val first = results[0] + results.forEach { assertTrue(it === first) } + } + + @Test + fun `concurrent load calls on workspace produce valid final state`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + // Fire rapid reloads — simulates rapid SSE disposed events + 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) + } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals(1, state.providers.providers.size) + assertEquals(1, state.agents.agents.size) + } + + @Test + fun `SSE global disposed triggers full app reload with new data`() = runBlocking { + mock.providers = PROVIDERS_JSON + mock.agents = AGENTS_JSON + mock.commands = COMMANDS_JSON + mock.skills = SKILLS_JSON + + val app = setup() + ready(app) + + // Change providers response then fire disposed event + mock.providers = """{ + "all": [{ + "id": "openai", + "name": "OpenAI", + "env": [], + "models": {} + }], + "default": {}, + "connected": ["openai"] + }""" + + mock.awaitSseConnection() + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + // global.disposed triggers full app reload which restarts the + // workspace manager (stop + start), clearing all cached workspaces. + // Wait for app to reach Ready again after reload. + withTimeout(15_000) { + // App may briefly leave Ready during reload + while (true) { + val state = app.appState.value + if (state is KiloAppState.Ready) { + delay(300) + if (app.appState.value is KiloAppState.Ready) break + } + delay(100) + } + } + + // Get a fresh workspace — old one was stopped during reload + val ws = app.workspaces.get("/test/project") + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Ready } + } + + val state = ws.state.value as KiloWorkspaceState.Ready + assertEquals("openai", state.providers.providers[0].id) + } + companion object { private val PROVIDERS_JSON = """{ "all": [{ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt index 9f51fa364f0..92a3aa7147b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt @@ -173,4 +173,49 @@ class KiloConnectionServiceTest { svc.dispose() assertEquals(ConnectionState.Disconnected, svc.state.value) } + + // ------ Reconnect & health ------ + + @Test + fun `restart tears down and reconnects`() = runBlocking { + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + // Restart should tear down and re-open + svc.restart() + + // Wait for reconnection + withTimeout(10_000) { + svc.state.first { it is ConnectionState.Connected } + } + assertIs(svc.state.value) + assertTrue(svc.api != null) + } + + @Test + fun `health check failure triggers reconnect`() = runBlocking { + // Start healthy then switch to failing health + val svc = KiloConnectionService(scope, fake, {}, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + // Make health checks fail — the health loop runs every 10s, so we + // close the SSE first (which triggers immediate reconnect path) + mock.health = """{"healthy": false}""" + mock.closeSse() + + // Should transition away from Connected + withTimeout(10_000) { + svc.state.first { it !is ConnectionState.Connected } + } + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 7dcfb3772c7..60b27d1fa9a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -10,6 +10,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.ConcurrentHashMap /** * Lightweight mock HTTP server simulating the Kilo CLI server. @@ -54,6 +56,18 @@ class MockCliServer : AutoCloseable { @Volatile var sessionDeleteStatus = 200 @Volatile var sessionStatusesStatus = 200 + /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ + @Volatile var responseDelay: Long = 0 + + /** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */ + private val counts = ConcurrentHashMap() + + /** Return the number of requests received for [path] (bare, no query). */ + fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + + /** Reset all request counters. */ + fun resetCounts() { counts.clear() } + private val executor = Executors.newCachedThreadPool { r -> Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true } } @@ -160,6 +174,13 @@ class MockCliServer : AutoCloseable { val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream())) val bare = path.substringBefore("?") + // Track request counts + counts.computeIfAbsent(bare) { AtomicInteger(0) }.incrementAndGet() + + // Optional delay for race condition testing + val delay = responseDelay + if (delay > 0) Thread.sleep(delay) + when { path == "/global/health" -> respond(output, 200, health) path == "/global/config" -> respond(output, configStatus, config) From cc373d9edb80d34a492422d3922e9d381d9152b4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 18:57:55 -0400 Subject: [PATCH 07/13] moving things --- .../ai/kilocode/backend/app/KiloBackendConnectionService.kt | 2 +- .../ai/kilocode/backend/{ => util}/KiloBackendHttpClients.kt | 2 +- .../kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) rename packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/{ => util}/KiloBackendHttpClients.kt (98%) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index dbcfc2fe774..15e8d8b6e77 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,7 +1,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.KiloBackendHttpClients +import ai.kilocode.backend.util.KiloBackendHttpClients import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt similarity index 98% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt index dc997ef302e..bfb3397aaaf 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.util import okhttp3.ConnectionPool import okhttp3.Interceptor diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt index b4e1d84f82e..a9ed85d2a64 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend +import ai.kilocode.backend.util.KiloBackendHttpClients import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import java.util.Base64 From 6e50b54921febe9c65c965d49f32709c143af3ce Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 21:53:41 -0400 Subject: [PATCH 08/13] fix(jetbrains): fix tool window rendering, workspace state flow, and provider deserialization - Fix blank tool window by creating CoroutineScope manually instead of retrieving it via project.service() which throws - Gate workspace RPC state flow on app Ready via flatMapLatest to prevent IllegalStateException when workspace manager isn't started yet - Include LoadProgress in Ready state DTO so the UI shows loaded data - Add 'active' to model status enum in OpenAPI spec (server returns it) - Add default values for non-nullable primitives in generated API models and enable coerceInputValues/explicitNulls=false in JSON deserializer to prevent MissingFieldException on dynamically added models --- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 10 +- .../backend/rpc/KiloProjectRpcApiImpl.kt | 31 +- .../src/main/kotlin/FixGeneratedApiTask.kt | 46 +- .../kilocode/client/KiloToolWindowFactory.kt | 29 +- .../ai/kilocode/client/KiloWelcomeUi.kt | 2 + packages/opencode/src/provider/models.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 38 +- packages/sdk/openapi.json | 2419 +++++++++++++---- 8 files changed, 2075 insertions(+), 502 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 00280d47937..e11f315a3fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -48,7 +48,15 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { status = KiloAppStatusDto.LOADING, progress = progress(state.progress), ) - is KiloAppState.Ready -> KiloAppStateDto(KiloAppStatusDto.READY) + is KiloAppState.Ready -> KiloAppStateDto( + status = KiloAppStatusDto.READY, + progress = LoadProgressDto( + config = true, + notifications = true, + profile = if (state.data.profile != null) ProfileStatusDto.LOADED + else ProfileStatusDto.NOT_LOGGED_IN, + ), + ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, error = state.message, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt index 28c6ce534e8..69335928b28 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo @@ -25,8 +26,11 @@ import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** @@ -38,15 +42,32 @@ import kotlinx.coroutines.flow.map */ class KiloProjectRpcApiImpl : KiloProjectRpcApi { - private val manager: KiloBackendWorkspaceManager - get() = service().workspaces + private val app: KiloBackendAppService get() = service() + private val manager: KiloBackendWorkspaceManager + get() = app.workspaces + + /** + * Emits workspace state for [directory]. Waits for the app to + * reach [KiloAppState.Ready] before creating the workspace — + * until then, emits [KiloWorkspaceStatusDto.PENDING]. + * + * When the app leaves Ready (e.g. during restart/reconnect), + * the flow falls back to PENDING again and re-subscribes to + * the new workspace once Ready returns. + */ + @OptIn(ExperimentalCoroutinesApi::class) override suspend fun state(directory: String): Flow = - manager.get(directory).state - .map(::dto) - .distinctUntilChanged() + app.appState.flatMapLatest { state -> + if (state is KiloAppState.Ready) { + manager.get(directory).state.map(::dto) + } else { + flowOf(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + } + }.distinctUntilChanged() override suspend fun reload(directory: String) { + if (app.appState.value !is KiloAppState.Ready) return manager.get(directory).reload() } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 6f9ebb2f898..75c1b9f3a5b 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -120,7 +120,51 @@ abstract class FixGeneratedApiTask : DefaultTask() { } } - // Fix 6: AnySerializer in Serializer.kt + // Fix 6: Lenient JSON — tolerate missing fields and absent nulls so the + // generated models survive API responses with optional fields the spec + // marks as required. `coerceInputValues` maps type mismatches to + // defaults; `explicitNulls = false` allows omitted nullable fields. + if (file.name == "Serializer.kt") { + if (!text.contains("coerceInputValues")) { + text = text.replace( + "ignoreUnknownKeys = true", + "ignoreUnknownKeys = true\n coerceInputValues = true\n explicitNulls = false" + ) + changed = true + } + } + + // Fix 7: Default values for non-nullable primitives in model data classes. + // The CLI API may omit fields that the OpenAPI spec marks as required + // (e.g. `attachment`, `reasoning` on dynamically added models). + // Add Kotlin defaults so kotlinx.serialization doesn't throw + // MissingFieldException. + if (text.contains("data class") && text.contains("@Serializable")) { + // Pattern: `val foo: kotlin.Boolean,` or `val foo: kotlin.Boolean\n` + // (without ` = ` before the comma/newline, which would mean a default exists) + val primitiveDefaults = listOf( + Regex("""(val \w+:\s*kotlin\.Boolean)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = false${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.Int)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.Double)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0.0${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.String)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = \"\"${m.groupValues[2]}" + }, + ) + for ((pattern, transform) in primitiveDefaults) { + if (pattern.containsMatchIn(text)) { + text = pattern.replace(text, transform) + changed = true + } + } + } + + // Fix 8: AnySerializer in Serializer.kt if (file.name == "Serializer.kt" && !text.contains("AnySerializer")) { text = text.replace( "import kotlinx.serialization.modules.SerializersModuleBuilder", diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 8fd8f0c017d..dad0fd07170 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -2,11 +2,13 @@ package ai.kilocode.client import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob /** * Creates the Kilo Code tool window content. @@ -16,18 +18,27 @@ import kotlinx.coroutines.CoroutineScope * status. All UI logic lives in [KiloWelcomeUi]. */ class KiloToolWindowFactory : ToolWindowFactory { + + companion object { + private val LOG = Logger.getInstance(KiloToolWindowFactory::class.java) + } + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val app = service() - val workspace = project.service() - val scope = project.service() - val ui = KiloWelcomeUi(app, workspace, scope) + try { + val app = service() + val workspace = project.service() + val scope = CoroutineScope(SupervisorJob()) + val ui = KiloWelcomeUi(app, workspace, scope) - val content = ContentFactory.getInstance().createContent(ui, "", false) - content.setDisposer(ui) - toolWindow.contentManager.addContent(content) + val content = ContentFactory.getInstance().createContent(ui, "", false) + content.setDisposer(ui) + toolWindow.contentManager.addContent(content) - ActionManager.getInstance().getAction("Kilo.Settings")?.let { - toolWindow.setTitleActions(listOf(it)) + ActionManager.getInstance().getAction("Kilo.Settings")?.let { + toolWindow.setTitleActions(listOf(it)) + } + } catch (e: Exception) { + LOG.error("Failed to create Kilo tool window content", e) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt index ed1e60990df..c346cf2de61 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt @@ -19,6 +19,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.awt.GridBagConstraints import java.awt.GridBagLayout @@ -110,6 +111,7 @@ class KiloWelcomeUi( override fun dispose() { appJob?.cancel() wsJob?.cancel() + cs.cancel() } // ------ rendering ------ diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 9d5e4d0047f..cf1943c57bc 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -93,7 +93,7 @@ export namespace ModelsDev { // kilocode_change end experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), + status: z.enum(["alpha", "beta", "deprecated", "active"]).optional(), // kilocode_change options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6f748a43df4..86f1f94cea6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -332,6 +332,21 @@ export type EventTodoUpdated = { } } +export type EventSessionTurnOpen = { + type: "session.turn.open" + properties: { + sessionID: string + } +} + +export type EventSessionTurnClose = { + type: "session.turn.close" + properties: { + sessionID: string + reason: "completed" | "error" | "interrupted" + } +} + export type SessionStatus = | { type: "idle" @@ -398,21 +413,6 @@ export type EventCommandExecuted = { } } -export type EventSessionTurnOpen = { - type: "session.turn.open" - properties: { - sessionID: string - } -} - -export type EventSessionTurnClose = { - type: "session.turn.close" - properties: { - sessionID: string - reason: "completed" | "error" | "interrupted" - } -} - export type FileDiff = { file: string before: string @@ -1075,14 +1075,14 @@ export type Event = | EventQuestionReplied | EventQuestionRejected | EventTodoUpdated + | EventSessionTurnOpen + | EventSessionTurnClose | EventSessionStatus | EventSessionIdle | EventSessionCompacted | EventFileEdited | EventFileWatcherUpdated | EventCommandExecuted - | EventSessionTurnOpen - | EventSessionTurnClose | EventSessionDiff | EventSessionError | EventVcsBranchUpdated @@ -1392,7 +1392,7 @@ export type ProviderConfig = { isFree?: boolean ai_sdk_provider?: "anthropic" | "openai" | "openai-compatible" | "openrouter" experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -4516,7 +4516,7 @@ export type ProviderListResponses = { isFree?: boolean ai_sdk_provider?: "anthropic" | "openai" | "openai-compatible" | "openrouter" experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 78f63f583be..d5a0f13a44c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27,7 +27,10 @@ "type": "string" } }, - "required": ["healthy", "version"] + "required": [ + "healthy", + "version" + ] } } } @@ -206,7 +209,10 @@ "type": "string" } }, - "required": ["success", "version"] + "required": [ + "success", + "version" + ] }, { "type": "object", @@ -219,7 +225,10 @@ "type": "string" } }, - "required": ["success", "error"] + "required": [ + "success", + "error" + ] } ] } @@ -412,7 +421,12 @@ "level": { "description": "Log level", "type": "string", - "enum": ["debug", "info", "error", "warn"] + "enum": [ + "debug", + "info", + "error", + "warn" + ] }, "message": { "description": "Log message", @@ -427,7 +441,11 @@ "additionalProperties": {} } }, - "required": ["service", "level", "message"] + "required": [ + "service", + "level", + "message" + ] } } } @@ -929,7 +947,10 @@ "type": "number" } }, - "required": ["rows", "cols"] + "required": [ + "rows", + "cols" + ] } } } @@ -1202,7 +1223,10 @@ "type": "string" } }, - "required": ["path", "message"] + "required": [ + "path", + "message" + ] } } } @@ -1262,7 +1286,10 @@ } } }, - "required": ["providers", "default"] + "required": [ + "providers", + "default" + ] } } } @@ -1469,7 +1496,11 @@ ] } }, - "required": ["type", "branch", "extra"] + "required": [ + "type", + "branch", + "extra" + ] } } } @@ -2400,7 +2431,9 @@ ], "summary": "Get session", "description": "Retrieve detailed information about a specific Kilo session.", - "tags": ["Session"], + "tags": [ + "Session" + ], "responses": { "200": { "description": "Get session", @@ -2628,7 +2661,9 @@ } ], "summary": "Get session children", - "tags": ["Session"], + "tags": [ + "Session" + ], "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { @@ -2825,7 +2860,11 @@ "pattern": "^msg.*" } }, - "required": ["modelID", "providerID", "messageID"] + "required": [ + "modelID", + "providerID", + "messageID" + ] } } } @@ -3251,7 +3290,10 @@ "type": "boolean" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] } } } @@ -3331,7 +3373,10 @@ } } }, - "required": ["info", "parts"] + "required": [ + "info", + "parts" + ] } } } @@ -3412,7 +3457,10 @@ } } }, - "required": ["info", "parts"] + "required": [ + "info", + "parts" + ] } } } @@ -3458,7 +3506,10 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "agent": { "type": "string" @@ -3528,7 +3579,9 @@ } } }, - "required": ["parts"] + "required": [ + "parts" + ] } } } @@ -3598,7 +3651,10 @@ } } }, - "required": ["info", "parts"] + "required": [ + "info", + "parts" + ] } } } @@ -3965,7 +4021,10 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "agent": { "type": "string" @@ -4035,7 +4094,9 @@ } } }, - "required": ["parts"] + "required": [ + "parts" + ] } } } @@ -4096,7 +4157,10 @@ } } }, - "required": ["info", "parts"] + "required": [ + "info", + "parts" + ] } } } @@ -4175,13 +4239,20 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["type", "mime", "url"] + "required": [ + "type", + "mime", + "url" + ] } ] } } }, - "required": ["arguments", "command"] + "required": [ + "arguments", + "command" + ] } } } @@ -4275,13 +4346,19 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "command": { "type": "string" } }, - "required": ["agent", "command"] + "required": [ + "agent", + "command" + ] } } } @@ -4371,7 +4448,9 @@ "pattern": "^prt.*" } }, - "required": ["messageID"] + "required": [ + "messageID" + ] } } } @@ -4534,10 +4613,16 @@ "properties": { "response": { "type": "string", - "enum": ["once", "always", "reject"] + "enum": [ + "once", + "always", + "reject" + ] } }, - "required": ["response"] + "required": [ + "response" + ] } } } @@ -4684,13 +4769,19 @@ "properties": { "reply": { "type": "string", - "enum": ["once", "always", "reject"] + "enum": [ + "once", + "always", + "reject" + ] }, "message": { "type": "string" } }, - "required": ["reply"] + "required": [ + "reply" + ] } } } @@ -4960,7 +5051,9 @@ } } }, - "required": ["answers"] + "required": [ + "answers" + ] } } } @@ -5138,10 +5231,15 @@ "properties": { "field": { "type": "string", - "enum": ["reasoning_content", "reasoning_details"] + "enum": [ + "reasoning_content", + "reasoning_details" + ] } }, - "required": ["field"], + "required": [ + "field" + ], "additionalProperties": false } ] @@ -5177,10 +5275,16 @@ "type": "number" } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] }, "limit": { "type": "object", @@ -5195,7 +5299,10 @@ "type": "number" } }, - "required": ["context", "output"] + "required": [ + "context", + "output" + ] }, "modalities": { "type": "object", @@ -5204,39 +5311,71 @@ "type": "array", "items": { "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] } }, "output": { "type": "array", "items": { "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] } } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] + "enum": [ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo" + ] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] }, "options": { "type": "object", @@ -5293,7 +5432,12 @@ } } }, - "required": ["name", "env", "id", "models"] + "required": [ + "name", + "env", + "id", + "models" + ] } }, "default": { @@ -5312,7 +5456,11 @@ } } }, - "required": ["all", "default", "connected"] + "required": [ + "all", + "default", + "connected" + ] } } } @@ -5449,7 +5597,9 @@ } } }, - "required": ["method"] + "required": [ + "method" + ] } } } @@ -5529,7 +5679,9 @@ "type": "string" } }, - "required": ["method"] + "required": [ + "method" + ] } } } @@ -5588,7 +5740,9 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] }, "lines": { "type": "object", @@ -5597,7 +5751,9 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] }, "line_number": { "type": "number" @@ -5617,7 +5773,9 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] }, "start": { "type": "number" @@ -5626,11 +5784,21 @@ "type": "number" } }, - "required": ["match", "start", "end"] + "required": [ + "match", + "start", + "end" + ] } } }, - "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] + "required": [ + "path", + "lines", + "line_number", + "absolute_offset", + "submatches" + ] } } } @@ -5676,7 +5844,10 @@ "name": "dirs", "schema": { "type": "string", - "enum": ["true", "false"] + "enum": [ + "true", + "false" + ] } }, { @@ -5684,7 +5855,10 @@ "name": "type", "schema": { "type": "string", - "enum": ["file", "directory"] + "enum": [ + "file", + "directory" + ] } }, { @@ -6074,7 +6248,10 @@ ] } }, - "required": ["name", "config"] + "required": [ + "name", + "config" + ] } } } @@ -6129,7 +6306,9 @@ "type": "string" } }, - "required": ["authorizationUrl"] + "required": [ + "authorizationUrl" + ] } } } @@ -6203,7 +6382,9 @@ "const": true } }, - "required": ["success"] + "required": [ + "success" + ] } } } @@ -6299,7 +6480,9 @@ "type": "string" } }, - "required": ["code"] + "required": [ + "code" + ] } } } @@ -6530,7 +6713,9 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] } } } @@ -6842,7 +7027,9 @@ "type": "string" } }, - "required": ["command"] + "required": [ + "command" + ] } } } @@ -6902,7 +7089,12 @@ }, "variant": { "type": "string", - "enum": ["info", "success", "warning", "error"] + "enum": [ + "info", + "success", + "warning", + "error" + ] }, "duration": { "description": "Duration in milliseconds", @@ -6910,7 +7102,10 @@ "type": "number" } }, - "required": ["message", "variant"] + "required": [ + "message", + "variant" + ] } } } @@ -7061,7 +7256,9 @@ "pattern": "^ses.*" } }, - "required": ["sessionID"] + "required": [ + "sessionID" + ] } } } @@ -7108,7 +7305,10 @@ }, "body": {} }, - "required": ["path", "body"] + "required": [ + "path", + "body" + ] } } } @@ -7425,7 +7625,12 @@ "type": "string" } }, - "required": ["name", "description", "location", "content"] + "required": [ + "name", + "description", + "location", + "content" + ] } } } @@ -7597,7 +7802,9 @@ "type": "string" } }, - "required": ["enable"] + "required": [ + "enable" + ] } } } @@ -7854,7 +8061,9 @@ "additionalProperties": {} } }, - "required": ["event"] + "required": [ + "event" + ] } } } @@ -7903,7 +8112,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -7953,7 +8165,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -8003,7 +8218,10 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } } } @@ -8050,7 +8268,9 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } } } @@ -8088,7 +8308,9 @@ "type": "string" } }, - "required": ["path"] + "required": [ + "path" + ] } } } @@ -8134,7 +8356,9 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] } } } @@ -8162,7 +8386,9 @@ "minLength": 1 } }, - "required": ["text"] + "required": [ + "text" + ] } } } @@ -8214,7 +8440,10 @@ "type": "boolean" } }, - "required": ["ok", "id"] + "required": [ + "ok", + "id" + ] } } } @@ -8278,7 +8507,13 @@ } } }, - "required": ["id", "worktree", "timeCreated", "timeUpdated", "sandboxes"] + "required": [ + "id", + "worktree", + "timeCreated", + "timeUpdated", + "sandboxes" + ] } } } @@ -8330,7 +8565,10 @@ "type": "boolean" } }, - "required": ["ok", "id"] + "required": [ + "ok", + "id" + ] } } } @@ -8405,7 +8643,11 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": [ + "additions", + "deletions", + "files" + ] }, "revert": { "type": "object", @@ -8423,7 +8665,9 @@ "type": "string" } }, - "required": ["messageID"] + "required": [ + "messageID" + ] }, "permission": { "type": "object", @@ -8445,7 +8689,16 @@ "type": "number" } }, - "required": ["id", "projectID", "slug", "directory", "title", "version", "timeCreated", "timeUpdated"] + "required": [ + "id", + "projectID", + "slug", + "directory", + "title", + "version", + "timeCreated", + "timeUpdated" + ] } } } @@ -8497,7 +8750,10 @@ "type": "boolean" } }, - "required": ["ok", "id"] + "required": [ + "ok", + "id" + ] } } } @@ -8544,7 +8800,9 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] }, "agent": { "type": "string" @@ -8559,7 +8817,10 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "tools": { "type": "object", @@ -8571,7 +8832,12 @@ } } }, - "required": ["role", "time", "agent", "model"] + "required": [ + "role", + "time", + "agent", + "model" + ] }, { "type": "object", @@ -8590,7 +8856,9 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] }, "parentID": { "type": "string" @@ -8617,7 +8885,10 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": [ + "cwd", + "root" + ] }, "summary": { "type": "boolean" @@ -8650,10 +8921,18 @@ "type": "number" } }, - "required": ["read", "write"] + "required": [ + "read", + "write" + ] } }, - "required": ["input", "output", "reasoning", "cache"] + "required": [ + "input", + "output", + "reasoning", + "cache" + ] }, "structured": {}, "variant": { @@ -8679,7 +8958,12 @@ ] } }, - "required": ["id", "sessionID", "timeCreated", "data"] + "required": [ + "id", + "sessionID", + "timeCreated", + "data" + ] } } } @@ -8731,7 +9015,10 @@ "type": "boolean" } }, - "required": ["ok", "id"] + "required": [ + "ok", + "id" + ] } } } @@ -8793,7 +9080,9 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] }, "metadata": { "type": "object", @@ -8803,7 +9092,10 @@ "additionalProperties": {} } }, - "required": ["type", "text"] + "required": [ + "type", + "text" + ] }, { "type": "object", @@ -8832,10 +9124,16 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] } }, - "required": ["type", "text", "time"] + "required": [ + "type", + "text", + "time" + ] }, { "type": "object", @@ -8870,7 +9168,11 @@ "type": "string" } }, - "required": ["status", "input", "raw"] + "required": [ + "status", + "input", + "raw" + ] }, { "type": "object", @@ -8903,10 +9205,16 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] } }, - "required": ["status", "input", "time"] + "required": [ + "status", + "input", + "time" + ] }, { "type": "object", @@ -8948,10 +9256,20 @@ "type": "number" } }, - "required": ["start", "end"] + "required": [ + "start", + "end" + ] } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ] }, { "type": "object", @@ -8987,10 +9305,18 @@ "type": "number" } }, - "required": ["start", "end"] + "required": [ + "start", + "end" + ] } }, - "required": ["status", "input", "error", "time"] + "required": [ + "status", + "input", + "error", + "time" + ] } ] }, @@ -9002,12 +9328,22 @@ "additionalProperties": {} } }, - "required": ["type", "callID", "tool", "state"] + "required": [ + "type", + "callID", + "tool", + "state" + ] } ] } }, - "required": ["id", "messageID", "sessionID", "data"] + "required": [ + "id", + "messageID", + "sessionID", + "data" + ] } } } @@ -9073,7 +9409,9 @@ "type": "string" } }, - "required": ["location"] + "required": [ + "location" + ] } } } @@ -9139,7 +9477,9 @@ "type": "string" } }, - "required": ["name"] + "required": [ + "name" + ] } } } @@ -9205,11 +9545,17 @@ "type": "string" } }, - "required": ["id", "name", "role"] + "required": [ + "id", + "name", + "role" + ] } } }, - "required": ["email"] + "required": [ + "email" + ] }, "balance": { "anyOf": [ @@ -9220,7 +9566,9 @@ "type": "number" } }, - "required": ["balance"] + "required": [ + "balance" + ] }, { "type": "null" @@ -9238,7 +9586,11 @@ ] } }, - "required": ["profile", "balance", "currentOrgId"] + "required": [ + "profile", + "balance", + "currentOrgId" + ] } } } @@ -9322,7 +9674,9 @@ ] } }, - "required": ["organizationId"] + "required": [ + "organizationId" + ] } } } @@ -9450,7 +9804,9 @@ } } }, - "required": ["modes"] + "required": [ + "modes" + ] } } } @@ -9561,7 +9917,10 @@ "type": "number" } }, - "required": ["prefix", "suffix"] + "required": [ + "prefix", + "suffix" + ] } } } @@ -9624,7 +9983,10 @@ "type": "string" } }, - "required": ["actionText", "actionURL"] + "required": [ + "actionText", + "actionURL" + ] }, "showIn": { "type": "array", @@ -9636,7 +9998,11 @@ "type": "string" } }, - "required": ["id", "title", "message"] + "required": [ + "id", + "title", + "message" + ] } } } @@ -9779,7 +10145,9 @@ "type": "string" } }, - "required": ["sessionId"] + "required": [ + "sessionId" + ] } } } @@ -9825,7 +10193,14 @@ "anyOf": [ { "type": "string", - "enum": ["provisioned", "starting", "restarting", "running", "stopped", "destroying"] + "enum": [ + "provisioned", + "starting", + "restarting", + "running", + "stopped", + "destroying" + ] }, { "type": "null" @@ -9848,7 +10223,10 @@ "type": "number" } }, - "required": ["cpus", "memory_mb"] + "required": [ + "cpus", + "memory_mb" + ] }, "openclawVersion": { "anyOf": [ @@ -9890,7 +10268,9 @@ "type": "string" } }, - "required": ["status"] + "required": [ + "status" + ] } } } @@ -9948,7 +10328,12 @@ "type": "string" } }, - "required": ["apiKey", "userId", "userToken", "channelId"] + "required": [ + "apiKey", + "userId", + "userToken", + "channelId" + ] }, { "type": "null" @@ -10045,7 +10430,13 @@ "type": "number" } }, - "required": ["session_id", "title", "created_at", "updated_at", "version"] + "required": [ + "session_id", + "title", + "created_at", + "updated_at", + "version" + ] } }, "nextCursor": { @@ -10059,7 +10450,10 @@ ] } }, - "required": ["cliSessions", "nextCursor"] + "required": [ + "cliSessions", + "nextCursor" + ] } } } @@ -10138,7 +10532,10 @@ "type": "number" } }, - "required": ["created", "updated"] + "required": [ + "created", + "updated" + ] }, "sandboxes": { "type": "array", @@ -10147,7 +10544,12 @@ } } }, - "required": ["id", "worktree", "time", "sandboxes"] + "required": [ + "id", + "worktree", + "time", + "sandboxes" + ] }, "Event.project.updated": { "type": "object", @@ -10160,7 +10562,10 @@ "$ref": "#/components/schemas/Project" } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.installation.updated": { "type": "object", @@ -10176,10 +10581,15 @@ "type": "string" } }, - "required": ["version"] + "required": [ + "version" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.installation.update-available": { "type": "object", @@ -10195,10 +10605,15 @@ "type": "string" } }, - "required": ["version"] + "required": [ + "version" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.server.instance.disposed": { "type": "object", @@ -10214,10 +10629,15 @@ "type": "string" } }, - "required": ["directory"] + "required": [ + "directory" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.server.connected": { "type": "object", @@ -10231,7 +10651,10 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.global.disposed": { "type": "object", @@ -10245,7 +10668,10 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.global.config.updated": { "type": "object", @@ -10259,7 +10685,10 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.lsp.client.diagnostics": { "type": "object", @@ -10278,10 +10707,16 @@ "type": "string" } }, - "required": ["serverID", "path"] + "required": [ + "serverID", + "path" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.lsp.updated": { "type": "object", @@ -10295,7 +10730,10 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.tui.prompt.append": { "type": "object", @@ -10311,10 +10749,15 @@ "type": "string" } }, - "required": ["text"] + "required": [ + "text" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.tui.command.execute": { "type": "object", @@ -10355,10 +10798,15 @@ ] } }, - "required": ["command"] + "required": [ + "command" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.tui.toast.show": { "type": "object", @@ -10378,7 +10826,12 @@ }, "variant": { "type": "string", - "enum": ["info", "success", "warning", "error"] + "enum": [ + "info", + "success", + "warning", + "error" + ] }, "duration": { "description": "Duration in milliseconds", @@ -10386,10 +10839,16 @@ "type": "number" } }, - "required": ["message", "variant"] + "required": [ + "message", + "variant" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.tui.session.select": { "type": "object", @@ -10407,10 +10866,15 @@ "pattern": "^ses.*" } }, - "required": ["sessionID"] + "required": [ + "sessionID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.mcp.tools.changed": { "type": "object", @@ -10426,10 +10890,15 @@ "type": "string" } }, - "required": ["server"] + "required": [ + "server" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.mcp.browser.open.failed": { "type": "object", @@ -10448,10 +10917,16 @@ "type": "string" } }, - "required": ["mcpName", "url"] + "required": [ + "mcpName", + "url" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "SessionNetworkWait": { "type": "object", @@ -10477,10 +10952,18 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] } }, - "required": ["id", "sessionID", "message", "restored", "time"] + "required": [ + "id", + "sessionID", + "message", + "restored", + "time" + ] }, "Event.session.network.asked": { "type": "object", @@ -10493,7 +10976,10 @@ "$ref": "#/components/schemas/SessionNetworkWait" } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.network.replied": { "type": "object", @@ -10512,10 +10998,16 @@ "type": "string" } }, - "required": ["sessionID", "requestID"] + "required": [ + "sessionID", + "requestID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.network.rejected": { "type": "object", @@ -10534,10 +11026,16 @@ "type": "string" } }, - "required": ["sessionID", "requestID"] + "required": [ + "sessionID", + "requestID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.network.restored": { "type": "object", @@ -10556,10 +11054,16 @@ "type": "string" } }, - "required": ["sessionID", "requestID"] + "required": [ + "sessionID", + "requestID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.message.part.delta": { "type": "object", @@ -10590,10 +11094,19 @@ "type": "string" } }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] + "required": [ + "sessionID", + "messageID", + "partID", + "field", + "delta" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "PermissionRequest": { "type": "object", @@ -10639,10 +11152,20 @@ "type": "string" } }, - "required": ["messageID", "callID"] + "required": [ + "messageID", + "callID" + ] } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + "required": [ + "id", + "sessionID", + "permission", + "patterns", + "metadata", + "always" + ] }, "Event.permission.asked": { "type": "object", @@ -10655,7 +11178,10 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.permission.replied": { "type": "object", @@ -10677,13 +11203,24 @@ }, "reply": { "type": "string", - "enum": ["once", "always", "reject"] + "enum": [ + "once", + "always", + "reject" + ] } }, - "required": ["sessionID", "requestID", "reply"] + "required": [ + "sessionID", + "requestID", + "reply" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "QuestionOption": { "type": "object", @@ -10697,7 +11234,10 @@ "type": "string" } }, - "required": ["label", "description"] + "required": [ + "label", + "description" + ] }, "QuestionInfo": { "type": "object", @@ -10726,7 +11266,11 @@ "type": "boolean" } }, - "required": ["question", "header", "options"] + "required": [ + "question", + "header", + "options" + ] }, "QuestionRequest": { "type": "object", @@ -10757,10 +11301,17 @@ "type": "string" } }, - "required": ["messageID", "callID"] + "required": [ + "messageID", + "callID" + ] } }, - "required": ["id", "sessionID", "questions"] + "required": [ + "id", + "sessionID", + "questions" + ] }, "Event.question.asked": { "type": "object", @@ -10773,7 +11324,10 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "QuestionAnswer": { "type": "array", @@ -10806,10 +11360,17 @@ } } }, - "required": ["sessionID", "requestID", "answers"] + "required": [ + "sessionID", + "requestID", + "answers" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.question.rejected": { "type": "object", @@ -10830,10 +11391,16 @@ "pattern": "^que.*" } }, - "required": ["sessionID", "requestID"] + "required": [ + "sessionID", + "requestID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Todo": { "type": "object", @@ -10851,7 +11418,11 @@ "type": "string" } }, - "required": ["content", "status", "priority"] + "required": [ + "content", + "status", + "priority" + ] }, "Event.todo.updated": { "type": "object", @@ -10874,10 +11445,73 @@ } } }, - "required": ["sessionID", "todos"] + "required": [ + "sessionID", + "todos" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] + }, + "Event.session.turn.open": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.open" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": [ + "sessionID" + ] + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.session.turn.close": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.close" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "completed", + "error", + "interrupted" + ] + } + }, + "required": [ + "sessionID", + "reason" + ] + } + }, + "required": [ + "type", + "properties" + ] }, "SessionStatus": { "anyOf": [ @@ -10889,7 +11523,9 @@ "const": "idle" } }, - "required": ["type"] + "required": [ + "type" + ] }, { "type": "object", @@ -10908,7 +11544,12 @@ "type": "number" } }, - "required": ["type", "attempt", "message", "next"] + "required": [ + "type", + "attempt", + "message", + "next" + ] }, { "type": "object", @@ -10918,7 +11559,9 @@ "const": "busy" } }, - "required": ["type"] + "required": [ + "type" + ] }, { "type": "object", @@ -10934,7 +11577,11 @@ "type": "string" } }, - "required": ["type", "requestID", "message"] + "required": [ + "type", + "requestID", + "message" + ] } ] }, @@ -10956,10 +11603,16 @@ "$ref": "#/components/schemas/SessionStatus" } }, - "required": ["sessionID", "status"] + "required": [ + "sessionID", + "status" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.idle": { "type": "object", @@ -10976,10 +11629,15 @@ "pattern": "^ses.*" } }, - "required": ["sessionID"] + "required": [ + "sessionID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.compacted": { "type": "object", @@ -10996,10 +11654,15 @@ "pattern": "^ses.*" } }, - "required": ["sessionID"] + "required": [ + "sessionID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.file.edited": { "type": "object", @@ -11015,10 +11678,15 @@ "type": "string" } }, - "required": ["file"] + "required": [ + "file" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.file.watcher.updated": { "type": "object", @@ -11050,10 +11718,16 @@ ] } }, - "required": ["file", "event"] + "required": [ + "file", + "event" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.command.executed": { "type": "object", @@ -11080,52 +11754,18 @@ "pattern": "^msg.*" } }, - "required": ["name", "sessionID", "arguments", "messageID"] + "required": [ + "name", + "sessionID", + "arguments", + "messageID" + ] } }, - "required": ["type", "properties"] - }, - "Event.session.turn.open": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.open" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.turn.close": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.close" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "reason": { - "type": "string", - "enum": ["completed", "error", "interrupted"] - } - }, - "required": ["sessionID", "reason"] - } - }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "FileDiff": { "type": "object", @@ -11147,10 +11787,20 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] } }, - "required": ["file", "before", "after", "additions", "deletions"] + "required": [ + "file", + "before", + "after", + "additions", + "deletions" + ] }, "Event.session.diff": { "type": "object", @@ -11173,10 +11823,16 @@ } } }, - "required": ["sessionID", "diff"] + "required": [ + "sessionID", + "diff" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "ProviderAuthError": { "type": "object", @@ -11195,10 +11851,16 @@ "type": "string" } }, - "required": ["providerID", "message"] + "required": [ + "providerID", + "message" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "UnknownError": { "type": "object", @@ -11214,10 +11876,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "MessageOutputLengthError": { "type": "object", @@ -11231,7 +11898,10 @@ "properties": {} } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "MessageAbortedError": { "type": "object", @@ -11247,10 +11917,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "StructuredOutputError": { "type": "object", @@ -11269,10 +11944,16 @@ "type": "number" } }, - "required": ["message", "retries"] + "required": [ + "message", + "retries" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "ContextOverflowError": { "type": "object", @@ -11291,10 +11972,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "APIError": { "type": "object", @@ -11337,10 +12023,16 @@ } } }, - "required": ["message", "isRetryable"] + "required": [ + "message", + "isRetryable" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "Event.session.error": { "type": "object", @@ -11384,7 +12076,10 @@ } } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.vcs.branch.updated": { "type": "object", @@ -11402,7 +12097,10 @@ } } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.kilo-sessions.remote-status-changed": { "type": "object", @@ -11421,10 +12119,16 @@ "type": "boolean" } }, - "required": ["enabled", "connected"] + "required": [ + "enabled", + "connected" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.workspace.ready": { "type": "object", @@ -11440,10 +12144,15 @@ "type": "string" } }, - "required": ["name"] + "required": [ + "name" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.workspace.failed": { "type": "object", @@ -11459,10 +12168,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Pty": { "type": "object", @@ -11488,13 +12202,24 @@ }, "status": { "type": "string", - "enum": ["running", "exited"] + "enum": [ + "running", + "exited" + ] }, "pid": { "type": "number" } }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] + "required": [ + "id", + "title", + "command", + "args", + "cwd", + "status", + "pid" + ] }, "Event.pty.created": { "type": "object", @@ -11510,10 +12235,15 @@ "$ref": "#/components/schemas/Pty" } }, - "required": ["info"] + "required": [ + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.pty.updated": { "type": "object", @@ -11529,10 +12259,15 @@ "$ref": "#/components/schemas/Pty" } }, - "required": ["info"] + "required": [ + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.pty.exited": { "type": "object", @@ -11552,10 +12287,16 @@ "type": "number" } }, - "required": ["id", "exitCode"] + "required": [ + "id", + "exitCode" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.pty.deleted": { "type": "object", @@ -11572,10 +12313,15 @@ "pattern": "^pty.*" } }, - "required": ["id"] + "required": [ + "id" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.worktree.ready": { "type": "object", @@ -11594,10 +12340,16 @@ "type": "string" } }, - "required": ["name", "branch"] + "required": [ + "name", + "branch" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.worktree.failed": { "type": "object", @@ -11613,10 +12365,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "OutputFormatText": { "type": "object", @@ -11626,7 +12383,9 @@ "const": "text" } }, - "required": ["type"] + "required": [ + "type" + ] }, "JSONSchema": { "type": "object", @@ -11652,7 +12411,10 @@ "maximum": 9007199254740991 } }, - "required": ["type", "schema"] + "required": [ + "type", + "schema" + ] }, "OutputFormat": { "anyOf": [ @@ -11686,7 +12448,9 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -11707,7 +12471,9 @@ } } }, - "required": ["diffs"] + "required": [ + "diffs" + ] }, "agent": { "type": "string" @@ -11722,7 +12488,10 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "system": { "type": "string" @@ -11763,7 +12532,14 @@ } } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": [ + "id", + "sessionID", + "role", + "time", + "agent", + "model" + ] }, "AssistantMessage": { "type": "object", @@ -11790,7 +12566,9 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] }, "error": { "anyOf": [ @@ -11843,7 +12621,10 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": [ + "cwd", + "root" + ] }, "summary": { "type": "boolean" @@ -11876,10 +12657,18 @@ "type": "number" } }, - "required": ["read", "write"] + "required": [ + "read", + "write" + ] } }, - "required": ["input", "output", "reasoning", "cache"] + "required": [ + "input", + "output", + "reasoning", + "cache" + ] }, "structured": {}, "variant": { @@ -11932,10 +12721,16 @@ "$ref": "#/components/schemas/Message" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.message.removed": { "type": "object", @@ -11956,10 +12751,16 @@ "pattern": "^msg.*" } }, - "required": ["sessionID", "messageID"] + "required": [ + "sessionID", + "messageID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "TextPart": { "type": "object", @@ -11999,7 +12800,9 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] }, "metadata": { "type": "object", @@ -12009,7 +12812,13 @@ "additionalProperties": {} } }, - "required": ["id", "sessionID", "messageID", "type", "text"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text" + ] }, "SubtaskPart": { "type": "object", @@ -12049,13 +12858,24 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "command": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ] }, "ReasoningPart": { "type": "object", @@ -12096,10 +12916,19 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] } }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ] }, "FilePartSourceText": { "type": "object", @@ -12118,7 +12947,11 @@ "maximum": 9007199254740991 } }, - "required": ["value", "start", "end"] + "required": [ + "value", + "start", + "end" + ] }, "FileSource": { "type": "object", @@ -12134,7 +12967,11 @@ "type": "string" } }, - "required": ["text", "type", "path"] + "required": [ + "text", + "type", + "path" + ] }, "Range": { "type": "object", @@ -12149,7 +12986,10 @@ "type": "number" } }, - "required": ["line", "character"] + "required": [ + "line", + "character" + ] }, "end": { "type": "object", @@ -12161,10 +13001,16 @@ "type": "number" } }, - "required": ["line", "character"] + "required": [ + "line", + "character" + ] } }, - "required": ["start", "end"] + "required": [ + "start", + "end" + ] }, "SymbolSource": { "type": "object", @@ -12191,7 +13037,14 @@ "maximum": 9007199254740991 } }, - "required": ["text", "type", "path", "range", "name", "kind"] + "required": [ + "text", + "type", + "path", + "range", + "name", + "kind" + ] }, "ResourceSource": { "type": "object", @@ -12210,7 +13063,12 @@ "type": "string" } }, - "required": ["text", "type", "clientName", "uri"] + "required": [ + "text", + "type", + "clientName", + "uri" + ] }, "FilePartSource": { "anyOf": [ @@ -12257,7 +13115,14 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "mime", + "url" + ] }, "ToolStatePending": { "type": "object", @@ -12277,7 +13142,11 @@ "type": "string" } }, - "required": ["status", "input", "raw"] + "required": [ + "status", + "input", + "raw" + ] }, "ToolStateRunning": { "type": "object", @@ -12310,10 +13179,16 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] } }, - "required": ["status", "input", "time"] + "required": [ + "status", + "input", + "time" + ] }, "ToolStateCompleted": { "type": "object", @@ -12355,7 +13230,10 @@ "type": "number" } }, - "required": ["start", "end"] + "required": [ + "start", + "end" + ] }, "attachments": { "type": "array", @@ -12364,7 +13242,14 @@ } } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": [ + "status", + "input", + "output", + "title", + "metadata", + "time" + ] }, "ToolStateError": { "type": "object", @@ -12400,10 +13285,18 @@ "type": "number" } }, - "required": ["start", "end"] + "required": [ + "start", + "end" + ] } }, - "required": ["status", "input", "error", "time"] + "required": [ + "status", + "input", + "error", + "time" + ] }, "ToolState": { "anyOf": [ @@ -12457,7 +13350,15 @@ "additionalProperties": {} } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "callID", + "tool", + "state" + ] }, "StepStartPart": { "type": "object", @@ -12482,7 +13383,12 @@ "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"] + "required": [ + "id", + "sessionID", + "messageID", + "type" + ] }, "StepFinishPart": { "type": "object", @@ -12537,13 +13443,29 @@ "type": "number" } }, - "required": ["read", "write"] + "required": [ + "read", + "write" + ] } }, - "required": ["input", "output", "reasoning", "cache"] + "required": [ + "input", + "output", + "reasoning", + "cache" + ] } }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "reason", + "cost", + "tokens" + ] }, "SnapshotPart": { "type": "object", @@ -12568,7 +13490,13 @@ "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "snapshot" + ] }, "PatchPart": { "type": "object", @@ -12599,7 +13527,14 @@ } } }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "hash", + "files" + ] }, "AgentPart": { "type": "object", @@ -12640,10 +13575,20 @@ "maximum": 9007199254740991 } }, - "required": ["value", "start", "end"] + "required": [ + "value", + "start", + "end" + ] } }, - "required": ["id", "sessionID", "messageID", "type", "name"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "name" + ] }, "RetryPart": { "type": "object", @@ -12677,10 +13622,20 @@ "type": "number" } }, - "required": ["created"] + "required": [ + "created" + ] } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "attempt", + "error", + "time" + ] }, "CompactionPart": { "type": "object", @@ -12708,7 +13663,13 @@ "type": "boolean" } }, - "required": ["id", "sessionID", "messageID", "type", "auto"] + "required": [ + "id", + "sessionID", + "messageID", + "type", + "auto" + ] }, "Part": { "anyOf": [ @@ -12771,10 +13732,17 @@ "type": "number" } }, - "required": ["sessionID", "part", "time"] + "required": [ + "sessionID", + "part", + "time" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.message.part.removed": { "type": "object", @@ -12799,14 +13767,25 @@ "pattern": "^prt.*" } }, - "required": ["sessionID", "messageID", "partID"] + "required": [ + "sessionID", + "messageID", + "partID" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "PermissionAction": { "type": "string", - "enum": ["allow", "deny", "ask"] + "enum": [ + "allow", + "deny", + "ask" + ] }, "PermissionRule": { "type": "object", @@ -12821,7 +13800,11 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": ["permission", "pattern", "action"] + "required": [ + "permission", + "pattern", + "action" + ] }, "PermissionRuleset": { "type": "array", @@ -12881,14 +13864,26 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] } }, - "required": ["file", "additions", "deletions"] + "required": [ + "file", + "additions", + "deletions" + ] } } }, - "required": ["additions", "deletions", "files"] + "required": [ + "additions", + "deletions", + "files" + ] }, "share": { "type": "object", @@ -12897,7 +13892,9 @@ "type": "string" } }, - "required": ["url"] + "required": [ + "url" + ] }, "title": { "type": "string" @@ -12921,7 +13918,10 @@ "type": "number" } }, - "required": ["created", "updated"] + "required": [ + "created", + "updated" + ] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -12944,10 +13944,20 @@ "type": "string" } }, - "required": ["messageID"] + "required": [ + "messageID" + ] } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time" + ] }, "Event.session.created": { "type": "object", @@ -12967,10 +13977,16 @@ "$ref": "#/components/schemas/Session" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.updated": { "type": "object", @@ -12990,10 +14006,16 @@ "$ref": "#/components/schemas/Session" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event.session.deleted": { "type": "object", @@ -13013,10 +14035,16 @@ "$ref": "#/components/schemas/Session" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "properties"] + "required": [ + "type", + "properties" + ] }, "Event": { "anyOf": [ @@ -13098,6 +14126,12 @@ { "$ref": "#/components/schemas/Event.todo.updated" }, + { + "$ref": "#/components/schemas/Event.session.turn.open" + }, + { + "$ref": "#/components/schemas/Event.session.turn.close" + }, { "$ref": "#/components/schemas/Event.session.status" }, @@ -13116,12 +14150,6 @@ { "$ref": "#/components/schemas/Event.command.executed" }, - { - "$ref": "#/components/schemas/Event.session.turn.open" - }, - { - "$ref": "#/components/schemas/Event.session.turn.close" - }, { "$ref": "#/components/schemas/Event.session.diff" }, @@ -13191,7 +14219,10 @@ "$ref": "#/components/schemas/Event" } }, - "required": ["directory", "payload"] + "required": [ + "directory", + "payload" + ] }, "SyncEvent.message.updated": { "type": "object", @@ -13215,10 +14246,17 @@ "$ref": "#/components/schemas/Message" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.message.removed": { "type": "object", @@ -13243,10 +14281,17 @@ "pattern": "^msg.*" } }, - "required": ["sessionID", "messageID"] + "required": [ + "sessionID", + "messageID" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.message.part.updated": { "type": "object", @@ -13273,10 +14318,18 @@ "type": "number" } }, - "required": ["sessionID", "part", "time"] + "required": [ + "sessionID", + "part", + "time" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.message.part.removed": { "type": "object", @@ -13305,10 +14358,18 @@ "pattern": "^prt.*" } }, - "required": ["sessionID", "messageID", "partID"] + "required": [ + "sessionID", + "messageID", + "partID" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.session.created": { "type": "object", @@ -13332,10 +14393,17 @@ "$ref": "#/components/schemas/Session" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.session.updated": { "type": "object", @@ -13451,14 +14519,26 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] } }, - "required": ["file", "additions", "deletions"] + "required": [ + "file", + "additions", + "deletions" + ] } } }, - "required": ["additions", "deletions", "files"] + "required": [ + "additions", + "deletions", + "files" + ] }, { "type": "null" @@ -13479,7 +14559,9 @@ ] } }, - "required": ["url"] + "required": [ + "url" + ] }, "title": { "anyOf": [ @@ -13545,7 +14627,12 @@ ] } }, - "required": ["created", "updated", "compacting", "archived"] + "required": [ + "created", + "updated", + "compacting", + "archived" + ] }, "permission": { "anyOf": [ @@ -13577,7 +14664,9 @@ "type": "string" } }, - "required": ["messageID"] + "required": [ + "messageID" + ] }, { "type": "null" @@ -13600,10 +14689,17 @@ ] } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent.session.deleted": { "type": "object", @@ -13627,10 +14723,17 @@ "$ref": "#/components/schemas/Session" } }, - "required": ["sessionID", "info"] + "required": [ + "sessionID", + "info" + ] } }, - "required": ["type", "aggregate", "data"] + "required": [ + "type", + "aggregate", + "data" + ] }, "SyncEvent": { "type": "object", @@ -13639,12 +14742,19 @@ "$ref": "#/components/schemas/SyncEvent" } }, - "required": ["payload"] + "required": [ + "payload" + ] }, "LogLevel": { "description": "Log level", "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + "enum": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ] }, "ServerConfig": { "description": "Server configuration for kilo serve and web commands", @@ -13682,7 +14792,11 @@ "anyOf": [ { "type": "string", - "enum": ["ask", "allow", "deny"] + "enum": [ + "ask", + "allow", + "deny" + ] }, { "type": "null" @@ -13822,7 +14936,11 @@ }, "mode": { "type": "string", - "enum": ["subagent", "primary", "all"] + "enum": [ + "subagent", + "primary", + "all" + ] }, "hidden": { "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", @@ -13844,7 +14962,15 @@ }, { "type": "string", - "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] + "enum": [ + "primary", + "secondary", + "accent", + "success", + "warning", + "error", + "info" + ] } ] }, @@ -13930,10 +15056,15 @@ "properties": { "field": { "type": "string", - "enum": ["reasoning_content", "reasoning_details"] + "enum": [ + "reasoning_content", + "reasoning_details" + ] } }, - "required": ["field"], + "required": [ + "field" + ], "additionalProperties": false } ] @@ -13969,10 +15100,16 @@ "type": "number" } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] }, "limit": { "type": "object", @@ -13987,7 +15124,10 @@ "type": "number" } }, - "required": ["context", "output"] + "required": [ + "context", + "output" + ] }, "modalities": { "type": "object", @@ -13996,39 +15136,71 @@ "type": "array", "items": { "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] } }, "output": { "type": "array", "items": { "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] + "enum": [ + "text", + "audio", + "image", + "video", + "pdf" + ] } } }, - "required": ["input", "output"] + "required": [ + "input", + "output" + ] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] + "enum": [ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo" + ] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] }, "options": { "type": "object", @@ -14170,7 +15342,10 @@ "maximum": 9007199254740991 } }, - "required": ["type", "command"], + "required": [ + "type", + "command" + ], "additionalProperties": false }, "McpOAuthConfig": { @@ -14236,13 +15411,19 @@ "maximum": 9007199254740991 } }, - "required": ["type", "url"], + "required": [ + "type", + "url" + ], "additionalProperties": false }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": ["auto", "stretch"] + "enum": [ + "auto", + "stretch" + ] }, "Config": { "type": "object", @@ -14282,7 +15463,9 @@ "type": "boolean" } }, - "required": ["template"] + "required": [ + "template" + ] } }, "skills": { @@ -14348,7 +15531,11 @@ "share": { "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", - "enum": ["manual", "auto", "disabled"] + "enum": [ + "manual", + "auto", + "disabled" + ] }, "autoshare": { "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", @@ -14503,7 +15690,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "additionalProperties": false } ] @@ -14573,7 +15762,9 @@ "const": true } }, - "required": ["disabled"] + "required": [ + "disabled" + ] }, { "type": "object", @@ -14610,7 +15801,9 @@ "additionalProperties": {} } }, - "required": ["command"] + "required": [ + "command" + ] } ] } @@ -14727,7 +15920,11 @@ "const": false } }, - "required": ["data", "errors", "success"] + "required": [ + "data", + "errors", + "success" + ] }, "OAuth": { "type": "object", @@ -14752,7 +15949,12 @@ "type": "string" } }, - "required": ["type", "refresh", "access", "expires"] + "required": [ + "type", + "refresh", + "access", + "expires" + ] }, "ApiAuth": { "type": "object", @@ -14765,7 +15967,10 @@ "type": "string" } }, - "required": ["type", "key"] + "required": [ + "type", + "key" + ] }, "WellKnownAuth": { "type": "object", @@ -14781,7 +15986,11 @@ "type": "string" } }, - "required": ["type", "key", "token"] + "required": [ + "type", + "key", + "token" + ] }, "Auth": { "anyOf": [ @@ -14810,10 +16019,15 @@ "type": "string" } }, - "required": ["message"] + "required": [ + "message" + ] } }, - "required": ["name", "data"] + "required": [ + "name", + "data" + ] }, "Model": { "type": "object", @@ -14837,7 +16051,11 @@ "type": "string" } }, - "required": ["id", "url", "npm"] + "required": [ + "id", + "url", + "npm" + ] }, "name": { "type": "string" @@ -14879,7 +16097,13 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": [ + "text", + "audio", + "image", + "video", + "pdf" + ] }, "output": { "type": "object", @@ -14900,7 +16124,13 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": [ + "text", + "audio", + "image", + "video", + "pdf" + ] }, "interleaved": { "anyOf": [ @@ -14912,15 +16142,28 @@ "properties": { "field": { "type": "string", - "enum": ["reasoning_content", "reasoning_details"] + "enum": [ + "reasoning_content", + "reasoning_details" + ] } }, - "required": ["field"] + "required": [ + "field" + ] } ] } }, - "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + "required": [ + "temperature", + "reasoning", + "attachment", + "toolcall", + "input", + "output", + "interleaved" + ] }, "cost": { "type": "object", @@ -14941,7 +16184,10 @@ "type": "number" } }, - "required": ["read", "write"] + "required": [ + "read", + "write" + ] }, "experimentalOver200K": { "type": "object", @@ -14962,13 +16208,24 @@ "type": "number" } }, - "required": ["read", "write"] + "required": [ + "read", + "write" + ] } }, - "required": ["input", "output", "cache"] + "required": [ + "input", + "output", + "cache" + ] } }, - "required": ["input", "output", "cache"] + "required": [ + "input", + "output", + "cache" + ] }, "limit": { "type": "object", @@ -14983,11 +16240,19 @@ "type": "number" } }, - "required": ["context", "output"] + "required": [ + "context", + "output" + ] }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] + "enum": [ + "alpha", + "beta", + "deprecated", + "active" + ] }, "options": { "type": "object", @@ -15026,14 +16291,26 @@ }, "prompt": { "type": "string", - "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] + "enum": [ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo" + ] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] + "enum": [ + "anthropic", + "openai", + "openai-compatible", + "openrouter" + ] } }, "required": [ @@ -15061,7 +16338,12 @@ }, "source": { "type": "string", - "enum": ["env", "config", "custom", "api"] + "enum": [ + "env", + "config", + "custom", + "api" + ] }, "env": { "type": "array", @@ -15089,7 +16371,14 @@ } } }, - "required": ["id", "name", "source", "env", "options", "models"] + "required": [ + "id", + "name", + "source", + "env", + "options", + "models" + ] }, "ToolIDs": { "type": "array", @@ -15108,7 +16397,11 @@ }, "parameters": {} }, - "required": ["id", "description", "parameters"] + "required": [ + "id", + "description", + "parameters" + ] }, "ToolList": { "type": "array", @@ -15168,7 +16461,15 @@ "type": "string" } }, - "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] + "required": [ + "id", + "type", + "branch", + "name", + "directory", + "extra", + "projectID" + ] }, "Worktree": { "type": "object", @@ -15183,7 +16484,11 @@ "type": "string" } }, - "required": ["name", "branch", "directory"] + "required": [ + "name", + "branch", + "directory" + ] }, "WorktreeCreateInput": { "type": "object", @@ -15204,7 +16509,9 @@ "type": "string" } }, - "required": ["directory"] + "required": [ + "directory" + ] }, "WorktreeResetInput": { "type": "object", @@ -15213,7 +16520,9 @@ "type": "string" } }, - "required": ["directory"] + "required": [ + "directory" + ] }, "WorktreeDiffItem": { "type": "object", @@ -15235,7 +16544,11 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] }, "tracked": { "type": "boolean" @@ -15275,7 +16588,10 @@ "type": "string" } }, - "required": ["id", "worktree"] + "required": [ + "id", + "worktree" + ] }, "GlobalSession": { "type": "object", @@ -15329,14 +16645,26 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] } }, - "required": ["file", "additions", "deletions"] + "required": [ + "file", + "additions", + "deletions" + ] } } }, - "required": ["additions", "deletions", "files"] + "required": [ + "additions", + "deletions", + "files" + ] }, "share": { "type": "object", @@ -15345,7 +16673,9 @@ "type": "string" } }, - "required": ["url"] + "required": [ + "url" + ] }, "title": { "type": "string" @@ -15369,7 +16699,10 @@ "type": "number" } }, - "required": ["created", "updated"] + "required": [ + "created", + "updated" + ] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -15392,7 +16725,9 @@ "type": "string" } }, - "required": ["messageID"] + "required": [ + "messageID" + ] }, "project": { "anyOf": [ @@ -15408,7 +16743,16 @@ "type": "string" } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] + "required": [ + "id", + "slug", + "projectID", + "directory", + "title", + "version", + "time", + "project" + ] }, "McpResource": { "type": "object", @@ -15429,7 +16773,11 @@ "type": "string" } }, - "required": ["name", "uri", "client"] + "required": [ + "name", + "uri", + "client" + ] }, "TextPartInput": { "type": "object", @@ -15461,7 +16809,9 @@ "type": "number" } }, - "required": ["start"] + "required": [ + "start" + ] }, "metadata": { "type": "object", @@ -15471,7 +16821,10 @@ "additionalProperties": {} } }, - "required": ["type", "text"] + "required": [ + "type", + "text" + ] }, "FilePartInput": { "type": "object", @@ -15497,7 +16850,11 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["type", "mime", "url"] + "required": [ + "type", + "mime", + "url" + ] }, "AgentPartInput": { "type": "object", @@ -15530,10 +16887,17 @@ "maximum": 9007199254740991 } }, - "required": ["value", "start", "end"] + "required": [ + "value", + "start", + "end" + ] } }, - "required": ["type", "name"] + "required": [ + "type", + "name" + ] }, "SubtaskPartInput": { "type": "object", @@ -15565,13 +16929,21 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": [ + "providerID", + "modelID" + ] }, "command": { "type": "string" } }, - "required": ["type", "prompt", "description", "agent"] + "required": [ + "type", + "prompt", + "description", + "agent" + ] }, "ProviderAuthMethod": { "type": "object", @@ -15633,10 +17005,18 @@ "type": "string" } }, - "required": ["key", "op", "value"] + "required": [ + "key", + "op", + "value" + ] } }, - "required": ["type", "key", "message"] + "required": [ + "type", + "key", + "message" + ] }, { "type": "object", @@ -15666,7 +17046,10 @@ "type": "string" } }, - "required": ["label", "value"] + "required": [ + "label", + "value" + ] } }, "when": { @@ -15691,16 +17074,28 @@ "type": "string" } }, - "required": ["key", "op", "value"] + "required": [ + "key", + "op", + "value" + ] } }, - "required": ["type", "key", "message", "options"] + "required": [ + "type", + "key", + "message", + "options" + ] } ] } } }, - "required": ["type", "label"] + "required": [ + "type", + "label" + ] }, "ProviderAuthAuthorization": { "type": "object", @@ -15724,7 +17119,11 @@ "type": "string" } }, - "required": ["url", "method", "instructions"] + "required": [ + "url", + "method", + "instructions" + ] }, "Symbol": { "type": "object", @@ -15745,10 +17144,17 @@ "$ref": "#/components/schemas/Range" } }, - "required": ["uri", "range"] + "required": [ + "uri", + "range" + ] } }, - "required": ["name", "kind", "location"] + "required": [ + "name", + "kind", + "location" + ] }, "FileNode": { "type": "object", @@ -15764,20 +17170,32 @@ }, "type": { "type": "string", - "enum": ["file", "directory"] + "enum": [ + "file", + "directory" + ] }, "ignored": { "type": "boolean" } }, - "required": ["name", "path", "absolute", "type", "ignored"] + "required": [ + "name", + "path", + "absolute", + "type", + "ignored" + ] }, "FileContent": { "type": "object", "properties": { "type": { "type": "string", - "enum": ["text", "binary"] + "enum": [ + "text", + "binary" + ] }, "content": { "type": "string" @@ -15824,14 +17242,24 @@ } } }, - "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] + "required": [ + "oldStart", + "oldLines", + "newStart", + "newLines", + "lines" + ] } }, "index": { "type": "string" } }, - "required": ["oldFileName", "newFileName", "hunks"] + "required": [ + "oldFileName", + "newFileName", + "hunks" + ] }, "encoding": { "type": "string", @@ -15841,7 +17269,10 @@ "type": "string" } }, - "required": ["type", "content"] + "required": [ + "type", + "content" + ] }, "File": { "type": "object", @@ -15861,10 +17292,19 @@ }, "status": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": [ + "added", + "deleted", + "modified" + ] } }, - "required": ["path", "added", "removed", "status"] + "required": [ + "path", + "added", + "removed", + "status" + ] }, "MCPStatusConnected": { "type": "object", @@ -15874,7 +17314,9 @@ "const": "connected" } }, - "required": ["status"] + "required": [ + "status" + ] }, "MCPStatusDisabled": { "type": "object", @@ -15884,7 +17326,9 @@ "const": "disabled" } }, - "required": ["status"] + "required": [ + "status" + ] }, "MCPStatusFailed": { "type": "object", @@ -15897,7 +17341,10 @@ "type": "string" } }, - "required": ["status", "error"] + "required": [ + "status", + "error" + ] }, "MCPStatusNeedsAuth": { "type": "object", @@ -15907,7 +17354,9 @@ "const": "needs_auth" } }, - "required": ["status"] + "required": [ + "status" + ] }, "MCPStatusNeedsClientRegistration": { "type": "object", @@ -15920,7 +17369,10 @@ "type": "string" } }, - "required": ["status", "error"] + "required": [ + "status", + "error" + ] }, "MCPStatus": { "anyOf": [ @@ -15960,7 +17412,13 @@ "type": "string" } }, - "required": ["home", "state", "config", "worktree", "directory"] + "required": [ + "home", + "state", + "config", + "worktree", + "directory" + ] }, "VcsInfo": { "type": "object", @@ -15987,7 +17445,11 @@ }, "source": { "type": "string", - "enum": ["command", "mcp", "skill"] + "enum": [ + "command", + "mcp", + "skill" + ] }, "template": { "anyOf": [ @@ -16009,7 +17471,11 @@ } } }, - "required": ["name", "template", "hints"] + "required": [ + "name", + "template", + "hints" + ] }, "Agent": { "type": "object", @@ -16028,7 +17494,11 @@ }, "mode": { "type": "string", - "enum": ["subagent", "primary", "all"] + "enum": [ + "subagent", + "primary", + "all" + ] }, "native": { "type": "boolean" @@ -16058,7 +17528,10 @@ "type": "string" } }, - "required": ["modelID", "providerID"] + "required": [ + "modelID", + "providerID" + ] }, "variant": { "type": "string" @@ -16079,7 +17552,12 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "permission", "options"] + "required": [ + "name", + "mode", + "permission", + "options" + ] }, "LSPStatus": { "type": "object", @@ -16106,7 +17584,12 @@ ] } }, - "required": ["id", "name", "root", "status"] + "required": [ + "id", + "name", + "root", + "status" + ] }, "FormatterStatus": { "type": "object", @@ -16124,8 +17607,12 @@ "type": "boolean" } }, - "required": ["name", "extensions", "enabled"] + "required": [ + "name", + "extensions", + "enabled" + ] } } } -} +} \ No newline at end of file From 7030606056acb51644693bd32c02927268a0d588 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Apr 2026 22:19:20 -0400 Subject: [PATCH 09/13] feat(jetbrains): refactor welcome UI with icon status rows and centered layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace HTML-based rendering with Swing icon+label rows using platform icons (TestPassed/TestFailed/TestNotRan/AnimatedIcon/Warning) as status indicators. All rows are left-aligned within centered section blocks. Status label uses primary color, progress rows use secondary. Profile row shows login state (Logged in / Not logged in with warning). Remove CLI version row — version now shown in status label text. --- .../ai/kilocode/client/KiloAppService.kt | 21 +- .../ai/kilocode/client/KiloWelcomeUi.kt | 427 ++++++++++-------- 2 files changed, 243 insertions(+), 205 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt index 60bee94264a..6d5122a8fac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt @@ -12,10 +12,9 @@ import fleet.rpc.client.durable import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** @@ -41,13 +40,8 @@ class KiloAppService(private val cs: CoroutineScope) { var version: String? = null private set - val state: StateFlow = flow { - durable { - KiloAppRpcApi.getInstance() - .state() - .collect { emit(it) } - } - }.stateIn(cs, SharingStarted.Eagerly, init) + private val _state = MutableStateFlow(init) + val state: StateFlow = _state.asStateFlow() fun connect() { if (!started.compareAndSet(false, true)) return @@ -56,6 +50,13 @@ class KiloAppService(private val cs: CoroutineScope) { KiloAppRpcApi.getInstance().connect() } } + cs.launch { + durable { + KiloAppRpcApi.getInstance() + .state() + .collect { _state.value = it } + } + } } /** One-shot health check. Returns null on failure. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt index c346cf2de61..75a8c6764fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt @@ -3,17 +3,14 @@ package ai.kilocode.client import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.LoadProgressDto import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.IconLoader -import com.intellij.openapi.util.text.HtmlBuilder -import com.intellij.openapi.util.text.HtmlChunk +import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -25,16 +22,16 @@ import java.awt.GridBagConstraints import java.awt.GridBagLayout import javax.swing.Box import javax.swing.BoxLayout +import javax.swing.Icon import javax.swing.JPanel import javax.swing.SwingConstants /** - * Welcome panel that shows app initialization progress and workspace - * loading status. Watches both [KiloAppService] and [KiloProjectService] - * state flows and renders a unified two-section status view. + * Welcome panel showing app + workspace initialization progress. * - * When connected, all details stay visible — the user always sees - * what was loaded, the CLI version, and workspace data counts. + * Uses icon+label rows for each resource being loaded. Icons act as + * status indicators: animated spinner for loading, green check for + * success, red circle for error, grey circle for idle. */ class KiloWelcomeUi( private val app: KiloAppService, @@ -42,38 +39,51 @@ class KiloWelcomeUi( private val cs: CoroutineScope, ) : JPanel(GridBagLayout()), Disposable { - private val icon = JBLabel( + // ------ status icons ------ + + private val iconLoading: Icon = AnimatedIcon.Default() + private val iconOk: Icon = AllIcons.RunConfigurations.TestPassed + private val iconError: Icon = AllIcons.RunConfigurations.TestFailed + private val iconWarn: Icon = AllIcons.General.Warning + private val iconIdle: Icon = AllIcons.RunConfigurations.TestNotRan + + // ------ header ------ + + private val logo = JBLabel( IconLoader.getIcon("/icons/kilo-content.svg", KiloWelcomeUi::class.java), ).apply { + alignmentX = CENTER_ALIGNMENT + } + + private val status = JBLabel().apply { + alignmentX = CENTER_ALIGNMENT horizontalAlignment = SwingConstants.CENTER - alignmentX = CENTER_ALIGNMENT - } - - private val status = JBLabel( - KiloBundle.message("toolwindow.status.disconnected"), - SwingConstants.CENTER, - ).apply { - alignmentX = CENTER_ALIGNMENT font = JBUI.Fonts.label(13f) - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) + foreground = UIUtil.getLabelForeground() } - private val appDetail = JBLabel("", SwingConstants.CENTER).apply { - alignmentX = CENTER_ALIGNMENT - font = JBUI.Fonts.smallFont() - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - setCopyable(true) - } + // ------ app rows ------ - private val wsDetail = JBLabel("", SwingConstants.CENTER).apply { - alignmentX = CENTER_ALIGNMENT - font = JBUI.Fonts.smallFont() - foreground = UIUtil.getContextHelpForeground() - setAllowAutoWrapping(true) - setCopyable(true) - } + private val configRow = row("Config") + private val notifRow = row("Notifications") + private val profileRow = row("Profile") + + // ------ workspace rows ------ + + private val providersRow = row("Providers") + private val agentsRow = row("Agents") + private val commandsRow = row("Commands") + private val skillsRow = row("Skills") + + // ------ section headers ------ + + private val appHeader = header("App") + private val wsHeader = header("Workspace") + + // Section panels group header + rows with left-aligned content. + // Each section is center-aligned as a block in the outer layout. + private val appSection = section(appHeader, configRow, notifRow, profileRow) + private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) private var appJob: Job? = null private var wsJob: Job? = null @@ -84,24 +94,31 @@ class KiloWelcomeUi( val body = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false - add(icon) - add(Box.createVerticalStrut(JBUI.scale(16))) + border = JBUI.Borders.empty(12, 16) + + add(logo) + add(Box.createVerticalStrut(JBUI.scale(12))) add(status) - add(Box.createVerticalStrut(JBUI.scale(6))) - add(appDetail) - add(Box.createVerticalStrut(JBUI.scale(6))) - add(wsDetail) + add(Box.createVerticalStrut(JBUI.scale(12))) + add(appSection) + add(Box.createVerticalStrut(JBUI.scale(12))) + add(wsSection) } + // GridBagLayout with default constraints centers the body + // both vertically and horizontally in the tool window. add(body, GridBagConstraints()) + // Initial state: everything idle + resetAll() + appJob = app.watch { state -> - update { renderApp(state) } + edt { renderApp(state) } } wsJob = cs.launch { workspace.state.collect { state -> - update { renderWorkspace(state) } + edt { renderWorkspace(state) } } } @@ -118,184 +135,204 @@ class KiloWelcomeUi( private fun renderApp(state: KiloAppStateDto) { status.text = title(state) - val html = appHtml(state) - appDetail.text = html - appDetail.isVisible = html.isNotEmpty() + + when (state.status) { + KiloAppStatusDto.DISCONNECTED -> { + resetAll() + } + KiloAppStatusDto.CONNECTING -> { + configRow.loading() + notifRow.loading() + profileRow.loading() + } + KiloAppStatusDto.LOADING -> { + val p = state.progress + if (p != null) { + if (p.config) configRow.ok("Config") else configRow.loading() + if (p.notifications) notifRow.ok("Notifications") else notifRow.loading() + renderProfile(p.profile) + } + } + KiloAppStatusDto.READY -> { + val p = state.progress + if (p != null) { + configRow.ok("Config") + notifRow.ok("Notifications") + renderProfile(p.profile) + } else { + configRow.ok("Config") + notifRow.ok("Notifications") + profileRow.ok("Logged in") + } + } + KiloAppStatusDto.ERROR -> { + val errors = state.errors.associate { it.resource to it } + configRow.apply { + if ("config" in errors) error("Config: ${errors["config"]?.detail ?: "failed"}") + else ok("Config") + } + notifRow.apply { + if ("notifications" in errors) error("Notifications: ${errors["notifications"]?.detail ?: "failed"}") + else ok("Notifications") + } + profileRow.apply { + if ("profile" in errors) error("Profile: ${errors["profile"]?.detail ?: "failed"}") + else ok("Logged in") + } + } + } } private fun renderWorkspace(state: KiloWorkspaceStateDto) { - // Hide workspace section entirely when app isn't ready val appReady = app.state.value.status == KiloAppStatusDto.READY - if (!appReady && state.status == KiloWorkspaceStatusDto.PENDING) { - wsDetail.isVisible = false - return + val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING + wsSection.isVisible = visible + if (!visible) return + + when (state.status) { + KiloWorkspaceStatusDto.PENDING -> { + providersRow.idle("Providers") + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") + } + KiloWorkspaceStatusDto.LOADING -> { + val p = state.progress + if (p != null) { + if (p.providers) providersRow.ok("Providers") else providersRow.loading() + if (p.agents) agentsRow.ok("Agents") else agentsRow.loading() + if (p.commands) commandsRow.ok("Commands") else commandsRow.loading() + if (p.skills) skillsRow.ok("Skills") else skillsRow.loading() + } else { + providersRow.loading() + agentsRow.loading() + commandsRow.loading() + skillsRow.loading() + } + } + KiloWorkspaceStatusDto.READY -> { + val prov = state.providers?.providers?.size ?: 0 + val ag = state.agents?.all?.size ?: 0 + val cmd = state.commands.size + val sk = state.skills.size + providersRow.ok("Providers ($prov)") + agentsRow.ok("Agents ($ag)") + commandsRow.ok("Commands ($cmd)") + skillsRow.ok("Skills ($sk)") + } + KiloWorkspaceStatusDto.ERROR -> { + val msg = state.error ?: "Unknown error" + providersRow.error(msg) + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") + } } - val html = wsHtml(state) - wsDetail.text = html - wsDetail.isVisible = html.isNotEmpty() } - // ------ app formatting ------ + // ------ helpers ------ private fun title(state: KiloAppStateDto): String = when (state.status) { KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected") + KiloAppStatusDto.READY -> { + val ver = app.version + if (ver != null) "Connected (CLI $ver)" else KiloBundle.message("toolwindow.status.connected") + } KiloAppStatusDto.ERROR -> KiloBundle.message( "toolwindow.status.error", state.error ?: KiloBundle.message("toolwindow.error.unknown"), ) } - private fun appHtml(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.LOADING -> appProgress(state.progress) - KiloAppStatusDto.READY -> appReady(state) - KiloAppStatusDto.ERROR -> appErrors(state) - else -> "" + private fun renderProfile(profile: ProfileStatusDto) { + when (profile) { + ProfileStatusDto.LOADED -> profileRow.ok("Logged in") + ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn("Not logged in") + ProfileStatusDto.PENDING -> profileRow.loading("Profile") } - - private fun appProgress(p: LoadProgressDto?): String { - if (p == null) return "" - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.app"))) - .br() - .append(item("Config", p.config)) - .br() - .append(item("Notifications", p.notifications)) - .br() - .append(profile(p.profile)) - return builder.wrapWithHtmlBody().toString() } - private fun appReady(state: KiloAppStateDto): String { - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.app"))) - - // Show final loading state - val p = state.progress - if (p != null) { - builder.br() - .append(item("Config", p.config)) - .br() - .append(item("Notifications", p.notifications)) - .br() - .append(profile(p.profile)) - } - - val ver = app.version - if (ver != null) { - builder.br().append(HtmlChunk.text("CLI: $ver")) - } - - return builder.wrapWithHtmlBody().toString() + private fun resetAll() { + configRow.idle("Config") + notifRow.idle("Notifications") + profileRow.idle("Profile") + providersRow.idle("Providers") + agentsRow.idle("Agents") + commandsRow.idle("Commands") + skillsRow.idle("Skills") } - private fun appErrors(state: KiloAppStateDto): String { - if (state.errors.isEmpty()) return "" - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.app"))) - .br() - .append(HtmlChunk.text("Failed to load:")) - state.errors.forEach { err -> - builder.br().append(formatError(err)) - } - return builder.wrapWithHtmlBody().toString() - } - - // ------ workspace formatting ------ - - private fun wsHtml(state: KiloWorkspaceStateDto): String = - when (state.status) { - KiloWorkspaceStatusDto.PENDING -> wsPending() - KiloWorkspaceStatusDto.LOADING -> wsProgress(state.progress) - KiloWorkspaceStatusDto.READY -> wsReady(state) - KiloWorkspaceStatusDto.ERROR -> wsError(state) - } - - private fun wsPending(): String { - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.workspace"))) - .br() - .append(HtmlChunk.text("$DOTS ${KiloBundle.message("toolwindow.workspace.pending")}")) - return builder.wrapWithHtmlBody().toString() - } - - private fun wsProgress(p: KiloWorkspaceLoadProgressDto?): String { - if (p == null) return "" - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.workspace"))) - .br() - .append(item("Providers", p.providers)) - .br() - .append(item("Agents", p.agents)) - .br() - .append(item("Commands", p.commands)) - .br() - .append(item("Skills", p.skills)) - return builder.wrapWithHtmlBody().toString() - } - - private fun wsReady(state: KiloWorkspaceStateDto): String { - val prov = state.providers?.providers?.size ?: 0 - val agents = state.agents?.all?.size ?: 0 - val commands = state.commands.size - val skills = state.skills.size - - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.workspace"))) - .br() - .append(HtmlChunk.text("$CHECK Providers ($prov)")) - .br() - .append(HtmlChunk.text("$CHECK Agents ($agents)")) - .br() - .append(HtmlChunk.text("$CHECK Commands ($commands)")) - .br() - .append(HtmlChunk.text("$CHECK Skills ($skills)")) - return builder.wrapWithHtmlBody().toString() - } - - private fun wsError(state: KiloWorkspaceStateDto): String { - val msg = state.error ?: "Unknown error" - val builder = HtmlBuilder() - .append(section(KiloBundle.message("toolwindow.section.workspace"))) - .br() - .append(HtmlChunk.text("$CROSS $msg")) - return builder.wrapWithHtmlBody().toString() - } - - // ------ shared helpers ------ - - private fun section(name: String): HtmlChunk = - HtmlChunk.text("$DASH $DASH $name $DASH $DASH").bold() - - private fun item(name: String, loaded: Boolean): HtmlChunk = - HtmlChunk.text(if (loaded) "$CHECK $name" else "$DOTS $name") - - private fun profile(status: ProfileStatusDto): HtmlChunk = - when (status) { - ProfileStatusDto.PENDING -> HtmlChunk.text("$DOTS Profile") - ProfileStatusDto.LOADED -> HtmlChunk.text("$CHECK Profile") - ProfileStatusDto.NOT_LOGGED_IN -> HtmlChunk.text("$DASH Profile (not logged in)") - } - - private fun formatError(err: LoadErrorDto): HtmlChunk { - val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: "" - val text = if (suffix.isEmpty()) "$CROSS ${err.resource}" - else "$CROSS ${err.resource}: $suffix" - return HtmlChunk.text(text) - } - - private fun update(block: () -> Unit) { + private fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeLater(block) } - companion object { - private const val CHECK = "\u2713" - private const val CROSS = "\u2717" - private const val DOTS = "\u2026" - private const val DASH = "\u2013" + // ------ row factory ------ + + private fun row(text: String): StatusRow = StatusRow(text, iconIdle) + + private fun header(text: String): JBLabel = JBLabel(text).apply { + alignmentX = LEFT_ALIGNMENT + font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or java.awt.Font.BOLD) + foreground = UIUtil.getLabelForeground() + border = JBUI.Borders.empty(0, 0, 4, 0) + } + + /** + * Groups a header and rows into a left-aligned block that + * is centered as a unit inside the outer BoxLayout. + */ + private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(hdr) + for (r in rows) add(r.label) + } + + /** + * A single status row: icon on the left, label on the right. + * Mutate via [ok], [loading], [error], [idle]. + */ + inner class StatusRow(text: String, icon: Icon) { + val label = JBLabel(text, icon, SwingConstants.LEFT).apply { + font = JBUI.Fonts.label() + foreground = UIUtil.getContextHelpForeground() + iconTextGap = JBUI.scale(6) + border = JBUI.Borders.empty(2, 0) + alignmentX = LEFT_ALIGNMENT + } + + fun ok(msg: String, ic: Icon = iconOk) { + label.icon = ic + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun loading(msg: String = label.text) { + label.icon = iconLoading + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun warn(msg: String) { + label.icon = iconWarn + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } + + fun error(msg: String) { + label.icon = iconError + label.text = msg + label.foreground = UIUtil.getErrorForeground() + } + + fun idle(msg: String) { + label.icon = iconIdle + label.text = msg + label.foreground = UIUtil.getContextHelpForeground() + } } } From 68c02792a73dfc43c3e91cf1b1e0bf580d0e6611 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Apr 2026 09:23:21 -0400 Subject: [PATCH 10/13] reformatting rolled back --- packages/sdk/openapi.json | 2419 +++++++------------------------------ 1 file changed, 466 insertions(+), 1953 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d5a0f13a44c..78f63f583be 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27,10 +27,7 @@ "type": "string" } }, - "required": [ - "healthy", - "version" - ] + "required": ["healthy", "version"] } } } @@ -209,10 +206,7 @@ "type": "string" } }, - "required": [ - "success", - "version" - ] + "required": ["success", "version"] }, { "type": "object", @@ -225,10 +219,7 @@ "type": "string" } }, - "required": [ - "success", - "error" - ] + "required": ["success", "error"] } ] } @@ -421,12 +412,7 @@ "level": { "description": "Log level", "type": "string", - "enum": [ - "debug", - "info", - "error", - "warn" - ] + "enum": ["debug", "info", "error", "warn"] }, "message": { "description": "Log message", @@ -441,11 +427,7 @@ "additionalProperties": {} } }, - "required": [ - "service", - "level", - "message" - ] + "required": ["service", "level", "message"] } } } @@ -947,10 +929,7 @@ "type": "number" } }, - "required": [ - "rows", - "cols" - ] + "required": ["rows", "cols"] } } } @@ -1223,10 +1202,7 @@ "type": "string" } }, - "required": [ - "path", - "message" - ] + "required": ["path", "message"] } } } @@ -1286,10 +1262,7 @@ } } }, - "required": [ - "providers", - "default" - ] + "required": ["providers", "default"] } } } @@ -1496,11 +1469,7 @@ ] } }, - "required": [ - "type", - "branch", - "extra" - ] + "required": ["type", "branch", "extra"] } } } @@ -2431,9 +2400,7 @@ ], "summary": "Get session", "description": "Retrieve detailed information about a specific Kilo session.", - "tags": [ - "Session" - ], + "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2661,9 +2628,7 @@ } ], "summary": "Get session children", - "tags": [ - "Session" - ], + "tags": ["Session"], "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { @@ -2860,11 +2825,7 @@ "pattern": "^msg.*" } }, - "required": [ - "modelID", - "providerID", - "messageID" - ] + "required": ["modelID", "providerID", "messageID"] } } } @@ -3290,10 +3251,7 @@ "type": "boolean" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] } } } @@ -3373,10 +3331,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3457,10 +3412,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3506,10 +3458,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3579,9 +3528,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3651,10 +3598,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -4021,10 +3965,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -4094,9 +4035,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -4157,10 +4096,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -4239,20 +4175,13 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] } ] } } }, - "required": [ - "arguments", - "command" - ] + "required": ["arguments", "command"] } } } @@ -4346,19 +4275,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "agent", - "command" - ] + "required": ["agent", "command"] } } } @@ -4448,9 +4371,7 @@ "pattern": "^prt.*" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } } } @@ -4613,16 +4534,10 @@ "properties": { "response": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "response" - ] + "required": ["response"] } } } @@ -4769,19 +4684,13 @@ "properties": { "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] }, "message": { "type": "string" } }, - "required": [ - "reply" - ] + "required": ["reply"] } } } @@ -5051,9 +4960,7 @@ } } }, - "required": [ - "answers" - ] + "required": ["answers"] } } } @@ -5231,15 +5138,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -5275,16 +5177,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -5299,10 +5195,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -5311,71 +5204,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -5432,12 +5293,7 @@ } } }, - "required": [ - "name", - "env", - "id", - "models" - ] + "required": ["name", "env", "id", "models"] } }, "default": { @@ -5456,11 +5312,7 @@ } } }, - "required": [ - "all", - "default", - "connected" - ] + "required": ["all", "default", "connected"] } } } @@ -5597,9 +5449,7 @@ } } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5679,9 +5529,7 @@ "type": "string" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5740,9 +5588,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "lines": { "type": "object", @@ -5751,9 +5597,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "line_number": { "type": "number" @@ -5773,9 +5617,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "start": { "type": "number" @@ -5784,21 +5626,11 @@ "type": "number" } }, - "required": [ - "match", - "start", - "end" - ] + "required": ["match", "start", "end"] } } }, - "required": [ - "path", - "lines", - "line_number", - "absolute_offset", - "submatches" - ] + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] } } } @@ -5844,10 +5676,7 @@ "name": "dirs", "schema": { "type": "string", - "enum": [ - "true", - "false" - ] + "enum": ["true", "false"] } }, { @@ -5855,10 +5684,7 @@ "name": "type", "schema": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] } }, { @@ -6248,10 +6074,7 @@ ] } }, - "required": [ - "name", - "config" - ] + "required": ["name", "config"] } } } @@ -6306,9 +6129,7 @@ "type": "string" } }, - "required": [ - "authorizationUrl" - ] + "required": ["authorizationUrl"] } } } @@ -6382,9 +6203,7 @@ "const": true } }, - "required": [ - "success" - ] + "required": ["success"] } } } @@ -6480,9 +6299,7 @@ "type": "string" } }, - "required": [ - "code" - ] + "required": ["code"] } } } @@ -6713,9 +6530,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -7027,9 +6842,7 @@ "type": "string" } }, - "required": [ - "command" - ] + "required": ["command"] } } } @@ -7089,12 +6902,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -7102,10 +6910,7 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } } } @@ -7256,9 +7061,7 @@ "pattern": "^ses.*" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } } } @@ -7305,10 +7108,7 @@ }, "body": {} }, - "required": [ - "path", - "body" - ] + "required": ["path", "body"] } } } @@ -7625,12 +7425,7 @@ "type": "string" } }, - "required": [ - "name", - "description", - "location", - "content" - ] + "required": ["name", "description", "location", "content"] } } } @@ -7802,9 +7597,7 @@ "type": "string" } }, - "required": [ - "enable" - ] + "required": ["enable"] } } } @@ -8061,9 +7854,7 @@ "additionalProperties": {} } }, - "required": [ - "event" - ] + "required": ["event"] } } } @@ -8112,10 +7903,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -8165,10 +7953,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -8218,10 +8003,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -8268,9 +8050,7 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } } } @@ -8308,9 +8088,7 @@ "type": "string" } }, - "required": [ - "path" - ] + "required": ["path"] } } } @@ -8356,9 +8134,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -8386,9 +8162,7 @@ "minLength": 1 } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -8440,10 +8214,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -8507,13 +8278,7 @@ } } }, - "required": [ - "id", - "worktree", - "timeCreated", - "timeUpdated", - "sandboxes" - ] + "required": ["id", "worktree", "timeCreated", "timeUpdated", "sandboxes"] } } } @@ -8565,10 +8330,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -8643,11 +8405,7 @@ } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "revert": { "type": "object", @@ -8665,9 +8423,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "permission": { "type": "object", @@ -8689,16 +8445,7 @@ "type": "number" } }, - "required": [ - "id", - "projectID", - "slug", - "directory", - "title", - "version", - "timeCreated", - "timeUpdated" - ] + "required": ["id", "projectID", "slug", "directory", "title", "version", "timeCreated", "timeUpdated"] } } } @@ -8750,10 +8497,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -8800,9 +8544,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "agent": { "type": "string" @@ -8817,10 +8559,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "tools": { "type": "object", @@ -8832,12 +8571,7 @@ } } }, - "required": [ - "role", - "time", - "agent", - "model" - ] + "required": ["role", "time", "agent", "model"] }, { "type": "object", @@ -8856,9 +8590,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "parentID": { "type": "string" @@ -8885,10 +8617,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -8921,18 +8650,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -8958,12 +8679,7 @@ ] } }, - "required": [ - "id", - "sessionID", - "timeCreated", - "data" - ] + "required": ["id", "sessionID", "timeCreated", "data"] } } } @@ -9015,10 +8731,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -9080,9 +8793,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -9092,10 +8803,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, { "type": "object", @@ -9124,16 +8832,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "type", - "text", - "time" - ] + "required": ["type", "text", "time"] }, { "type": "object", @@ -9168,11 +8870,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, { "type": "object", @@ -9205,16 +8903,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, { "type": "object", @@ -9256,20 +8948,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, { "type": "object", @@ -9305,18 +8987,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] } ] }, @@ -9328,22 +9002,12 @@ "additionalProperties": {} } }, - "required": [ - "type", - "callID", - "tool", - "state" - ] + "required": ["type", "callID", "tool", "state"] } ] } }, - "required": [ - "id", - "messageID", - "sessionID", - "data" - ] + "required": ["id", "messageID", "sessionID", "data"] } } } @@ -9409,9 +9073,7 @@ "type": "string" } }, - "required": [ - "location" - ] + "required": ["location"] } } } @@ -9477,9 +9139,7 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } } } @@ -9545,17 +9205,11 @@ "type": "string" } }, - "required": [ - "id", - "name", - "role" - ] + "required": ["id", "name", "role"] } } }, - "required": [ - "email" - ] + "required": ["email"] }, "balance": { "anyOf": [ @@ -9566,9 +9220,7 @@ "type": "number" } }, - "required": [ - "balance" - ] + "required": ["balance"] }, { "type": "null" @@ -9586,11 +9238,7 @@ ] } }, - "required": [ - "profile", - "balance", - "currentOrgId" - ] + "required": ["profile", "balance", "currentOrgId"] } } } @@ -9674,9 +9322,7 @@ ] } }, - "required": [ - "organizationId" - ] + "required": ["organizationId"] } } } @@ -9804,9 +9450,7 @@ } } }, - "required": [ - "modes" - ] + "required": ["modes"] } } } @@ -9917,10 +9561,7 @@ "type": "number" } }, - "required": [ - "prefix", - "suffix" - ] + "required": ["prefix", "suffix"] } } } @@ -9983,10 +9624,7 @@ "type": "string" } }, - "required": [ - "actionText", - "actionURL" - ] + "required": ["actionText", "actionURL"] }, "showIn": { "type": "array", @@ -9998,11 +9636,7 @@ "type": "string" } }, - "required": [ - "id", - "title", - "message" - ] + "required": ["id", "title", "message"] } } } @@ -10145,9 +9779,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ] + "required": ["sessionId"] } } } @@ -10193,14 +9825,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "provisioned", - "starting", - "restarting", - "running", - "stopped", - "destroying" - ] + "enum": ["provisioned", "starting", "restarting", "running", "stopped", "destroying"] }, { "type": "null" @@ -10223,10 +9848,7 @@ "type": "number" } }, - "required": [ - "cpus", - "memory_mb" - ] + "required": ["cpus", "memory_mb"] }, "openclawVersion": { "anyOf": [ @@ -10268,9 +9890,7 @@ "type": "string" } }, - "required": [ - "status" - ] + "required": ["status"] } } } @@ -10328,12 +9948,7 @@ "type": "string" } }, - "required": [ - "apiKey", - "userId", - "userToken", - "channelId" - ] + "required": ["apiKey", "userId", "userToken", "channelId"] }, { "type": "null" @@ -10430,13 +10045,7 @@ "type": "number" } }, - "required": [ - "session_id", - "title", - "created_at", - "updated_at", - "version" - ] + "required": ["session_id", "title", "created_at", "updated_at", "version"] } }, "nextCursor": { @@ -10450,10 +10059,7 @@ ] } }, - "required": [ - "cliSessions", - "nextCursor" - ] + "required": ["cliSessions", "nextCursor"] } } } @@ -10532,10 +10138,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "sandboxes": { "type": "array", @@ -10544,12 +10147,7 @@ } } }, - "required": [ - "id", - "worktree", - "time", - "sandboxes" - ] + "required": ["id", "worktree", "time", "sandboxes"] }, "Event.project.updated": { "type": "object", @@ -10562,10 +10160,7 @@ "$ref": "#/components/schemas/Project" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.installation.updated": { "type": "object", @@ -10581,15 +10176,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.installation.update-available": { "type": "object", @@ -10605,15 +10195,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.instance.disposed": { "type": "object", @@ -10629,15 +10214,10 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.connected": { "type": "object", @@ -10651,10 +10231,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.disposed": { "type": "object", @@ -10668,10 +10245,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.config.updated": { "type": "object", @@ -10685,10 +10259,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", @@ -10707,16 +10278,10 @@ "type": "string" } }, - "required": [ - "serverID", - "path" - ] + "required": ["serverID", "path"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.updated": { "type": "object", @@ -10730,10 +10295,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -10749,15 +10311,10 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.command.execute": { "type": "object", @@ -10798,15 +10355,10 @@ ] } }, - "required": [ - "command" - ] + "required": ["command"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.toast.show": { "type": "object", @@ -10826,12 +10378,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -10839,16 +10386,10 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.session.select": { "type": "object", @@ -10866,15 +10407,10 @@ "pattern": "^ses.*" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.tools.changed": { "type": "object", @@ -10890,15 +10426,10 @@ "type": "string" } }, - "required": [ - "server" - ] + "required": ["server"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", @@ -10917,16 +10448,10 @@ "type": "string" } }, - "required": [ - "mcpName", - "url" - ] + "required": ["mcpName", "url"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "SessionNetworkWait": { "type": "object", @@ -10952,18 +10477,10 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] } }, - "required": [ - "id", - "sessionID", - "message", - "restored", - "time" - ] + "required": ["id", "sessionID", "message", "restored", "time"] }, "Event.session.network.asked": { "type": "object", @@ -10976,10 +10493,7 @@ "$ref": "#/components/schemas/SessionNetworkWait" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.network.replied": { "type": "object", @@ -10998,16 +10512,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.network.rejected": { "type": "object", @@ -11026,16 +10534,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.network.restored": { "type": "object", @@ -11054,16 +10556,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.delta": { "type": "object", @@ -11094,19 +10590,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID", - "field", - "delta" - ] + "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionRequest": { "type": "object", @@ -11152,20 +10639,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "permission", - "patterns", - "metadata", - "always" - ] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] }, "Event.permission.asked": { "type": "object", @@ -11178,10 +10655,7 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.permission.replied": { "type": "object", @@ -11203,24 +10677,13 @@ }, "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "sessionID", - "requestID", - "reply" - ] + "required": ["sessionID", "requestID", "reply"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionOption": { "type": "object", @@ -11234,10 +10697,7 @@ "type": "string" } }, - "required": [ - "label", - "description" - ] + "required": ["label", "description"] }, "QuestionInfo": { "type": "object", @@ -11266,11 +10726,7 @@ "type": "boolean" } }, - "required": [ - "question", - "header", - "options" - ] + "required": ["question", "header", "options"] }, "QuestionRequest": { "type": "object", @@ -11301,17 +10757,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "questions" - ] + "required": ["id", "sessionID", "questions"] }, "Event.question.asked": { "type": "object", @@ -11324,10 +10773,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -11360,17 +10806,10 @@ } } }, - "required": [ - "sessionID", - "requestID", - "answers" - ] + "required": ["sessionID", "requestID", "answers"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.question.rejected": { "type": "object", @@ -11391,16 +10830,10 @@ "pattern": "^que.*" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Todo": { "type": "object", @@ -11418,11 +10851,7 @@ "type": "string" } }, - "required": [ - "content", - "status", - "priority" - ] + "required": ["content", "status", "priority"] }, "Event.todo.updated": { "type": "object", @@ -11445,73 +10874,10 @@ } } }, - "required": [ - "sessionID", - "todos" - ] + "required": ["sessionID", "todos"] } }, - "required": [ - "type", - "properties" - ] - }, - "Event.session.turn.open": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.open" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - } - }, - "required": [ - "sessionID" - ] - } - }, - "required": [ - "type", - "properties" - ] - }, - "Event.session.turn.close": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.close" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "reason": { - "type": "string", - "enum": [ - "completed", - "error", - "interrupted" - ] - } - }, - "required": [ - "sessionID", - "reason" - ] - } - }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -11523,9 +10889,7 @@ "const": "idle" } }, - "required": [ - "type" - ] + "required": ["type"] }, { "type": "object", @@ -11544,12 +10908,7 @@ "type": "number" } }, - "required": [ - "type", - "attempt", - "message", - "next" - ] + "required": ["type", "attempt", "message", "next"] }, { "type": "object", @@ -11559,9 +10918,7 @@ "const": "busy" } }, - "required": [ - "type" - ] + "required": ["type"] }, { "type": "object", @@ -11577,11 +10934,7 @@ "type": "string" } }, - "required": [ - "type", - "requestID", - "message" - ] + "required": ["type", "requestID", "message"] } ] }, @@ -11603,16 +10956,10 @@ "$ref": "#/components/schemas/SessionStatus" } }, - "required": [ - "sessionID", - "status" - ] + "required": ["sessionID", "status"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.idle": { "type": "object", @@ -11629,15 +10976,10 @@ "pattern": "^ses.*" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.compacted": { "type": "object", @@ -11654,15 +10996,10 @@ "pattern": "^ses.*" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.edited": { "type": "object", @@ -11678,15 +11015,10 @@ "type": "string" } }, - "required": [ - "file" - ] + "required": ["file"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.watcher.updated": { "type": "object", @@ -11718,16 +11050,10 @@ ] } }, - "required": [ - "file", - "event" - ] + "required": ["file", "event"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.command.executed": { "type": "object", @@ -11754,18 +11080,52 @@ "pattern": "^msg.*" } }, - "required": [ - "name", - "sessionID", - "arguments", - "messageID" - ] + "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] + }, + "Event.session.turn.open": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.open" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.turn.close": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.close" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["completed", "error", "interrupted"] + } + }, + "required": ["sessionID", "reason"] + } + }, + "required": ["type", "properties"] }, "FileDiff": { "type": "object", @@ -11787,20 +11147,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "before", - "after", - "additions", - "deletions" - ] + "required": ["file", "before", "after", "additions", "deletions"] }, "Event.session.diff": { "type": "object", @@ -11823,16 +11173,10 @@ } } }, - "required": [ - "sessionID", - "diff" - ] + "required": ["sessionID", "diff"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "ProviderAuthError": { "type": "object", @@ -11851,16 +11195,10 @@ "type": "string" } }, - "required": [ - "providerID", - "message" - ] + "required": ["providerID", "message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "UnknownError": { "type": "object", @@ -11876,15 +11214,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageOutputLengthError": { "type": "object", @@ -11898,10 +11231,7 @@ "properties": {} } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageAbortedError": { "type": "object", @@ -11917,15 +11247,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "StructuredOutputError": { "type": "object", @@ -11944,16 +11269,10 @@ "type": "number" } }, - "required": [ - "message", - "retries" - ] + "required": ["message", "retries"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "ContextOverflowError": { "type": "object", @@ -11972,15 +11291,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "APIError": { "type": "object", @@ -12023,16 +11337,10 @@ } } }, - "required": [ - "message", - "isRetryable" - ] + "required": ["message", "isRetryable"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "Event.session.error": { "type": "object", @@ -12076,10 +11384,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", @@ -12097,10 +11402,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.kilo-sessions.remote-status-changed": { "type": "object", @@ -12119,16 +11421,10 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.ready": { "type": "object", @@ -12144,15 +11440,10 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.failed": { "type": "object", @@ -12168,15 +11459,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Pty": { "type": "object", @@ -12202,24 +11488,13 @@ }, "status": { "type": "string", - "enum": [ - "running", - "exited" - ] + "enum": ["running", "exited"] }, "pid": { "type": "number" } }, - "required": [ - "id", - "title", - "command", - "args", - "cwd", - "status", - "pid" - ] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] }, "Event.pty.created": { "type": "object", @@ -12235,15 +11510,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.updated": { "type": "object", @@ -12259,15 +11529,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.exited": { "type": "object", @@ -12287,16 +11552,10 @@ "type": "number" } }, - "required": [ - "id", - "exitCode" - ] + "required": ["id", "exitCode"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.deleted": { "type": "object", @@ -12313,15 +11572,10 @@ "pattern": "^pty.*" } }, - "required": [ - "id" - ] + "required": ["id"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.ready": { "type": "object", @@ -12340,16 +11594,10 @@ "type": "string" } }, - "required": [ - "name", - "branch" - ] + "required": ["name", "branch"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.failed": { "type": "object", @@ -12365,15 +11613,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "OutputFormatText": { "type": "object", @@ -12383,9 +11626,7 @@ "const": "text" } }, - "required": [ - "type" - ] + "required": ["type"] }, "JSONSchema": { "type": "object", @@ -12411,10 +11652,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "schema" - ] + "required": ["type", "schema"] }, "OutputFormat": { "anyOf": [ @@ -12448,9 +11686,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -12471,9 +11707,7 @@ } } }, - "required": [ - "diffs" - ] + "required": ["diffs"] }, "agent": { "type": "string" @@ -12488,10 +11722,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "system": { "type": "string" @@ -12532,14 +11763,7 @@ } } }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ] + "required": ["id", "sessionID", "role", "time", "agent", "model"] }, "AssistantMessage": { "type": "object", @@ -12566,9 +11790,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "error": { "anyOf": [ @@ -12621,10 +11843,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -12657,18 +11876,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -12721,16 +11932,10 @@ "$ref": "#/components/schemas/Message" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.removed": { "type": "object", @@ -12751,16 +11956,10 @@ "pattern": "^msg.*" } }, - "required": [ - "sessionID", - "messageID" - ] + "required": ["sessionID", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "TextPart": { "type": "object", @@ -12800,9 +11999,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -12812,13 +12009,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ] + "required": ["id", "sessionID", "messageID", "type", "text"] }, "SubtaskPart": { "type": "object", @@ -12858,24 +12049,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] }, "ReasoningPart": { "type": "object", @@ -12916,19 +12096,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "text", "time"] }, "FilePartSourceText": { "type": "object", @@ -12947,11 +12118,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] }, "FileSource": { "type": "object", @@ -12967,11 +12134,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "path" - ] + "required": ["text", "type", "path"] }, "Range": { "type": "object", @@ -12986,10 +12149,7 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] }, "end": { "type": "object", @@ -13001,16 +12161,10 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "SymbolSource": { "type": "object", @@ -13037,14 +12191,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ] + "required": ["text", "type", "path", "range", "name", "kind"] }, "ResourceSource": { "type": "object", @@ -13063,12 +12210,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "clientName", - "uri" - ] + "required": ["text", "type", "clientName", "uri"] }, "FilePartSource": { "anyOf": [ @@ -13115,14 +12257,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"] }, "ToolStatePending": { "type": "object", @@ -13142,11 +12277,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, "ToolStateRunning": { "type": "object", @@ -13179,16 +12310,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, "ToolStateCompleted": { "type": "object", @@ -13230,10 +12355,7 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "attachments": { "type": "array", @@ -13242,14 +12364,7 @@ } } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, "ToolStateError": { "type": "object", @@ -13285,18 +12400,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] }, "ToolState": { "anyOf": [ @@ -13350,15 +12457,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] }, "StepStartPart": { "type": "object", @@ -13383,12 +12482,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ] + "required": ["id", "sessionID", "messageID", "type"] }, "StepFinishPart": { "type": "object", @@ -13443,29 +12537,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] }, "SnapshotPart": { "type": "object", @@ -13490,13 +12568,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ] + "required": ["id", "sessionID", "messageID", "type", "snapshot"] }, "PatchPart": { "type": "object", @@ -13527,14 +12599,7 @@ } } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"] }, "AgentPart": { "type": "object", @@ -13575,20 +12640,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ] + "required": ["id", "sessionID", "messageID", "type", "name"] }, "RetryPart": { "type": "object", @@ -13622,20 +12677,10 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] }, "CompactionPart": { "type": "object", @@ -13663,13 +12708,7 @@ "type": "boolean" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ] + "required": ["id", "sessionID", "messageID", "type", "auto"] }, "Part": { "anyOf": [ @@ -13732,17 +12771,10 @@ "type": "number" } }, - "required": [ - "sessionID", - "part", - "time" - ] + "required": ["sessionID", "part", "time"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.removed": { "type": "object", @@ -13767,25 +12799,14 @@ "pattern": "^prt.*" } }, - "required": [ - "sessionID", - "messageID", - "partID" - ] + "required": ["sessionID", "messageID", "partID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionAction": { "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] + "enum": ["allow", "deny", "ask"] }, "PermissionRule": { "type": "object", @@ -13800,11 +12821,7 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": [ - "permission", - "pattern", - "action" - ] + "required": ["permission", "pattern", "action"] }, "PermissionRuleset": { "type": "array", @@ -13864,26 +12881,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -13892,9 +12897,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -13918,10 +12921,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -13944,20 +12944,10 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] }, "Event.session.created": { "type": "object", @@ -13977,16 +12967,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.updated": { "type": "object", @@ -14006,16 +12990,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.deleted": { "type": "object", @@ -14035,16 +13013,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event": { "anyOf": [ @@ -14126,12 +13098,6 @@ { "$ref": "#/components/schemas/Event.todo.updated" }, - { - "$ref": "#/components/schemas/Event.session.turn.open" - }, - { - "$ref": "#/components/schemas/Event.session.turn.close" - }, { "$ref": "#/components/schemas/Event.session.status" }, @@ -14150,6 +13116,12 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.session.turn.open" + }, + { + "$ref": "#/components/schemas/Event.session.turn.close" + }, { "$ref": "#/components/schemas/Event.session.diff" }, @@ -14219,10 +13191,7 @@ "$ref": "#/components/schemas/Event" } }, - "required": [ - "directory", - "payload" - ] + "required": ["directory", "payload"] }, "SyncEvent.message.updated": { "type": "object", @@ -14246,17 +13215,10 @@ "$ref": "#/components/schemas/Message" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.message.removed": { "type": "object", @@ -14281,17 +13243,10 @@ "pattern": "^msg.*" } }, - "required": [ - "sessionID", - "messageID" - ] + "required": ["sessionID", "messageID"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.message.part.updated": { "type": "object", @@ -14318,18 +13273,10 @@ "type": "number" } }, - "required": [ - "sessionID", - "part", - "time" - ] + "required": ["sessionID", "part", "time"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.message.part.removed": { "type": "object", @@ -14358,18 +13305,10 @@ "pattern": "^prt.*" } }, - "required": [ - "sessionID", - "messageID", - "partID" - ] + "required": ["sessionID", "messageID", "partID"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.session.created": { "type": "object", @@ -14393,17 +13332,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.session.updated": { "type": "object", @@ -14519,26 +13451,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, { "type": "null" @@ -14559,9 +13479,7 @@ ] } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "anyOf": [ @@ -14627,12 +13545,7 @@ ] } }, - "required": [ - "created", - "updated", - "compacting", - "archived" - ] + "required": ["created", "updated", "compacting", "archived"] }, "permission": { "anyOf": [ @@ -14664,9 +13577,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, { "type": "null" @@ -14689,17 +13600,10 @@ ] } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent.session.deleted": { "type": "object", @@ -14723,17 +13627,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "sessionID", - "info" - ] + "required": ["sessionID", "info"] } }, - "required": [ - "type", - "aggregate", - "data" - ] + "required": ["type", "aggregate", "data"] }, "SyncEvent": { "type": "object", @@ -14742,19 +13639,12 @@ "$ref": "#/components/schemas/SyncEvent" } }, - "required": [ - "payload" - ] + "required": ["payload"] }, "LogLevel": { "description": "Log level", "type": "string", - "enum": [ - "DEBUG", - "INFO", - "WARN", - "ERROR" - ] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, "ServerConfig": { "description": "Server configuration for kilo serve and web commands", @@ -14792,11 +13682,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] + "enum": ["ask", "allow", "deny"] }, { "type": "null" @@ -14936,11 +13822,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "hidden": { "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", @@ -14962,15 +13844,7 @@ }, { "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } ] }, @@ -15056,15 +13930,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -15100,16 +13969,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -15124,10 +13987,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -15136,71 +13996,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -15342,10 +14170,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "command" - ], + "required": ["type", "command"], "additionalProperties": false }, "McpOAuthConfig": { @@ -15411,19 +14236,13 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "url" - ], + "required": ["type", "url"], "additionalProperties": false }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": [ - "auto", - "stretch" - ] + "enum": ["auto", "stretch"] }, "Config": { "type": "object", @@ -15463,9 +14282,7 @@ "type": "boolean" } }, - "required": [ - "template" - ] + "required": ["template"] } }, "skills": { @@ -15531,11 +14348,7 @@ "share": { "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", - "enum": [ - "manual", - "auto", - "disabled" - ] + "enum": ["manual", "auto", "disabled"] }, "autoshare": { "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", @@ -15690,9 +14503,7 @@ "type": "boolean" } }, - "required": [ - "enabled" - ], + "required": ["enabled"], "additionalProperties": false } ] @@ -15762,9 +14573,7 @@ "const": true } }, - "required": [ - "disabled" - ] + "required": ["disabled"] }, { "type": "object", @@ -15801,9 +14610,7 @@ "additionalProperties": {} } }, - "required": [ - "command" - ] + "required": ["command"] } ] } @@ -15920,11 +14727,7 @@ "const": false } }, - "required": [ - "data", - "errors", - "success" - ] + "required": ["data", "errors", "success"] }, "OAuth": { "type": "object", @@ -15949,12 +14752,7 @@ "type": "string" } }, - "required": [ - "type", - "refresh", - "access", - "expires" - ] + "required": ["type", "refresh", "access", "expires"] }, "ApiAuth": { "type": "object", @@ -15967,10 +14765,7 @@ "type": "string" } }, - "required": [ - "type", - "key" - ] + "required": ["type", "key"] }, "WellKnownAuth": { "type": "object", @@ -15986,11 +14781,7 @@ "type": "string" } }, - "required": [ - "type", - "key", - "token" - ] + "required": ["type", "key", "token"] }, "Auth": { "anyOf": [ @@ -16019,15 +14810,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "Model": { "type": "object", @@ -16051,11 +14837,7 @@ "type": "string" } }, - "required": [ - "id", - "url", - "npm" - ] + "required": ["id", "url", "npm"] }, "name": { "type": "string" @@ -16097,13 +14879,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "output": { "type": "object", @@ -16124,13 +14900,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "interleaved": { "anyOf": [ @@ -16142,28 +14912,15 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ] + "required": ["field"] } ] } }, - "required": [ - "temperature", - "reasoning", - "attachment", - "toolcall", - "input", - "output", - "interleaved" - ] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] }, "cost": { "type": "object", @@ -16184,10 +14941,7 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] }, "experimentalOver200K": { "type": "object", @@ -16208,24 +14962,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] }, "limit": { "type": "object", @@ -16240,19 +14983,11 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { "type": "object", @@ -16291,26 +15026,14 @@ }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] } }, "required": [ @@ -16338,12 +15061,7 @@ }, "source": { "type": "string", - "enum": [ - "env", - "config", - "custom", - "api" - ] + "enum": ["env", "config", "custom", "api"] }, "env": { "type": "array", @@ -16371,14 +15089,7 @@ } } }, - "required": [ - "id", - "name", - "source", - "env", - "options", - "models" - ] + "required": ["id", "name", "source", "env", "options", "models"] }, "ToolIDs": { "type": "array", @@ -16397,11 +15108,7 @@ }, "parameters": {} }, - "required": [ - "id", - "description", - "parameters" - ] + "required": ["id", "description", "parameters"] }, "ToolList": { "type": "array", @@ -16461,15 +15168,7 @@ "type": "string" } }, - "required": [ - "id", - "type", - "branch", - "name", - "directory", - "extra", - "projectID" - ] + "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", @@ -16484,11 +15183,7 @@ "type": "string" } }, - "required": [ - "name", - "branch", - "directory" - ] + "required": ["name", "branch", "directory"] }, "WorktreeCreateInput": { "type": "object", @@ -16509,9 +15204,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeResetInput": { "type": "object", @@ -16520,9 +15213,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeDiffItem": { "type": "object", @@ -16544,11 +15235,7 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] }, "tracked": { "type": "boolean" @@ -16588,10 +15275,7 @@ "type": "string" } }, - "required": [ - "id", - "worktree" - ] + "required": ["id", "worktree"] }, "GlobalSession": { "type": "object", @@ -16645,26 +15329,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -16673,9 +15345,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -16699,10 +15369,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -16725,9 +15392,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "project": { "anyOf": [ @@ -16743,16 +15408,7 @@ "type": "string" } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time", - "project" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] }, "McpResource": { "type": "object", @@ -16773,11 +15429,7 @@ "type": "string" } }, - "required": [ - "name", - "uri", - "client" - ] + "required": ["name", "uri", "client"] }, "TextPartInput": { "type": "object", @@ -16809,9 +15461,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -16821,10 +15471,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, "FilePartInput": { "type": "object", @@ -16850,11 +15497,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] }, "AgentPartInput": { "type": "object", @@ -16887,17 +15530,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "type", - "name" - ] + "required": ["type", "name"] }, "SubtaskPartInput": { "type": "object", @@ -16929,21 +15565,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "type", - "prompt", - "description", - "agent" - ] + "required": ["type", "prompt", "description", "agent"] }, "ProviderAuthMethod": { "type": "object", @@ -17005,18 +15633,10 @@ "type": "string" } }, - "required": [ - "key", - "op", - "value" - ] + "required": ["key", "op", "value"] } }, - "required": [ - "type", - "key", - "message" - ] + "required": ["type", "key", "message"] }, { "type": "object", @@ -17046,10 +15666,7 @@ "type": "string" } }, - "required": [ - "label", - "value" - ] + "required": ["label", "value"] } }, "when": { @@ -17074,28 +15691,16 @@ "type": "string" } }, - "required": [ - "key", - "op", - "value" - ] + "required": ["key", "op", "value"] } }, - "required": [ - "type", - "key", - "message", - "options" - ] + "required": ["type", "key", "message", "options"] } ] } } }, - "required": [ - "type", - "label" - ] + "required": ["type", "label"] }, "ProviderAuthAuthorization": { "type": "object", @@ -17119,11 +15724,7 @@ "type": "string" } }, - "required": [ - "url", - "method", - "instructions" - ] + "required": ["url", "method", "instructions"] }, "Symbol": { "type": "object", @@ -17144,17 +15745,10 @@ "$ref": "#/components/schemas/Range" } }, - "required": [ - "uri", - "range" - ] + "required": ["uri", "range"] } }, - "required": [ - "name", - "kind", - "location" - ] + "required": ["name", "kind", "location"] }, "FileNode": { "type": "object", @@ -17170,32 +15764,20 @@ }, "type": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] }, "ignored": { "type": "boolean" } }, - "required": [ - "name", - "path", - "absolute", - "type", - "ignored" - ] + "required": ["name", "path", "absolute", "type", "ignored"] }, "FileContent": { "type": "object", "properties": { "type": { "type": "string", - "enum": [ - "text", - "binary" - ] + "enum": ["text", "binary"] }, "content": { "type": "string" @@ -17242,24 +15824,14 @@ } } }, - "required": [ - "oldStart", - "oldLines", - "newStart", - "newLines", - "lines" - ] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] } }, "index": { "type": "string" } }, - "required": [ - "oldFileName", - "newFileName", - "hunks" - ] + "required": ["oldFileName", "newFileName", "hunks"] }, "encoding": { "type": "string", @@ -17269,10 +15841,7 @@ "type": "string" } }, - "required": [ - "type", - "content" - ] + "required": ["type", "content"] }, "File": { "type": "object", @@ -17292,19 +15861,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "path", - "added", - "removed", - "status" - ] + "required": ["path", "added", "removed", "status"] }, "MCPStatusConnected": { "type": "object", @@ -17314,9 +15874,7 @@ "const": "connected" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusDisabled": { "type": "object", @@ -17326,9 +15884,7 @@ "const": "disabled" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusFailed": { "type": "object", @@ -17341,10 +15897,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatusNeedsAuth": { "type": "object", @@ -17354,9 +15907,7 @@ "const": "needs_auth" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusNeedsClientRegistration": { "type": "object", @@ -17369,10 +15920,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatus": { "anyOf": [ @@ -17412,13 +15960,7 @@ "type": "string" } }, - "required": [ - "home", - "state", - "config", - "worktree", - "directory" - ] + "required": ["home", "state", "config", "worktree", "directory"] }, "VcsInfo": { "type": "object", @@ -17445,11 +15987,7 @@ }, "source": { "type": "string", - "enum": [ - "command", - "mcp", - "skill" - ] + "enum": ["command", "mcp", "skill"] }, "template": { "anyOf": [ @@ -17471,11 +16009,7 @@ } } }, - "required": [ - "name", - "template", - "hints" - ] + "required": ["name", "template", "hints"] }, "Agent": { "type": "object", @@ -17494,11 +16028,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "native": { "type": "boolean" @@ -17528,10 +16058,7 @@ "type": "string" } }, - "required": [ - "modelID", - "providerID" - ] + "required": ["modelID", "providerID"] }, "variant": { "type": "string" @@ -17552,12 +16079,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "name", - "mode", - "permission", - "options" - ] + "required": ["name", "mode", "permission", "options"] }, "LSPStatus": { "type": "object", @@ -17584,12 +16106,7 @@ ] } }, - "required": [ - "id", - "name", - "root", - "status" - ] + "required": ["id", "name", "root", "status"] }, "FormatterStatus": { "type": "object", @@ -17607,12 +16124,8 @@ "type": "boolean" } }, - "required": [ - "name", - "extensions", - "enabled" - ] + "required": ["name", "extensions", "enabled"] } } } -} \ No newline at end of file +} From 017ca5738c1b69aa30d0952bba400d8ecfd4888e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Apr 2026 12:28:00 -0400 Subject: [PATCH 11/13] fix(jetbrains): address review feedback from #8886 and #8849 - Add ensureActive() in monitorProcess() to prevent stale process monitor from racing fresh connections during intentional restarts - Refactor load() to use local variables, assigning to shared fields only after ensureActive() to eliminate NPE race with clear() - Refactor fetchProfile() to return FetchResult instead of throwing, making all fetchers use consistent result types - Seed session statuses in list() so first load/reconnect gets current status instead of empty map - Use stored worktree directory in session get/delete to fix lookups for sessions created in worktrees - Make KiloBackendCliManager.process @Volatile and update KDoc to document that exited() is called off-mutex from IO dispatcher --- .../backend/app/KiloBackendAppService.kt | 81 +++++++++++-------- .../backend/app/KiloBackendCliManager.kt | 5 +- .../app/KiloBackendConnectionService.kt | 2 + .../backend/app/KiloBackendSessionManager.kt | 1 + .../backend/rpc/KiloSessionRpcApiImpl.kt | 12 ++- 5 files changed, 61 insertions(+), 40 deletions(-) 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 bc6041ff66c..120de07ab35 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 @@ -177,31 +177,32 @@ class KiloBackendAppService private constructor( _appState.value = KiloAppState.Loading(progress.get()) val errors = mutableListOf() + var cfg: Config? = null + var prof: KiloProfile200Response? = null + var notifs: List = emptyList() try { coroutineScope { launch { - try { - val result = fetchProfile() - progress.updateAndGet { it.copy(profile = result) } - .also { _appState.value = KiloAppState.Loading(it) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val err = LoadError( - resource = "profile", - status = (e as? ClientException)?.statusCode - ?: (e as? ServerException)?.statusCode, - detail = e.message, - ) - synchronized(errors) { errors.add(err) } - throw LoadFailure(err) + val result = fetchProfile() + val status = when { + result.error != null -> { + synchronized(errors) { errors.add(result.error) } + throw LoadFailure(result.error) + } + result.value != null -> { + prof = result.value + ProfileResult.LOADED + } + else -> ProfileResult.NOT_LOGGED_IN } + progress.updateAndGet { it.copy(profile = status) } + .also { _appState.value = KiloAppState.Loading(it) } } launch { val result = fetchWithRetry("config") { fetchConfig() } if (result.value != null) { - config = result.value + cfg = result.value progress.updateAndGet { it.copy(config = true) } .also { _appState.value = KiloAppState.Loading(it) } } else { @@ -213,7 +214,7 @@ class KiloBackendAppService private constructor( launch { val result = fetchWithRetry("notifications") { fetchNotifications() } if (result.value != null) { - notifications = result.value + notifs = result.value progress.updateAndGet { it.copy(notifications = true) } .also { _appState.value = KiloAppState.Loading(it) } } else { @@ -224,18 +225,21 @@ class KiloBackendAppService private constructor( } } - ensureActive() - sessions.start(connection.api!!, connection.events) - workspaces.start(connection.api!!, connection.events) - _appState.value = KiloAppState.Ready( - AppData( - profile = profile, - config = config!!, - notifications = notifications, + ensureActive() + profile = prof + config = cfg + notifications = notifs + sessions.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.events) + _appState.value = KiloAppState.Ready( + AppData( + profile = prof, + config = cfg!!, + notifications = notifs, + ) ) - ) - log.info("Application started — config, profile, notifications loaded") - startWatchingGlobalSseEvents() + log.info("Application started — config, profile, notifications loaded") + startWatchingGlobalSseEvents() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -249,23 +253,30 @@ class KiloBackendAppService private constructor( } } - private suspend fun fetchProfile(): ProfileResult { - val client = connection.api ?: return ProfileResult.NOT_LOGGED_IN + /** + * Fetch the user profile. Returns [FetchResult.ok] with the response + * on success, [FetchResult.ok] with `null` when not logged in (401), + * or [FetchResult.fail] on other errors. Never throws. + */ + private suspend fun fetchProfile(): FetchResult { + val client = connection.api + ?: return FetchResult.ok(null) return try { val response = client.kiloProfile() - profile = response log.info("Profile: ${response.profile.email}") - ProfileResult.LOADED + FetchResult.ok(response) } catch (e: ClientException) { if (e.statusCode == 401) { log.info("Profile: not logged in (401)") - return ProfileResult.NOT_LOGGED_IN + return FetchResult.ok(null) } log.warn("Profile fetch failed: HTTP ${e.statusCode}", e) - throw e + logResponseBody("profile", e) + FetchResult.fail("profile", e) } catch (e: Exception) { log.warn("Profile fetch failed: ${e.message}", e) - throw e + logResponseBody("profile", e) + FetchResult.fail("profile", e) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt index 52980482c68..c80ab7612b4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt @@ -25,7 +25,9 @@ import java.util.concurrent.TimeUnit * spawns `kilo serve --port 0`, and exposes the result as [State]. * * Concurrency is handled by the owning [KiloBackendAppService] — all public - * methods are called under its mutex so no internal synchronization is needed. + * methods except [exited] are called under its mutex. [exited] is called from + * [KiloConnectionService]'s IO dispatcher and is thread-safe via the stale-ref + * guard and volatile [process] field. */ class KiloBackendCliManager( private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java), @@ -37,6 +39,7 @@ class KiloBackendCliManager( private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""") } + @Volatile private var process: Process? = null private var hook: Thread? = null diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index 15e8d8b6e77..a2c4530317a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -6,6 +6,7 @@ import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -304,6 +305,7 @@ class KiloConnectionService( private fun monitorProcess(proc: Process) = cs.launch(Dispatchers.IO) { proc.waitFor() + ensureActive() server.exited(proc) val code = proc.exitValue() log.warn("CLI process exited with code $code") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 330cb46c61b..bf9ff224422 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -97,6 +97,7 @@ class KiloBackendSessionManager( /** List root sessions for a directory and include current statuses. */ fun list(dir: String): SessionListDto { + seed(dir) val raw = requireClient().sessionList(directory = dir, roots = true) val mapped = raw.map(::dto) val ids = mapped.map { it.id }.toSet() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 2cd29fbe98a..e2dcf4025d5 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -34,11 +34,15 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun create(directory: String): SessionDto = workspaces.get(directory).createSession() - override suspend fun get(id: String, directory: String): SessionDto = - sessions.get(id, directory) + override suspend fun get(id: String, directory: String): SessionDto { + val dir = sessions.getDirectory(id, directory) + return sessions.get(id, dir) + } - override suspend fun delete(id: String, directory: String) = - workspaces.get(directory).deleteSession(id) + override suspend fun delete(id: String, directory: String) { + val dir = sessions.getDirectory(id, directory) + workspaces.get(dir).deleteSession(id) + } override suspend fun statuses(): Flow> = sessions.statuses From 9ad515c18edb6d7458e92947e46d2db4ec079857 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Apr 2026 12:59:24 -0400 Subject: [PATCH 12/13] fix(jetbrains): replace synchronized(errors) with CopyOnWriteArrayList --- .../ai/kilocode/backend/app/KiloBackendAppService.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 120de07ab35..66a80d3c354 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 @@ -29,6 +29,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.net.ConnectException import java.net.SocketTimeoutException +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicReference /** @@ -176,7 +177,7 @@ class KiloBackendAppService private constructor( val progress = AtomicReference(LoadProgress()) _appState.value = KiloAppState.Loading(progress.get()) - val errors = mutableListOf() + val errors = CopyOnWriteArrayList() var cfg: Config? = null var prof: KiloProfile200Response? = null var notifs: List = emptyList() @@ -187,7 +188,7 @@ class KiloBackendAppService private constructor( val result = fetchProfile() val status = when { result.error != null -> { - synchronized(errors) { errors.add(result.error) } + errors.add(result.error) throw LoadFailure(result.error) } result.value != null -> { @@ -207,7 +208,7 @@ class KiloBackendAppService private constructor( .also { _appState.value = KiloAppState.Loading(it) } } else { val err = result.error!! - synchronized(errors) { errors.add(err) } + errors.add(err) throw LoadFailure(err) } } @@ -219,7 +220,7 @@ class KiloBackendAppService private constructor( .also { _appState.value = KiloAppState.Loading(it) } } else { val err = result.error!! - synchronized(errors) { errors.add(err) } + errors.add(err) throw LoadFailure(err) } } @@ -246,7 +247,7 @@ class KiloBackendAppService private constructor( log.warn("Application start failed: ${e.message}") _appState.value = KiloAppState.Error( message = "Failed to load required data", - errors = synchronized(errors) { errors.toList() }, + errors = errors.toList(), ) } } From 2e542920103129c4e43d2a2e014175050d43d3b1 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:52:55 +0000 Subject: [PATCH 13/13] feat(jetbrains): add detekt for cyclomatic complexity and file size limits Adds detekt static analysis to enforce complexity and file size caps in kilo-jetbrains, mirroring the ESLint complexity/max-lines rules used by the VS Code extension. --- packages/kilo-jetbrains/build.gradle.kts | 14 +++++++++ packages/kilo-jetbrains/detekt.yml | 30 +++++++++++++++++++ .../kilo-jetbrains/gradle/libs.versions.toml | 2 ++ 3 files changed, 46 insertions(+) create mode 100644 packages/kilo-jetbrains/detekt.yml diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3f7698cd3c3..4b3e02952db 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -9,6 +9,7 @@ plugins { application id("java") alias(libs.plugins.intellij.platform) + alias(libs.plugins.detekt) alias(libs.plugins.kotlin) apply false alias(libs.plugins.kotlin.serialization) apply false @@ -17,6 +18,19 @@ plugins { subprojects { apply(plugin = "org.jetbrains.intellij.platform.module") + apply(plugin = "io.gitlab.arturbosch.detekt") + + detekt { + config.setFrom(rootProject.file("detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/kotlin") + } +} + +detekt { + config.setFrom(file("detekt.yml")) + buildUponDefaultConfig = true + source.setFrom("src/main/kotlin") } allprojects { diff --git a/packages/kilo-jetbrains/detekt.yml b/packages/kilo-jetbrains/detekt.yml new file mode 100644 index 00000000000..e5e889fa4d8 --- /dev/null +++ b/packages/kilo-jetbrains/detekt.yml @@ -0,0 +1,30 @@ +# Detekt configuration for kilo-jetbrains. +# +# Enforces file size and cyclomatic complexity limits — the Kotlin +# equivalent of the ESLint complexity/max-lines rules used by the +# VS Code extension (packages/kilo-vscode/eslint.config.mjs). +# +# New code must stay within the default limits. Do not raise these +# caps; refactor instead. + +complexity: + CyclomaticComplexMethod: + active: true + threshold: 15 + LongMethod: + active: true + threshold: 60 + LargeClass: + active: true + threshold: 600 + TooManyFunctions: + active: true + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + LongParameterList: + active: false + NestedBlockDepth: + active: true + threshold: 4 diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 6d236a36ae1..964f07922a7 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -7,6 +7,7 @@ kotlin-serialization-plugin = "2.1.20" kotlin-serialization = "1.8.1" okhttp = "4.12.0" openapi-generator = "7.21.0" +detekt = "1.23.8" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -16,6 +17,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } [plugins] +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" } rpc = { id = "rpc", version.ref = "intellij-rpc-plugin" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" }