feat(jetbrains): add IntelliJ-independent backend test suite

Extract CliServer interface and KiloLog abstraction to decouple backend
classes from IntelliJ platform APIs, enabling tests that run as plain
JVM/JUnit without the IDE test framework. Includes a raw-socket mock
HTTP+SSE server, 41 tests covering connection lifecycle, data loading,
retry logic, serialization roundtrips, and HTTP client configuration.
This commit is contained in:
kirillk
2026-04-13 09:10:20 -04:00
parent 6496c74fa6
commit ac87871442
16 changed files with 1329 additions and 274 deletions
@@ -124,4 +124,12 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,20 @@
package ai.kilocode.backend
/**
* Abstraction over the CLI process lifecycle.
*
* Production: [KiloBackendCliManager]. Tests: fake returning mock server port.
*/
interface CliServer {
sealed class State {
data class Ready(val port: Int, val password: String) : State()
data class Error(val message: String, val details: String? = null) : State()
}
var forceExtract: Boolean
fun process(): Process?
suspend fun init(): State
fun exited(proc: Process)
fun stop()
fun dispose()
}
@@ -9,7 +9,6 @@ import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -41,19 +40,35 @@ import java.util.concurrent.atomic.AtomicReference
* Profile is optional — 401 (not logged in) is not an error.
*/
@Service(Service.Level.APP)
class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
class KiloBackendAppService private constructor(
private val cs: CoroutineScope,
private val server: CliServer,
private val log: KiloLog,
) : Disposable {
/** IntelliJ service injection entry point. */
constructor(cs: CoroutineScope) : this(
cs,
KiloBackendCliManager(),
IntellijLog(KiloBackendAppService::class.java),
)
companion object {
private val LOG = Logger.getInstance(KiloBackendAppService::class.java)
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
/** Test factory — no IntelliJ deps needed. */
internal fun create(
cs: CoroutineScope,
server: CliServer,
log: KiloLog,
) = KiloBackendAppService(cs, server, log)
}
private val mutex = Mutex()
private val server = KiloBackendCliManager()
private val connection = KiloConnectionService(cs, server) {
private val connection = KiloConnectionService(cs, server, onReconnect = {
cs.launch { reconnect() }
}
}, log = log)
private var router: Job? = null
private var loader: Job? = null
@@ -106,10 +121,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
mutex.withLock {
val current = _appState.value
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) {
LOG.info("reconnect: already ${current::class.simpleName} — skipping")
log.info("reconnect: already ${current::class.simpleName} — skipping")
return
}
LOG.info("reconnect: full restart under mutex")
log.info("reconnect: full restart under mutex")
connection.restart()
}
}
@@ -140,7 +155,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
private fun load() {
loader?.cancel()
loader = cs.launch {
LOG.info("Loading global data")
log.info("Loading global data")
val progress = AtomicReference(LoadProgress())
_appState.value = KiloAppState.Loading(progress.get())
@@ -186,12 +201,12 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
notifications = notifications,
)
)
LOG.info("Global data loaded — app is Ready")
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}")
log.warn("Global data load failed: ${e.message}")
_appState.value = KiloAppState.Error(
message = "Failed to load required data",
errors = synchronized(errors) { errors.toList() },
@@ -205,10 +220,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
return try {
val response = client.kiloProfile()
profile = response
LOG.info("Profile: ${response.profile.email}")
log.info("Profile: ${response.profile.email}")
ProfileResult.LOADED
} catch (e: Exception) {
LOG.info("Profile fetch skipped: ${e.message}")
log.info("Profile fetch skipped: ${e.message}")
ProfileResult.NOT_LOGGED_IN
}
}
@@ -219,7 +234,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
return try {
FetchResult.ok(client.globalConfigGet())
} catch (e: Exception) {
LOG.warn("Global config fetch failed: ${e.message}", e)
log.warn("Global config fetch failed: ${e.message}", e)
logResponseBody("config", e)
FetchResult.fail("config", e)
}
@@ -231,7 +246,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
return try {
FetchResult.ok(client.kiloNotifications())
} catch (e: Exception) {
LOG.warn("Notifications fetch failed: ${e.message}", e)
log.warn("Notifications fetch failed: ${e.message}", e)
logResponseBody("notifications", e)
FetchResult.fail("notifications", e)
}
@@ -250,7 +265,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
else -> null
}
if (body != null) {
LOG.warn("$resource response body: $body")
log.warn("$resource response body: $body")
}
}
@@ -263,11 +278,11 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
last = block()
if (last.value != null) return last
if (attempt < MAX_RETRIES - 1) {
LOG.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
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")
log.error("$name: all $MAX_RETRIES attempts failed")
return last
}
@@ -1,7 +1,6 @@
package ai.kilocode.backend
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.system.CpuArch
import kotlinx.coroutines.Dispatchers
@@ -17,271 +16,266 @@ import java.util.concurrent.TimeUnit
* Manages the Kilo CLI binary lifecycle.
*
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
* spawns `kilo serve --port 0`, and exposes the result as [ServerState].
* spawns `kilo serve --port 0`, and exposes the result as [CliServer.State].
*
* Concurrency is handled by the owning [KiloBackendAppService] — all public
* methods are called under its mutex so no internal synchronization is needed.
*/
class KiloBackendCliManager {
class KiloBackendCliManager(
private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java),
) : CliServer {
sealed class ServerState {
data class Ready(val port: Int, val password: String) : ServerState()
data class Error(val message: String, val details: String? = null) :
ServerState()
}
companion object {
private const val STARTUP_TIMEOUT_MS = 30_000L
private const val KILL_TIMEOUT_SECONDS = 5L
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
}
companion object {
private val LOG = Logger.getInstance(KiloBackendCliManager::class.java)
private const val STARTUP_TIMEOUT_MS = 30_000L
private const val KILL_TIMEOUT_SECONDS = 5L
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
}
private var process: Process? = null
private var hook: Thread? = null
private var process: Process? = null
private var hook: Thread? = null
/**
* When true, the next [extractCli] call deletes and re-extracts the binary
* regardless of the size check. Reset to false after extraction.
*/
@Volatile
override var forceExtract = false
/**
* When true, the next [extractCli] call deletes and re-extracts the binary
* regardless of the size check. Reset to false after extraction.
*/
@Volatile
var forceExtract = false
override fun process(): Process? = process
fun process(): Process? = process
/**
* Extract the CLI binary (if needed) and spawn `kilo serve`.
*
* Must be called under [KiloBackendAppService]'s mutex — no internal
* synchronization is performed.
*/
override suspend fun init(): CliServer.State {
return try {
val path = extractCli()
log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
withTimeout(STARTUP_TIMEOUT_MS) {
spawn(path)
}
} catch (e: Exception) {
log.warn("CLI startup failed", e)
// If spawn started a process but timed out (or failed after start),
// kill the orphaned process so it doesn't leak.
process?.let { proc ->
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
process = null
uninstall()
kill(proc, "startup failure cleanup")
}
CliServer.State.Error(
message = e.message ?: "Unknown error",
details = e.stackTraceToString(),
)
}
}
/**
* Extract the CLI binary (if needed) and spawn `kilo serve`.
*
* Must be called under [KiloBackendAppService]'s mutex — no internal
* synchronization is performed.
*/
suspend fun init(): ServerState {
return try {
val path = extractCli()
LOG.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
withTimeout(STARTUP_TIMEOUT_MS) {
spawn(path)
}
} catch (e: Exception) {
LOG.warn("CLI startup failed", e)
// If spawn started a process but timed out (or failed after start),
// kill the orphaned process so it doesn't leak.
process?.let { proc ->
LOG.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
/**
* Mark the given process as exited and clear state.
* Called from the process monitor when the CLI process dies.
*/
override fun exited(proc: Process) {
if (process != proc) return
process = null
uninstall()
kill(proc, "startup failure cleanup")
}
ServerState.Error(
message = e.message ?: "Unknown error",
details = e.stackTraceToString(),
)
}
}
/**
* Mark the given process as exited and clear state.
* Called from the process monitor when the CLI process dies.
*/
fun exited(proc: Process) {
if (process != proc) return
process = null
uninstall()
}
/**
* Kill the running CLI process and reset state so the next [init] spawns fresh.
*/
fun stop() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "stop()")
}
private fun extractCli(): File {
val platform = platform()
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
val resource = "cli/$platform/$exe"
val loader = javaClass.classLoader
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
if (forceExtract && target.exists()) {
LOG.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
target.delete()
forceExtract = false
}
val url = loader.getResource(resource)
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
val size = url.openConnection().contentLengthLong
if (size >= 0 && target.exists() && target.length() == size) {
LOG.info("CLI binary up-to-date at ${target.absolutePath}")
return target
/**
* Kill the running CLI process and reset state so the next [init] spawns fresh.
*/
override fun stop() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "stop()")
}
LOG.info("Extracting CLI binary to ${target.absolutePath}")
target.parentFile.mkdirs()
private fun extractCli(): File {
val platform = platform()
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
val resource = "cli/$platform/$exe"
val loader = javaClass.classLoader
url.openStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
if (!SystemInfo.isWindows) {
target.setExecutable(true)
}
return target
}
private suspend fun spawn(cli: File): ServerState =
withContext(Dispatchers.IO) {
val pwd = generatePassword()
val env = buildMap {
putAll(System.getenv())
put("KILO_SERVER_PASSWORD", pwd)
put("KILO_CLIENT", "jetbrains")
put("KILO_ENABLE_QUESTION_TOOL", "true")
put("KILO_PLATFORM", "jetbrains")
put("KILO_APP_NAME", "kilo-code")
}
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
val builder = ProcessBuilder(cmd)
builder.environment().clear()
builder.environment().putAll(env)
builder.redirectErrorStream(false)
LOG.info("Starting CLI: ${cmd.joinToString(" ")}")
LOG.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
val proc = builder.start()
LOG.info("CLI process started (pid=${proc.pid()})")
process = proc
install(proc)
val stderr = StringBuilder()
Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
LOG.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
if (forceExtract && target.exists()) {
log.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
target.delete()
forceExtract = false
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
LOG.info("CLI stdout: $line")
val match = PORT_REGEX.find(line)
if (match != null) {
val p = match.groupValues[1].toInt()
LOG.info("CLI server ready on port $p")
return@withContext ServerState.Ready(port = p, password = pwd)
}
val url = loader.getResource(resource)
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
if (!proc.isAlive) break
val size = url.openConnection().contentLengthLong
if (size >= 0 && target.exists() && target.length() == size) {
log.info("CLI binary up-to-date at ${target.absolutePath}")
return target
}
}
val code = proc.waitFor()
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
ServerState.Error(
message = "CLI process exited with code $code before announcing a port",
details = details.ifEmpty { null },
)
log.info("Extracting CLI binary to ${target.absolutePath}")
target.parentFile.mkdirs()
url.openStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!SystemInfo.isWindows) {
target.setExecutable(true)
}
return target
}
fun dispose() {
val proc = process ?: return
process = null
uninstall()
private suspend fun spawn(cli: File): CliServer.State =
withContext(Dispatchers.IO) {
val pwd = generatePassword()
kill(proc, "Disposing")
}
val env = buildMap {
putAll(System.getenv())
put("KILO_SERVER_PASSWORD", pwd)
put("KILO_CLIENT", "jetbrains")
put("KILO_ENABLE_QUESTION_TOOL", "true")
put("KILO_PLATFORM", "jetbrains")
put("KILO_APP_NAME", "kilo-code")
}
private fun install(proc: Process) {
uninstall()
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
val builder = ProcessBuilder(cmd)
builder.environment().clear()
builder.environment().putAll(env)
builder.redirectErrorStream(false)
val next = Thread({
LOG.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
kill(proc, "Shutdown hook", wait = false)
}, "kilo-cli-shutdown")
log.info("Starting CLI: ${cmd.joinToString(" ")}")
log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
val proc = builder.start()
log.info("CLI process started (pid=${proc.pid()})")
process = proc
install(proc)
val ok = runCatching {
Runtime.getRuntime().addShutdownHook(next)
val stderr = StringBuilder()
Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
log.info("CLI stdout: $line")
val match = PORT_REGEX.find(line)
if (match != null) {
val p = match.groupValues[1].toInt()
log.info("CLI server ready on port $p")
return@withContext CliServer.State.Ready(port = p, password = pwd)
}
if (!proc.isAlive) break
}
}
val code = proc.waitFor()
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
CliServer.State.Error(
message = "CLI process exited with code $code before announcing a port",
details = details.ifEmpty { null },
)
}
override fun dispose() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "Disposing")
}
if (ok.isFailure) {
LOG.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
return
private fun install(proc: Process) {
uninstall()
val next = Thread({
log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
kill(proc, "Shutdown hook", wait = false)
}, "kilo-cli-shutdown")
val ok = runCatching {
Runtime.getRuntime().addShutdownHook(next)
}
if (ok.isFailure) {
log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
return
}
hook = next
}
hook = next
}
private fun uninstall() {
val curr = hook ?: return
hook = null
private fun uninstall() {
val curr = hook ?: return
hook = null
val ok = runCatching {
Runtime.getRuntime().removeShutdownHook(curr)
}
val ok = runCatching {
Runtime.getRuntime().removeShutdownHook(curr)
if (ok.isFailure) {
log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
}
}
if (ok.isFailure) {
LOG.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
private fun kill(proc: Process, source: String, wait: Boolean = true) {
log.info("$source — killing CLI process tree (pid ${proc.pid()})")
children(proc).forEach { it.destroy() }
proc.destroy()
if (!wait) return
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
log.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
children(proc).forEach { it.destroyForcibly() }
proc.destroyForcibly()
}
}
}
private fun kill(proc: Process, source: String, wait: Boolean = true) {
LOG.info("$source — killing CLI process tree (pid ${proc.pid()})")
children(proc).forEach { it.destroy() }
proc.destroy()
if (!wait) return
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
LOG.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
children(proc).forEach { it.destroyForcibly() }
proc.destroyForcibly()
private fun children(proc: Process): List<ProcessHandle> {
return proc.toHandle().descendants().toList().asReversed()
}
}
private fun children(proc: Process): List<ProcessHandle> {
return proc.toHandle().descendants().toList().asReversed()
}
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
SystemInfo.isLinux -> "linux"
SystemInfo.isWindows -> "windows"
else -> throw IllegalStateException(
"Unsupported OS: ${
System.getProperty(
"os.name"
)
}"
)
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
SystemInfo.isLinux -> "linux"
SystemInfo.isWindows -> "windows"
else -> throw IllegalStateException(
"Unsupported OS: ${
System.getProperty(
"os.name"
)
}"
)
}
val arch = when (CpuArch.CURRENT) {
CpuArch.ARM64 -> "arm64"
CpuArch.X86_64 -> "x64"
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
}
return "$os-$arch"
}
val arch = when (CpuArch.CURRENT) {
CpuArch.ARM64 -> "arm64"
CpuArch.X86_64 -> "x64"
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
}
return "$os-$arch"
}
private fun generatePassword(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun generatePassword(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,7 +1,6 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.client.DefaultApi
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -52,12 +51,12 @@ data class SseEvent(val type: String, val data: String)
*/
class KiloConnectionService(
private val cs: CoroutineScope,
private val server: KiloBackendCliManager,
private val server: CliServer,
private val onReconnect: () -> Unit,
private val log: KiloLog = IntellijLog(KiloConnectionService::class.java),
) {
companion object {
private val LOG = Logger.getInstance(KiloConnectionService::class.java)
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
private const val RECONNECT_DELAY_MS = 250L
@@ -81,6 +80,7 @@ class KiloConnectionService(
private val source = AtomicReference<EventSource?>(null)
private val lastEvent = AtomicLong(0L)
@Volatile private var disposed = false
private var heartbeatJob: Job? = null
private var healthJob: Job? = null
private var processJob: Job? = null
@@ -101,11 +101,11 @@ class KiloConnectionService(
* Called under [KiloBackendAppService]'s mutex.
*/
suspend fun restart() {
LOG.info("restart: initiated — tearing down current connection")
log.info("restart: initiated — tearing down current connection")
teardown()
LOG.info("restart: teardown complete — spawning new CLI process")
log.info("restart: teardown complete — spawning new CLI process")
open()
LOG.info("restart: open() returned — CLI process started")
log.info("restart: open() returned — CLI process started")
}
/**
@@ -114,13 +114,13 @@ class KiloConnectionService(
* Called under [KiloBackendAppService]'s mutex.
*/
suspend fun reinstall() {
LOG.info("reinstall: initiated — tearing down current connection")
log.info("reinstall: initiated — tearing down current connection")
teardown()
LOG.info("reinstall: teardown complete — setting forceExtract flag")
log.info("reinstall: teardown complete — setting forceExtract flag")
server.forceExtract = true
LOG.info("reinstall: spawning new CLI process (binary will be re-extracted)")
log.info("reinstall: spawning new CLI process (binary will be re-extracted)")
open()
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
log.info("reinstall: open() returned — CLI process started with fresh binary")
}
/**
@@ -130,19 +130,19 @@ class KiloConnectionService(
* cannot race with the SSE close or process kill.
*/
private fun teardown() {
LOG.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
log.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
reconnectJob?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
LOG.info("teardown: closing SSE event source")
log.info("teardown: closing SSE event source")
source.getAndSet(null)?.cancel()
LOG.info("teardown: shutting down OkHttp clients")
log.info("teardown: shutting down OkHttp clients")
close()
setState(ConnectionState.Disconnected)
LOG.info("teardown: killing CLI process via ServerManager.stop()")
log.info("teardown: killing CLI process via ServerManager.stop()")
server.stop()
LOG.info("teardown: complete")
log.info("teardown: complete")
}
private suspend fun open() {
@@ -155,12 +155,12 @@ class KiloConnectionService(
val result = server.init()
if (result is KiloBackendCliManager.ServerState.Error) {
if (result is CliServer.State.Error) {
setState(ConnectionState.Error(result.message))
return
}
val ready = result as KiloBackendCliManager.ServerState.Ready
val ready = result as CliServer.State.Ready
port = ready.port
password = ready.password
@@ -195,12 +195,12 @@ class KiloConnectionService(
.build()
)
source.set(factory.newEventSource(request, listener))
LOG.info("SSE: connecting to port $port")
log.info("SSE: connecting to port $port")
}
private val listener = object : EventSourceListener() {
override fun onOpen(src: EventSource, response: Response) {
LOG.info("SSE: connected")
log.info("SSE: connected")
setState(ConnectionState.Connected(port, password))
lastEvent.set(System.currentTimeMillis())
}
@@ -212,15 +212,15 @@ class KiloConnectionService(
}
override fun onClosed(src: EventSource) {
LOG.info("SSE: stream closed — scheduling reconnect")
log.info("SSE: stream closed — scheduling reconnect")
scheduleReconnect()
}
override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
if (t != null) {
LOG.warn("SSE: failure (${t.message}) — scheduling reconnect")
log.warn("SSE: failure (${t.message}) — scheduling reconnect")
} else {
LOG.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
}
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
scheduleReconnect()
@@ -233,6 +233,7 @@ class KiloConnectionService(
* goes through [KiloBackendAppService]'s mutex for a full restart.
*/
private fun scheduleReconnect() {
if (disposed) return
if (reconnectJob?.isActive == true) return
reconnectJob = cs.launch {
delay(RECONNECT_DELAY_MS)
@@ -241,14 +242,14 @@ class KiloConnectionService(
val proc = server.process()
if (proc?.isAlive == true) {
LOG.info("SSE: reconnecting (process alive)")
log.info("SSE: reconnecting (process alive)")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Connecting)
startSse()
return@launch
}
LOG.warn("CLI process not running — delegating full reconnect to AppService")
log.warn("CLI process not running — delegating full reconnect to AppService")
onReconnect()
}
}
@@ -261,7 +262,7 @@ class KiloConnectionService(
if (_state.value !is ConnectionState.Connected) continue
val elapsed = System.currentTimeMillis() - lastEvent.get()
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
LOG.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
log.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
@@ -275,7 +276,7 @@ class KiloConnectionService(
if (_state.value !is ConnectionState.Connected) continue
val ok = checkHealth()
if (!ok && _state.value is ConnectionState.Connected) {
LOG.warn("Health check failed — forcing SSE reconnect")
log.warn("Health check failed — forcing SSE reconnect")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
@@ -290,7 +291,7 @@ class KiloConnectionService(
.build()
http.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
LOG.info("Health check exception: ${e.message}")
log.info("Health check exception: ${e.message}")
false
}
}
@@ -299,7 +300,7 @@ class KiloConnectionService(
proc.waitFor()
server.exited(proc)
val code = proc.exitValue()
LOG.warn("CLI process exited with code $code")
log.warn("CLI process exited with code $code")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Error("CLI process exited with code $code"))
scheduleReconnect()
@@ -314,20 +315,22 @@ class KiloConnectionService(
}
private fun setState(next: ConnectionState) {
if (disposed) return
_state.value = next
}
private fun extractType(data: String): String =
internal fun extractType(data: String): String =
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
fun dispose() {
disposed = true
source.getAndSet(null)?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
reconnectJob?.cancel()
close()
setState(ConnectionState.Disconnected)
LOG.info("KiloConnectionService disposed")
_state.value = ConnectionState.Disconnected
log.info("KiloConnectionService disposed")
}
}
@@ -0,0 +1,20 @@
package ai.kilocode.backend
import com.intellij.openapi.diagnostic.Logger
interface KiloLog {
fun info(msg: String)
fun warn(msg: String, t: Throwable? = null)
fun error(msg: String, t: Throwable? = null)
}
internal class IntellijLog(cls: Class<*>) : KiloLog {
private val delegate = Logger.getInstance(cls)
override fun info(msg: String) = delegate.info(msg)
override fun warn(msg: String, t: Throwable?) {
if (t != null) delegate.warn(msg, t) else delegate.warn(msg)
}
override fun error(msg: String, t: Throwable?) {
if (t != null) delegate.error(msg, t) else delegate.error(msg)
}
}
@@ -0,0 +1,164 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.infrastructure.Serializer
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.GlobalHealth200Response
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInnerAction
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseBalance
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfile
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfileOrganizationsInner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Verifies that the generated API model classes serialize/deserialize
* correctly using the same [Serializer.kotlinxSerializationJson] instance
* that the production [DefaultApi] client uses.
*/
class ApiModelSerializationTest {
private val json = Serializer.kotlinxSerializationJson
@Test
fun `GlobalHealth200Response roundtrip`() {
val src = """{"healthy":true,"version":"2.1.0"}"""
val obj = json.decodeFromString<GlobalHealth200Response>(src)
assertTrue(obj.healthy)
assertEquals("2.1.0", obj.version)
val back = json.encodeToString(GlobalHealth200Response.serializer(), obj)
assertTrue(back.contains(""""healthy":true"""))
assertTrue(back.contains(""""version":"2.1.0""""))
}
@Test
fun `Config deserializes minimal JSON`() {
val obj = json.decodeFromString<Config>("""{}""")
assertNull(obj.model)
assertNull(obj.provider)
assertNull(obj.mcp)
}
@Test
fun `Config deserializes with known fields`() {
val src = """{"model":"claude-4","username":"alice"}"""
val obj = json.decodeFromString<Config>(src)
assertEquals("claude-4", obj.model)
assertEquals("alice", obj.username)
}
@Test
fun `Config ignores unknown fields`() {
val src = """{"model":"test","totally_new_field":"value","nested":{"a":1}}"""
val obj = json.decodeFromString<Config>(src)
assertEquals("test", obj.model)
}
@Test
fun `KiloNotifications200ResponseInner with action`() {
val src = """{
"id": "notif-1",
"title": "Update available",
"message": "Version 3.0 is out",
"action": {"actionText": "Update now", "actionURL": "https://example.com/update"}
}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals("notif-1", obj.id)
assertEquals("Update available", obj.title)
assertEquals("Version 3.0 is out", obj.message)
assertNotNull(obj.action)
assertEquals("Update now", obj.action!!.actionText)
assertEquals("https://example.com/update", obj.action!!.actionURL)
}
@Test
fun `KiloNotifications200ResponseInner without action`() {
val src = """{"id":"n2","title":"Info","message":"Hello"}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals("n2", obj.id)
assertNull(obj.action)
assertNull(obj.showIn)
}
@Test
fun `KiloNotifications200ResponseInner with showIn and suggestModelId`() {
val src = """{
"id": "n3",
"title": "Try new model",
"message": "Check it out",
"showIn": ["cli", "vscode"],
"suggestModelId": "claude-4"
}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals(listOf("cli", "vscode"), obj.showIn)
assertEquals("claude-4", obj.suggestModelId)
}
@Test
fun `empty notifications array`() {
val list = json.decodeFromString<List<KiloNotifications200ResponseInner>>("[]")
assertTrue(list.isEmpty())
}
@Test
fun `KiloProfile200Response with balance`() {
val src = """{
"profile": {"email": "user@test.com", "name": "User"},
"balance": {"balance": 42.5},
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertEquals("user@test.com", obj.profile.email)
assertEquals("User", obj.profile.name)
assertNotNull(obj.balance)
assertEquals(42.5, obj.balance!!.balance)
assertEquals("org-1", obj.currentOrgId)
}
@Test
fun `KiloProfile200Response with null balance`() {
val src = """{
"profile": {"email": "user@test.com"},
"balance": null,
"currentOrgId": null
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertEquals("user@test.com", obj.profile.email)
assertNull(obj.balance)
assertNull(obj.currentOrgId)
}
@Test
fun `KiloProfile200Response with organizations`() {
val src = """{
"profile": {
"email": "user@test.com",
"organizations": [
{"id": "org-1", "name": "Acme", "role": "admin"},
{"id": "org-2", "name": "Beta", "role": "member"}
]
},
"balance": null,
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertNotNull(obj.profile.organizations)
assertEquals(2, obj.profile.organizations!!.size)
assertEquals("Acme", obj.profile.organizations!![0].name)
assertEquals("admin", obj.profile.organizations!![0].role)
}
@Test
fun `Config roundtrip preserves model field`() {
val original = Config(model = "gpt-4o", username = "test")
val encoded = json.encodeToString(Config.serializer(), original)
val decoded = json.decodeFromString<Config>(encoded)
assertEquals("gpt-4o", decoded.model)
assertEquals("test", decoded.username)
}
}
@@ -0,0 +1,86 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.model.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloAppStateTest {
@Test
fun `default LoadProgress has all fields unloaded`() {
val progress = LoadProgress()
assertFalse(progress.config)
assertFalse(progress.notifications)
assertEquals(ProfileResult.PENDING, progress.profile)
}
@Test
fun `LoadProgress copy tracks individual completion`() {
val p1 = LoadProgress()
val p2 = p1.copy(config = true)
assertTrue(p2.config)
assertFalse(p2.notifications)
val p3 = p2.copy(notifications = true, profile = ProfileResult.LOADED)
assertTrue(p3.config)
assertTrue(p3.notifications)
assertEquals(ProfileResult.LOADED, p3.profile)
}
@Test
fun `KiloAppState sealed subtypes are distinct`() {
assertIs<KiloAppState.Disconnected>(KiloAppState.Disconnected)
assertIs<KiloAppState.Connecting>(KiloAppState.Connecting)
assertIs<KiloAppState.Loading>(KiloAppState.Loading(LoadProgress()))
assertIs<KiloAppState.Error>(KiloAppState.Error("fail"))
}
@Test
fun `KiloAppState Error with errors list`() {
val errors = listOf(
LoadError("config", status = 500, detail = "server error"),
LoadError("notifications", detail = "timeout"),
)
val state = KiloAppState.Error("Failed", errors = errors)
assertEquals(2, state.errors.size)
assertEquals("config", state.errors[0].resource)
assertEquals(500, state.errors[0].status)
assertNull(state.errors[1].status)
}
@Test
fun `AppData construction`() {
val cfg = Config()
val data = AppData(profile = null, config = cfg, notifications = emptyList())
assertNull(data.profile)
assertEquals(cfg, data.config)
assertTrue(data.notifications.isEmpty())
}
@Test
fun `LoadError with all fields`() {
val err = LoadError(resource = "config", status = 503, detail = "Service Unavailable")
assertEquals("config", err.resource)
assertEquals(503, err.status)
assertEquals("Service Unavailable", err.detail)
}
@Test
fun `LoadError with minimal fields`() {
val err = LoadError(resource = "notifications")
assertNull(err.status)
assertNull(err.detail)
}
@Test
fun `ProfileResult enum values`() {
assertEquals(3, ProfileResult.entries.size)
assertTrue(ProfileResult.entries.containsAll(
listOf(ProfileResult.PENDING, ProfileResult.LOADED, ProfileResult.NOT_LOGGED_IN)
))
}
}
@@ -0,0 +1,216 @@
package ai.kilocode.backend
import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloBackendAppServiceTest {
private val mock = MockCliServer()
private val log = TestLog()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
scope.cancel()
mock.close()
}
private fun create(): KiloBackendAppService =
KiloBackendAppService.create(scope, FakeCliServer(mock), log)
@Test
fun `full lifecycle reaches Ready`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val ready = svc.appState.value as KiloAppState.Ready
assertNotNull(ready.data.config)
assertNotNull(ready.data.notifications)
}
@Test
fun `config is loaded`() = runBlocking {
mock.config = """{"model":"claude-4","username":"testuser"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertNotNull(svc.config)
assertEquals("claude-4", svc.config!!.model)
}
@Test
fun `profile is loaded when available`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertNotNull(svc.profile)
assertEquals("alice@test.com", svc.profile!!.profile.email)
}
@Test
fun `profile 401 does not prevent Ready`() = runBlocking {
mock.profileStatus = 401
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
// Profile is null but we still reached Ready
assertNull(svc.profile)
assertIs<KiloAppState.Ready>(svc.appState.value)
}
@Test
fun `config failure retries then transitions to Error`() = runBlocking {
mock.configStatus = 500
mock.config = """{"error":"internal"}"""
val svc = create()
svc.connect()
withTimeout(15_000) {
svc.appState.first { it is KiloAppState.Error }
}
val err = svc.appState.value as KiloAppState.Error
assertEquals("Failed to load required data", err.message)
assertTrue(err.errors.any { it.resource == "config" })
}
@Test
fun `notifications failure transitions to Error`() = runBlocking {
mock.notificationsStatus = 500
mock.notifications = """{"error":"internal"}"""
val svc = create()
svc.connect()
withTimeout(15_000) {
svc.appState.first { it is KiloAppState.Error }
}
val err = svc.appState.value as KiloAppState.Error
assertTrue(err.errors.any { it.resource == "notifications" })
}
@Test
fun `connect when already Ready is no-op`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
// Second connect should not change state
svc.connect()
assertIs<KiloAppState.Ready>(svc.appState.value)
}
@Test
fun `health returns HealthDto when connected`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val dto = svc.health()
assertTrue(dto.healthy)
assertEquals("1.0.0", dto.version)
}
@Test
fun `dispose transitions to Disconnected`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
svc.dispose()
assertEquals(KiloAppState.Disconnected, svc.appState.value)
}
@Test
fun `loading tracks progress through Loading state`() = runBlocking {
val svc = create()
val states = mutableListOf<KiloAppState>()
val collector = scope.launch {
svc.appState.collect { states.add(it) }
}
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
collector.cancel()
// Should have passed through Loading at least once
assertTrue(states.any { it is KiloAppState.Loading })
// Should have reached Ready
assertTrue(states.any { it is KiloAppState.Ready })
}
@Test
fun `SSE config updated event refreshes config`() = runBlocking {
mock.config = """{"model":"initial"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertEquals("initial", svc.config?.model)
// Change the config response and push an SSE event
mock.config = """{"model":"updated"}"""
mock.awaitSseConnection()
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
// Wait for config to be refreshed
withTimeout(5_000) {
while (svc.config?.model != "updated") {
delay(100)
}
}
assertEquals("updated", svc.config?.model)
}
}
@@ -0,0 +1,100 @@
package ai.kilocode.backend
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class KiloBackendHttpClientsTest {
@Test
fun `api client sends correct basic auth header`() {
val pwd = "secret123"
val server = MockWebServer()
server.enqueue(MockResponse().setBody("ok"))
server.start()
val client = KiloBackendHttpClients.api(pwd)
try {
val request = okhttp3.Request.Builder()
.url(server.url("/test"))
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
}
val recorded = server.takeRequest()
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
assertEquals(expected, recorded.getHeader("Authorization"))
} finally {
KiloBackendHttpClients.shutdown(client)
server.shutdown()
}
}
@Test
fun `api client has no call or read timeout`() {
val client = KiloBackendHttpClients.api("test")
try {
assertEquals(0, client.callTimeoutMillis)
assertEquals(0, client.readTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `api client has connect timeout`() {
val client = KiloBackendHttpClients.api("test")
try {
assertTrue(client.connectTimeoutMillis > 0)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `health client has short timeout`() {
val client = KiloBackendHttpClients.health("test")
try {
assertEquals(3000, client.callTimeoutMillis)
assertEquals(3000, client.connectTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `health client sends correct basic auth header`() {
val pwd = "healthpwd"
val server = MockWebServer()
server.enqueue(MockResponse().setBody("ok"))
server.start()
val client = KiloBackendHttpClients.health(pwd)
try {
val request = okhttp3.Request.Builder()
.url(server.url("/global/health"))
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
}
val recorded = server.takeRequest()
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
assertEquals(expected, recorded.getHeader("Authorization"))
} finally {
KiloBackendHttpClients.shutdown(client)
server.shutdown()
}
}
@Test
fun `shutdown evicts connection pool`() {
val client = KiloBackendHttpClients.api("test")
KiloBackendHttpClients.shutdown(client)
assertEquals(0, client.connectionPool.connectionCount())
}
}
@@ -0,0 +1,168 @@
package ai.kilocode.backend
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 java.util.concurrent.atomic.AtomicInteger
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class KiloConnectionServiceTest {
private val mock = MockCliServer()
private val fake = FakeCliServer(mock)
private val log = TestLog()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
scope.cancel()
mock.close()
}
@Test
fun `connect transitions to Connected`() = runBlocking {
val reconnects = AtomicInteger(0)
val svc = KiloConnectionService(scope, fake, { reconnects.incrementAndGet() }, log)
svc.connect()
// Wait for SSE to connect, which triggers Connected
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
assertIs<ConnectionState.Connected>(svc.state.value)
}
@Test
fun `connect provides API client`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
assertTrue(svc.api != null)
}
@Test
fun `SSE events are emitted`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
// Use first{} on the flow to capture the event — avoids race with SharedFlow subscription
val deferred = scope.launch {
val event = svc.events.first { it.type == "global.config.updated" }
assertEquals("global.config.updated", event.type)
}
// Small delay to ensure the collector subscription is active
delay(200)
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
withTimeout(5_000) {
deferred.join()
}
}
@Test
fun `SSE close triggers error state`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
// Close SSE stream
mock.closeSse()
// Should transition away from Connected (to Error or Connecting on reconnect)
withTimeout(5_000) {
svc.state.first { it !is ConnectionState.Connected }
}
}
@Test
fun `init error transitions to Error state`() = runBlocking {
val failing = object : CliServer {
override var forceExtract = false
override fun process(): Process? = null
override suspend fun init() = CliServer.State.Error("binary not found")
override fun exited(proc: Process) {}
override fun stop() {}
override fun dispose() {}
}
val svc = KiloConnectionService(scope, failing, {}, log)
svc.connect()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Error }
}
val err = svc.state.value as ConnectionState.Error
assertEquals("binary not found", err.message)
}
@Test
fun `reinstall sets forceExtract on server`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
svc.reinstall()
assertTrue(fake.forceExtract)
}
@Test
fun `extractType parses type from JSON data`() {
val svc = KiloConnectionService(scope, fake, {}, log)
val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""")
assertEquals("global.config.updated", result)
}
@Test
fun `extractType returns unknown for missing type`() {
val svc = KiloConnectionService(scope, fake, {}, log)
assertEquals("unknown", svc.extractType("""{"data":"something"}"""))
}
@Test
fun `dispose transitions to Disconnected`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
svc.dispose()
assertEquals(ConnectionState.Disconnected, svc.state.value)
}
}
@@ -0,0 +1,33 @@
package ai.kilocode.backend.testing
import ai.kilocode.backend.CliServer
/**
* Fake [CliServer] that delegates to a [MockCliServer] instead of
* spawning a real CLI process. Returns the mock's port and password
* from [init], and has no real process to monitor.
*
* [stop] shuts down the current server socket (restartable).
* [dispose] does final cleanup (not restartable).
*/
class FakeCliServer(private val mock: MockCliServer) : CliServer {
override var forceExtract = false
override fun process(): Process? = null
override suspend fun init(): CliServer.State =
CliServer.State.Ready(mock.start(), mock.password)
override fun exited(proc: Process) {}
/** Shutdown the server socket but keep the mock alive for restart. */
override fun stop() {
mock.shutdown()
}
/** Final cleanup. */
override fun dispose() {
mock.close()
}
}
@@ -0,0 +1,192 @@
package ai.kilocode.backend.testing
import java.io.BufferedWriter
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Lightweight mock HTTP server simulating the Kilo CLI server.
*
* Handles REST endpoints with configurable JSON responses and provides
* full control over the SSE `/global/event` stream. Uses raw sockets
* so SSE connections can be held open and events pushed on demand.
*
* Supports restart: [start] can be called after [shutdown] to bind a new port.
* Call [close] for final cleanup (shuts down the thread pool).
*/
class MockCliServer : AutoCloseable {
val password = "test-password"
// Configurable REST responses — can be changed between requests
@Volatile var health = """{"healthy":true,"version":"1.0.0"}"""
@Volatile var config = """{"model":"test/model"}"""
@Volatile var notifications = "[]"
@Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}"""
@Volatile var profileStatus = 200
@Volatile var configStatus = 200
@Volatile var notificationsStatus = 200
private val executor = Executors.newCachedThreadPool { r ->
Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true }
}
private val closed = AtomicBoolean(false)
private var server: ServerSocket? = null
private val connections = ConcurrentLinkedQueue<Socket>()
private var port = 0
// SSE stream control — reset on each start()
@Volatile private var sseWriter: BufferedWriter? = null
private var sseLatch = CountDownLatch(1)
private var sseConnected = CountDownLatch(1)
/** Start (or restart) the mock server. Returns the port. */
fun start(): Int {
// Clean up any previous instance
shutdownServer()
sseLatch = CountDownLatch(1)
sseConnected = CountDownLatch(1)
sseWriter = null
val srv = ServerSocket(0)
server = srv
port = srv.localPort
executor.submit { acceptLoop(srv) }
return port
}
/** Stop the server socket and SSE without killing the thread pool. */
fun shutdown() {
shutdownServer()
}
/** Wait until an SSE client has connected (up to [timeout] ms). */
fun awaitSseConnection(timeout: Long = 5_000): Boolean =
sseConnected.await(timeout, TimeUnit.MILLISECONDS)
/** Push an SSE event to the connected client. */
fun pushEvent(type: String, data: String) {
val w = sseWriter ?: return
synchronized(w) {
w.write("event: $type\n")
w.write("data: $data\n")
w.write("\n")
w.flush()
}
}
/** Close the SSE stream to simulate a server-side disconnect. */
fun closeSse() {
val w = sseWriter
sseWriter = null
runCatching { w?.close() }
sseLatch.countDown()
}
/** Final cleanup — shuts down the thread pool. Not restartable after this. */
override fun close() {
if (!closed.compareAndSet(false, true)) return
shutdownServer()
executor.shutdownNow()
}
private fun shutdownServer() {
sseLatch.countDown()
val w = sseWriter
sseWriter = null
runCatching { w?.close() }
connections.forEach { runCatching { it.close() } }
connections.clear()
runCatching { server?.close() }
server = null
}
private fun acceptLoop(srv: ServerSocket) {
while (!closed.get() && !srv.isClosed) {
try {
val socket = srv.accept()
connections.add(socket)
executor.submit { handle(socket) }
} catch (_: SocketException) {
break
}
}
}
private fun handle(socket: Socket) {
try {
val input = socket.getInputStream().bufferedReader()
val line = input.readLine() ?: return
val parts = line.split(" ")
if (parts.size < 2) return
val path = parts[1]
// Read all headers
while (true) {
val header = input.readLine()
if (header.isNullOrBlank()) break
}
val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
when {
path == "/global/health" -> respond(output, 200, health)
path == "/global/config" -> respond(output, configStatus, config)
path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications)
path.startsWith("/kilo/profile") -> {
if (profileStatus == 401) {
respond(output, 401, """{"message":"Unauthorized"}""")
} else {
respond(output, profileStatus, profile)
}
}
path == "/global/event" -> handleSse(output)
else -> respond(output, 404, """{"error":"Not found"}""")
}
} catch (_: SocketException) {
// Client disconnected
} catch (_: Exception) {
// Ignore errors in test mock
}
}
private fun respond(writer: BufferedWriter, status: Int, body: String) {
val phrase = when (status) {
200 -> "OK"
401 -> "Unauthorized"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}
val bytes = body.toByteArray(Charsets.UTF_8)
writer.write("HTTP/1.1 $status $phrase\r\n")
writer.write("Content-Type: application/json\r\n")
writer.write("Content-Length: ${bytes.size}\r\n")
writer.write("Connection: close\r\n")
writer.write("\r\n")
writer.write(body)
writer.flush()
}
private fun handleSse(writer: BufferedWriter) {
writer.write("HTTP/1.1 200 OK\r\n")
writer.write("Content-Type: text/event-stream\r\n")
writer.write("Cache-Control: no-cache\r\n")
writer.write("Connection: keep-alive\r\n")
writer.write("\r\n")
writer.flush()
sseWriter = writer
sseConnected.countDown()
// Block until SSE is closed or server shuts down
sseLatch.await()
}
}
@@ -0,0 +1,27 @@
package ai.kilocode.backend.testing
import ai.kilocode.backend.KiloLog
/**
* Test logger that captures messages for assertions and prints to stdout.
*/
class TestLog : KiloLog {
val messages = mutableListOf<String>()
override fun info(msg: String) {
synchronized(messages) { messages.add("INFO: $msg") }
println("[test] INFO: $msg")
}
override fun warn(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("WARN: $msg") }
println("[test] WARN: $msg")
t?.printStackTrace()
}
override fun error(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("ERROR: $msg") }
System.err.println("[test] ERROR: $msg")
t?.printStackTrace()
}
}
@@ -1,6 +1,13 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Empty plugin entry point required by the `gradlePlugin {}` DSL so that
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
*
* The real value lives in the custom task classes this composite build
* provides: [FixGeneratedApiTask], [PrepareLocalCliTask], and [CheckCliTask].
*/
class BuildTasksPlugin : Plugin<Project> {
override fun apply(target: Project) {}
}
@@ -11,7 +11,9 @@ openapi-generator = "7.21.0"
[libraries]
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
[plugins]
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }