Merge pull request #8886 from Kilo-Org/pie-parmesan

feat(jetbrains): app lifecycle, workspace loading, session management, and welcome UI
This commit is contained in:
Kirill Kalishev
2026-04-15 14:03:44 -04:00
committed by GitHub
42 changed files with 3443 additions and 274 deletions
@@ -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 ?: ""
}
@@ -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
@@ -18,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
@@ -27,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
/**
@@ -76,8 +79,10 @@ class KiloBackendAppService private constructor(
cs.launch { reconnect() }
}, log = log)
private var router: Job? = null
private var watcher: Job? = null
private var eventWatcher: Job? = null
private var loader: Job? = null
private val loadLock = Any()
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
@@ -85,6 +90,9 @@ class KiloBackendAppService private constructor(
val events: SharedFlow<SseEvent> get() = connection.events
val api: DefaultApi? get() = connection.api
val sessions = KiloBackendSessionManager(cs, log)
val workspaces = KiloBackendWorkspaceManager(cs, sessions, log)
@Volatile var profile: KiloProfile200Response? = null
private set
@@ -98,6 +106,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 +144,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
@@ -159,98 +169,115 @@ class KiloBackendAppService private constructor(
* On failure of required data, transitions to [KiloAppState.Error].
*/
private fun load() {
loader?.cancel()
loader = cs.launch {
log.info("Loading global data")
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<LoadError>()
val errors = CopyOnWriteArrayList<LoadError>()
var cfg: Config? = null
var prof: KiloProfile200Response? = null
var notifs: List<KiloNotifications200ResponseInner> = emptyList()
try {
coroutineScope {
launch {
try {
try {
coroutineScope {
launch {
val result = fetchProfile()
progress.updateAndGet { it.copy(profile = result) }
val status = when {
result.error != null -> {
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) }
} 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) {
cfg = result.value
progress.updateAndGet { it.copy(config = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
errors.add(err)
throw LoadFailure(err)
}
}
launch {
val result = fetchWithRetry("notifications") { fetchNotifications() }
if (result.value != null) {
notifs = result.value
progress.updateAndGet { it.copy(notifications = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
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)
}
}
}
_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("Global data loaded — app is Ready")
ensureRouter()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn("Global data load failed: ${e.message}")
_appState.value = KiloAppState.Error(
message = "Failed to load required data",
errors = synchronized(errors) { errors.toList() },
)
log.info("Application started — config, profile, notifications loaded")
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 = errors.toList(),
)
}
}
}
}
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<KiloProfile200Response?> {
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)
}
}
@@ -312,22 +339,54 @@ class KiloBackendAppService private constructor(
return last
}
private fun ensureRouter() {
if (router?.isActive == true) return
router = 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)
)
/**
* 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() {
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")
}
}
}
"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()
}
}
}
}
@@ -335,8 +394,12 @@ class KiloBackendAppService private constructor(
}
private fun clear() {
loader?.cancel()
router?.cancel()
synchronized(loadLock) {
loader?.cancel()
eventWatcher?.cancel()
}
workspaces.stop()
sessions.stop()
profile = null
config = null
notifications = emptyList()
@@ -344,6 +407,8 @@ class KiloBackendAppService private constructor(
}
override fun dispose() {
watcher?.cancel()
watcher = null
clear()
connection.dispose()
server.dispose()
@@ -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
/**
@@ -21,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),
@@ -33,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
@@ -145,6 +152,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 +285,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<String, String> = 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)
@@ -1,11 +1,12 @@
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -197,6 +198,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")
}
@@ -301,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")
@@ -0,0 +1,188 @@
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.flow.update
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<String, Regex>()
/** 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<String, String>()
private val _statuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
val statuses: StateFlow<Map<String, SessionStatusDto>> = _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<SseEvent>) {
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 {
seed(dir)
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.update { it + 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.update { it + (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 },
)
}
@@ -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,
@@ -0,0 +1,156 @@
@file:Suppress("UnstableApiUsage")
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
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.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 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
/**
* Backend implementation of [KiloProjectRpcApi].
*
* 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 {
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<KiloWorkspaceStateDto> =
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()
}
// ------ mapping: domain model → DTO ------
private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto =
when (state) {
KiloWorkspaceState.Pending -> KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)
is KiloWorkspaceState.Loading -> KiloWorkspaceStateDto(
status = KiloWorkspaceStatusDto.LOADING,
progress = progress(state.progress),
)
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 KiloWorkspaceState.Error -> KiloWorkspaceStateDto(
status = KiloWorkspaceStatusDto.ERROR,
error = state.message,
)
}
private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto(
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,
)
}
@@ -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<KiloProjectRpcApi>()) {
KiloProjectRpcApiImpl()
}
}
}
@@ -0,0 +1,55 @@
@file:Suppress("UnstableApiUsage")
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
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.service
import kotlinx.coroutines.flow.Flow
/**
* Backend implementation of [KiloSessionRpcApi].
*
* 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 workspaces: KiloBackendWorkspaceManager
get() = service<KiloBackendAppService>().workspaces
private val sessions: KiloBackendSessionManager
get() = service<KiloBackendAppService>().sessions
override suspend fun list(directory: String): SessionListDto =
workspaces.get(directory).sessions()
override suspend fun create(directory: String): SessionDto =
workspaces.get(directory).createSession()
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) {
val dir = sessions.getDirectory(id, directory)
workspaces.get(dir).deleteSession(id)
}
override suspend fun statuses(): Flow<Map<String, SessionStatusDto>> =
sessions.statuses
override suspend fun setDirectory(id: String, directory: String) =
sessions.setDirectory(id, directory)
override suspend fun getDirectory(id: String, fallback: String): String =
sessions.getDirectory(id, fallback)
}
@@ -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<KiloSessionRpcApi>()) {
KiloSessionRpcApiImpl()
}
}
}
@@ -1,4 +1,4 @@
package ai.kilocode.backend
package ai.kilocode.backend.util
import okhttp3.ConnectionPool
import okhttp3.Interceptor
@@ -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<KiloBackendAppService>().workspaces.get(directory)
}
@@ -0,0 +1,299 @@
package ai.kilocode.backend.workspace
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 ai.kilocode.jetbrains.api.model.Agent
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import kotlinx.coroutines.CancellationException
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
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
/**
* Single entry point for all directory-scoped data: project catalog
* (providers, agents, commands, skills) and session access.
*
* **Not an IntelliJ service** — a plain class created by
* [KiloBackendWorkspaceManager] for each directory. Receives a
* pre-connected [DefaultApi] — no null checks needed.
*
* Session operations delegate to [KiloBackendSessionManager] with
* this workspace's [directory], so the frontend only needs one
* object per directory.
*/
class KiloBackendWorkspace(
val directory: String,
private val cs: CoroutineScope,
private val api: DefaultApi,
private val events: SharedFlow<SseEvent>,
private val sessions: KiloBackendSessionManager,
private val log: KiloLog,
) {
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val _state = MutableStateFlow<KiloWorkspaceState>(KiloWorkspaceState.Pending)
val state: StateFlow<KiloWorkspaceState> = _state.asStateFlow()
private var loader: Job? = null
private var eventWatcher: Job? = null
private val loadLock = Any()
/** Load project data (providers, agents, commands, skills). */
fun load() {
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())
var prov: ProviderData? = null
var ag: AgentData? = null
var cmd: List<CommandInfo>? = null
var sk: List<SkillInfo>? = null
val errors = mutableListOf<String>()
try {
coroutineScope {
launch {
val result = fetchWithRetry("providers") { fetchProviders() }
if (result != null) {
prov = result
progress.updateAndGet { it.copy(providers = true) }
.also { _state.value = KiloWorkspaceState.Loading(it) }
} else {
synchronized(errors) { errors.add("providers") }
throw LoadFailure("providers")
}
}
launch {
val result = fetchWithRetry("agents") { fetchAgents() }
if (result != null) {
ag = result
progress.updateAndGet { it.copy(agents = true) }
.also { _state.value = KiloWorkspaceState.Loading(it) }
} else {
synchronized(errors) { errors.add("agents") }
throw LoadFailure("agents")
}
}
launch {
val result = fetchWithRetry("commands") { fetchCommands() }
if (result != null) {
cmd = result
progress.updateAndGet { it.copy(commands = true) }
.also { _state.value = KiloWorkspaceState.Loading(it) }
} else {
synchronized(errors) { errors.add("commands") }
throw LoadFailure("commands")
}
}
launch {
val result = fetchWithRetry("skills") { fetchSkills() }
if (result != null) {
sk = result
progress.updateAndGet { it.copy(skills = true) }
.also { _state.value = KiloWorkspaceState.Loading(it) }
} else {
synchronized(errors) { errors.add("skills") }
throw LoadFailure("skills")
}
}
}
_state.value = KiloWorkspaceState.Ready(
providers = prov!!,
agents = ag!!,
commands = cmd!!,
skills = sk!!,
)
log.info("Workspace data loaded for $directory")
ensureActive()
startWatchingGlobalSseEvents()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
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() {
synchronized(loadLock) {
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 workspace data.
*
* - `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() {
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()
}
}
}
}
}
}
// ------ fetch methods ------
private fun fetchProviders(): ProviderData? =
try {
val response = api.providerList(directory = directory)
ProviderData(
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,
)
} catch (e: Exception) {
log.warn("Providers fetch failed: ${e.message}", e)
null
}
private fun fetchAgents(): AgentData? =
try {
val response = api.appAgents(directory = directory)
val mapped = response.map(::mapAgent)
val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true }
AgentData(
agents = visible.map(::mapAgent),
all = mapped,
default = visible.firstOrNull()?.name ?: "code",
)
} catch (e: Exception) {
log.warn("Agents fetch failed: ${e.message}", e)
null
}
private fun fetchCommands(): List<CommandInfo>? =
try {
api.commandList(directory = directory).map { c ->
CommandInfo(
name = c.name,
description = c.description,
source = c.source?.value,
hints = c.hints,
)
}
} catch (e: Exception) {
log.warn("Commands fetch failed: ${e.message}", e)
null
}
private fun fetchSkills(): List<SkillInfo>? =
try {
api.appSkills(directory = directory).map { s ->
SkillInfo(
name = s.name,
description = s.description,
location = s.location,
)
}
} 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 <T> 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")
}
@@ -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<String, KiloBackendWorkspace>()
private var api: DefaultApi? = null
private var events: SharedFlow<SseEvent>? = 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<SseEvent>) {
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.computeIfAbsent(dir) { d ->
log.info("Creating workspace for $d")
KiloBackendWorkspace(d, 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()
}
}
@@ -0,0 +1,86 @@
package ai.kilocode.backend.workspace
/**
* 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
* workspace data can be fetched.
*/
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<CommandInfo>,
val skills: List<SkillInfo>,
) : KiloWorkspaceState()
data class Error(val message: String) : KiloWorkspaceState()
}
/**
* Tracks which workspace data fetches have completed during
* the [KiloWorkspaceState.Loading] phase.
*/
data class KiloWorkspaceLoadProgress(
val providers: Boolean = false,
val agents: Boolean = false,
val commands: Boolean = false,
val skills: Boolean = false,
)
data class ProviderData(
val providers: List<ProviderInfo>,
val connected: List<String>,
val defaults: Map<String, String>,
)
data class ProviderInfo(
val id: String,
val name: String,
val source: String?,
val models: Map<String, ModelInfo>,
)
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<AgentInfo>,
val all: List<AgentInfo>,
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<String>,
)
data class SkillInfo(
val name: String,
val description: String,
val location: String,
)
@@ -7,5 +7,7 @@
<extensions defaultExtensionNs="com.intellij">
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloAppRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloProjectRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloSessionRpcApiProvider"/>
</extensions>
</idea-plugin>
@@ -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<KiloAppState.Ready>(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<KiloAppState.Ready>(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<KiloAppState.Ready>(svc.appState.value)
}
}
@@ -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
@@ -0,0 +1,337 @@
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.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.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<IllegalStateException> {
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<IllegalStateException> {
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())
}
// ------ 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<IllegalStateException> { 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
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)
}
}
@@ -0,0 +1,506 @@
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.async
import kotlinx.coroutines.awaitAll
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<IllegalStateException> {
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<KiloWorkspaceState.Pending>(ws.state.value)
// Manager should throw since app is disconnected
assertFailsWith<IllegalStateException> {
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<KiloWorkspaceState.Ready>(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<KiloWorkspaceState.Ready>(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)
}
// ------ 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": [{
"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()
}
}
@@ -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<ConnectionState.Connected>(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 }
}
}
}
@@ -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<ProviderList200Response>(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<ProviderList200Response>(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<ProviderList200Response>(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<ProviderList200Response>(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<Agent>(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<Agent>(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<Agent>(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<List<Agent>>(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<Command>(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<Command>(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<List<Command>>(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<AppSkills200ResponseInner>(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<List<AppSkills200ResponseInner>>(src)
assertEquals(2, list.size)
}
@Test
fun `empty skill list`() {
val list = json.decodeFromString<List<AppSkills200ResponseInner>>("[]")
assertTrue(list.isEmpty())
}
}
@@ -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<Session>(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<Session>(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<Session>(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<List<Session>>(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<List<Session>>("[]")
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<Session>(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<SessionStatus>(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<SessionStatus>(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<SessionStatus>(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<SessionStatus>(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<Map<String, SessionStatus>>(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<Map<String, SessionStatus>>("{}")
assertTrue(map.isEmpty())
}
}
@@ -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.
@@ -34,6 +36,38 @@ 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
// 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
/** 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<String, AtomicInteger>()
/** 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 }
}
@@ -128,6 +162,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
@@ -137,6 +172,14 @@ 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)
@@ -150,6 +193,17 @@ class MockCliServer : AutoCloseable {
}
}
path == "/global/event" -> handleSse(output)
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) {
@@ -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
@@ -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",
+14
View File
@@ -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 {
+30
View File
@@ -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
@@ -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<KiloAppStateDto> = flow {
durable {
KiloAppRpcApi.getInstance()
.state()
.collect { emit(it) }
}
}.stateIn(cs, SharingStarted.Eagerly, init)
private val _state = MutableStateFlow(init)
val state: StateFlow<KiloAppStateDto> = _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. */
@@ -0,0 +1,57 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.client
import ai.kilocode.rpc.KiloProjectRpcApi
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
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 = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)
}
private val directory: String get() = project.basePath ?: ""
val state: StateFlow<KiloWorkspaceStateDto> = 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)
}
}
}
}
@@ -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<List<SessionDto>>(emptyList())
val sessions: StateFlow<List<SessionDto>> = _sessions.asStateFlow()
private val _active = MutableStateFlow<SessionDto?>(null)
val active: StateFlow<SessionDto?> = _active.asStateFlow()
/** Live session status map from SSE events. */
val statuses: StateFlow<Map<String, SessionStatusDto>> = 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)
}
}
}
}
@@ -1,160 +1,44 @@
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.diagnostic.Logger
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
import kotlinx.coroutines.SupervisorJob
/**
* 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<KiloAppService>()
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 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() })
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<String>()
lines.add(item("Config", p.config))
lines.add(item("Notifications", p.notifications))
lines.add(profile(p.profile))
return "<html>${lines.joinToString("<br>")}</html>"
}
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<KiloAppService>()
val ver = svc.version
val lines = mutableListOf<String>()
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 "<html>${lines.joinToString("<br>")}</html>"
}
private fun errors(state: KiloAppStateDto): String {
if (state.errors.isEmpty()) return ""
val lines = state.errors.map(::formatError)
return "<html>Failed to load:<br>${lines.joinToString("<br>")}</html>"
}
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"
private val LOG = Logger.getInstance(KiloToolWindowFactory::class.java)
}
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
try {
val app = service<KiloAppService>()
val workspace = project.service<KiloProjectService>()
val scope = CoroutineScope(SupervisorJob())
val ui = KiloWelcomeUi(app, workspace, scope)
val content = ContentFactory.getInstance().createContent(ui, "", false)
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
toolWindow.setTitleActions(listOf(it))
}
} catch (e: Exception) {
LOG.error("Failed to create Kilo tool window content", e)
}
}
}
@@ -0,0 +1,338 @@
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.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
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.ui.AnimatedIcon
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.cancel
import kotlinx.coroutines.launch
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 showing app + workspace initialization progress.
*
* 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,
private val workspace: KiloProjectService,
private val cs: CoroutineScope,
) : JPanel(GridBagLayout()), Disposable {
// ------ 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
font = JBUI.Fonts.label(13f)
foreground = UIUtil.getLabelForeground()
}
// ------ app rows ------
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
init {
isOpaque = false
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(12, 16)
add(logo)
add(Box.createVerticalStrut(JBUI.scale(12)))
add(status)
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 ->
edt { renderApp(state) }
}
wsJob = cs.launch {
workspace.state.collect { state ->
edt { renderWorkspace(state) }
}
}
app.connect()
}
override fun dispose() {
appJob?.cancel()
wsJob?.cancel()
cs.cancel()
}
// ------ rendering ------
private fun renderApp(state: KiloAppStateDto) {
status.text = title(state)
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) {
val appReady = app.state.value.status == KiloAppStatusDto.READY
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")
}
}
}
// ------ 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 -> {
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 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 resetAll() {
configRow.idle("Config")
notifRow.idle("Notifications")
profileRow.idle("Profile")
providersRow.idle("Providers")
agentsRow.idle("Agents")
commandsRow.idle("Commands")
skillsRow.idle("Skills")
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
// ------ 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()
}
}
}
@@ -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
@@ -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" }
@@ -0,0 +1,30 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import kotlinx.coroutines.flow.Flow
/**
* Workspace-level RPC API exposed from backend to frontend.
*
* 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<Unit> {
companion object {
suspend fun getInstance(): KiloProjectRpcApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloProjectRpcApi>())
}
}
/** Observe workspace state loading progress. */
suspend fun state(directory: String): Flow<KiloWorkspaceStateDto>
/** Trigger a full reload of workspace data. */
suspend fun reload(directory: String)
}
@@ -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<Unit> {
companion object {
suspend fun getInstance(): KiloSessionRpcApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloSessionRpcApi>())
}
}
/** 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<Map<String, SessionStatusDto>>
/** 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
}
@@ -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<AgentDto>,
val all: List<AgentDto>,
val default: String,
)
@@ -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<String> = emptyList(),
)
@@ -0,0 +1,30 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
enum class KiloWorkspaceStatusDto {
PENDING,
LOADING,
READY,
ERROR,
}
@Serializable
data class KiloWorkspaceLoadProgressDto(
val providers: Boolean = false,
val agents: Boolean = false,
val commands: Boolean = false,
val skills: Boolean = false,
)
@Serializable
data class KiloWorkspaceStateDto(
val status: KiloWorkspaceStatusDto,
val progress: KiloWorkspaceLoadProgressDto? = null,
val providers: ProvidersDto? = null,
val agents: AgentsDto? = null,
val commands: List<CommandDto> = emptyList(),
val skills: List<SkillDto> = emptyList(),
val error: String? = null,
)
@@ -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<String, ModelDto> = emptyMap(),
)
@Serializable
data class ProvidersDto(
val providers: List<ProviderDto>,
val connected: List<String>,
val defaults: Map<String, String>,
)
@@ -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<SessionDto>,
val statuses: Map<String, SessionStatusDto>,
)
@@ -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,
)