mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
-22
@@ -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 ?: ""
|
||||
}
|
||||
+154
-89
@@ -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()
|
||||
|
||||
+49
-1
@@ -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)
|
||||
|
||||
+6
-1
@@ -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")
|
||||
|
||||
+188
@@ -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 },
|
||||
)
|
||||
}
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+156
@@ -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,
|
||||
)
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
+15
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.backend
|
||||
package ai.kilocode.backend.util
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Interceptor
|
||||
+27
@@ -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)
|
||||
}
|
||||
+299
@@ -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")
|
||||
}
|
||||
+73
@@ -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()
|
||||
}
|
||||
}
|
||||
+86
@@ -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>
|
||||
|
||||
+80
@@ -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
@@ -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
|
||||
|
||||
+337
@@ -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)
|
||||
}
|
||||
}
|
||||
+506
@@ -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()
|
||||
}
|
||||
}
|
||||
+45
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+250
@@ -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())
|
||||
}
|
||||
}
|
||||
+165
@@ -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())
|
||||
}
|
||||
}
|
||||
+54
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
+11
-10
@@ -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. */
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-146
@@ -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(),
|
||||
)
|
||||
+30
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user