test(jetbrains): add SessionModel tests with constructor-injected fake RPC

Refactor services (KiloSessionService, KiloAppService, KiloProjectService)
to accept RPC API via internal constructor for testability. Production
constructor passes null and resolves via durable{}.

26 tests across 10 focused test classes verify session creation, message
list updates, turn lifecycle, status computation, config selection,
workspace/app state watching, history loading, view switching, and
listener lifecycle. Every test asserts RPC calls are off-EDT and
listener callbacks are on-EDT.
This commit is contained in:
kirillk
2026-04-15 17:18:51 -04:00
parent b02e8daa48
commit 906b87dfaa
19 changed files with 876 additions and 63 deletions
@@ -1,3 +1,5 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
plugins {
alias(libs.plugins.rpc)
alias(libs.plugins.kotlin)
@@ -12,7 +14,20 @@ dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
bundledModule("intellij.platform.frontend")
testFramework(TestFrameworkType.Platform)
}
implementation(project(":shared"))
testImplementation(kotlin("test"))
testImplementation("junit:junit:4.13.2")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
}
tasks.test {
// BasePlatformTestCase uses JUnit 3 test naming (test prefix),
// discovered by the vintage engine via JUnit Platform
useJUnitPlatform()
// Ensure JUnit 3/4 tests run via vintage engine
jvmArgs("-Didea.force.use.core.classloader=true")
}
@@ -22,12 +22,15 @@ import kotlinx.coroutines.launch
*
* Communicates with the backend via [KiloAppRpcApi]. All operations
* are app-scoped — no project context is needed.
*
* Callers of [watch] are responsible for scheduling UI updates on
* the EDT and converting [KiloAppStateDto] to display text.
*/
@Service(Service.Level.APP)
class KiloAppService(private val cs: CoroutineScope) {
class KiloAppService internal constructor(
private val cs: CoroutineScope,
private val rpc: KiloAppRpcApi?,
) {
/** Platform constructor — resolves RPC from the service container. */
constructor(cs: CoroutineScope) : this(cs, null)
companion object {
private val LOG = Logger.getInstance(KiloAppService::class.java)
private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
@@ -40,28 +43,31 @@ class KiloAppService(private val cs: CoroutineScope) {
var version: String? = null
private set
private val _state = MutableStateFlow(init)
internal val _state = MutableStateFlow(init)
val state: StateFlow<KiloAppStateDto> = _state.asStateFlow()
// ------ RPC helper ------
private suspend fun <T> call(block: suspend KiloAppRpcApi.() -> T): T {
val api = rpc
return if (api != null) block(api) else durable { block(KiloAppRpcApi.getInstance()) }
}
// ------ Lifecycle ------
fun connect() {
if (!started.compareAndSet(false, true)) return
cs.launch { call { connect() } }
cs.launch {
durable {
KiloAppRpcApi.getInstance().connect()
}
}
cs.launch {
durable {
KiloAppRpcApi.getInstance()
.state()
.collect { _state.value = it }
}
val api = rpc
if (api != null) api.state().collect { _state.value = it }
else durable { KiloAppRpcApi.getInstance().state().collect { _state.value = it } }
}
}
/** One-shot health check. Returns null on failure. */
suspend fun health(): HealthDto? = try {
durable { KiloAppRpcApi.getInstance().health() }
call { health() }
} catch (e: Exception) {
LOG.warn("health check failed", e)
null
@@ -72,7 +78,7 @@ class KiloAppService(private val cs: CoroutineScope) {
LOG.info("restart: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloAppRpcApi.getInstance().restart() }
call { restart() }
LOG.info("restart: RPC returned — backend restart complete")
}
@@ -81,7 +87,7 @@ class KiloAppService(private val cs: CoroutineScope) {
LOG.info("reinstall: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloAppRpcApi.getInstance().reinstall() }
call { reinstall() }
LOG.info("reinstall: RPC returned — backend reinstall complete")
}
@@ -113,9 +119,6 @@ class KiloAppService(private val cs: CoroutineScope) {
/**
* Collect app state changes and invoke [fn] for each update.
*
* The callback receives raw [KiloAppStateDto] — the caller is
* responsible for converting to display text and scheduling on the EDT.
*/
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
return cs.launch {
@@ -10,6 +10,7 @@ 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -24,16 +25,16 @@ import kotlinx.coroutines.launch
* Project-level frontend service that provides reactive access
* to project-scoped data (providers, agents, commands, skills)
* and resolves the real project directory from the backend.
*
* In split mode, [Project.getBasePath] returns a synthetic sandbox
* path. This service resolves the backend's actual project directory
* via [KiloProjectRpcApi.directory] and uses it for all CLI calls.
*/
@Service(Service.Level.PROJECT)
class KiloProjectService(
class KiloProjectService internal constructor(
private val project: Project,
private val cs: CoroutineScope,
private val rpc: KiloProjectRpcApi?,
) {
/** Platform constructor — resolves RPC from the service container. */
constructor(project: Project, cs: CoroutineScope) : this(project, cs, null)
companion object {
private val LOG = Logger.getInstance(KiloProjectService::class.java)
private val init = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)
@@ -41,15 +42,30 @@ class KiloProjectService(
private val hint: String get() = project.basePath ?: ""
private val _directory = MutableStateFlow("")
internal val _directory = MutableStateFlow("")
/** The real project directory as resolved by the backend. */
val directory: StateFlow<String> = _directory.asStateFlow()
// ------ RPC helpers ------
private suspend fun <T> call(block: suspend KiloProjectRpcApi.() -> T): T {
val api = rpc
return if (api != null) block(api) else durable { block(KiloProjectRpcApi.getInstance()) }
}
private fun <T> stream(block: suspend KiloProjectRpcApi.() -> Flow<T>): Flow<T> = flow {
val api = rpc
if (api != null) block(api).collect { emit(it) }
else durable { block(KiloProjectRpcApi.getInstance()).collect { emit(it) } }
}
// ------ Init ------
init {
cs.launch {
try {
val resolved = durable { KiloProjectRpcApi.getInstance().directory(hint) }
val resolved = call { directory(hint) }
LOG.info("Resolved project directory: hint=$hint → resolved=$resolved")
_directory.value = resolved
} catch (e: Exception) {
@@ -63,13 +79,7 @@ class KiloProjectService(
val state: StateFlow<KiloWorkspaceStateDto> = _directory
.flatMapLatest { dir ->
if (dir.isEmpty()) return@flatMapLatest flowOf(init)
flow {
durable {
KiloProjectRpcApi.getInstance()
.state(dir)
.collect { emit(it) }
}
}
stream { state(dir) }
}
.stateIn(cs, SharingStarted.Eagerly, init)
@@ -79,7 +89,7 @@ class KiloProjectService(
val dir = _directory.value
if (dir.isEmpty()) return@launch
try {
durable { KiloProjectRpcApi.getInstance().reload(dir) }
call { reload(dir) }
} catch (e: Exception) {
LOG.warn("project data reload failed", e)
}
@@ -29,15 +29,18 @@ import kotlinx.coroutines.launch
* Project-level frontend service for session management.
*
* Stateless with respect to "active session" — callers pass explicit
* session IDs. [SessionModel] owns the active session concept.
*
* All operations are scoped to the project's [directory] by default.
* session IDs. [ai.kilocode.client.chat.model.SessionModel] owns the
* active session concept.
*/
@Service(Service.Level.PROJECT)
class KiloSessionService(
class KiloSessionService internal constructor(
private val project: Project,
private val cs: CoroutineScope,
private val rpc: KiloSessionRpcApi?,
) {
/** Platform constructor — resolves RPC from the service container. */
constructor(project: Project, cs: CoroutineScope) : this(project, cs, null)
companion object {
private val LOG = Logger.getInstance(KiloSessionService::class.java)
}
@@ -61,13 +64,21 @@ class KiloSessionService(
val sessions: StateFlow<List<SessionDto>> = _sessions.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())
val statuses: StateFlow<Map<String, SessionStatusDto>> =
stream { statuses() }.stateIn(cs, SharingStarted.Eagerly, emptyMap())
// ------ RPC helpers ------
private suspend fun <T> call(block: suspend KiloSessionRpcApi.() -> T): T {
val api = rpc
return if (api != null) block(api) else durable { block(KiloSessionRpcApi.getInstance()) }
}
private fun <T> stream(block: suspend KiloSessionRpcApi.() -> Flow<T>): Flow<T> = flow {
val api = rpc
if (api != null) block(api).collect { emit(it) }
else durable { block(KiloSessionRpcApi.getInstance()).collect { emit(it) } }
}
// ------ Session CRUD ------
@@ -75,7 +86,7 @@ class KiloSessionService(
fun refresh() {
cs.launch {
try {
val result = durable { KiloSessionRpcApi.getInstance().list(directory) }
val result = call { list(directory) }
_sessions.value = result.sessions
} catch (e: Exception) {
LOG.warn("session list failed", e)
@@ -87,7 +98,7 @@ class KiloSessionService(
suspend fun create(): SessionDto {
val dir = directory
LOG.info("create: dir=$dir")
val session = durable { KiloSessionRpcApi.getInstance().create(dir) }
val session = call { create(dir) }
LOG.info("create: id=${session.id}")
refresh()
return session
@@ -97,7 +108,7 @@ class KiloSessionService(
fun delete(id: String) {
cs.launch {
try {
durable { KiloSessionRpcApi.getInstance().delete(id, directory) }
call { delete(id, directory) }
refresh()
} catch (e: Exception) {
LOG.warn("session delete failed", e)
@@ -109,7 +120,7 @@ class KiloSessionService(
fun setDirectory(id: String, dir: String) {
cs.launch {
try {
durable { KiloSessionRpcApi.getInstance().setDirectory(id, dir) }
call { setDirectory(id, dir) }
} catch (e: Exception) {
LOG.warn("setDirectory failed", e)
}
@@ -121,33 +132,32 @@ class KiloSessionService(
/** Send a text prompt to a session. */
suspend fun prompt(id: String, dir: String, text: String) {
LOG.info("prompt: session=$id, dir=$dir, text=${text.take(80)}")
val prompt = PromptDto(
parts = listOf(PromptPartDto(type = "text", text = text)),
)
durable { KiloSessionRpcApi.getInstance().prompt(id, dir, prompt) }
val dto = PromptDto(parts = listOf(PromptPartDto(type = "text", text = text)))
call { prompt(id, dir, dto) }
LOG.info("prompt: RPC returned successfully")
}
/** Abort ongoing processing for a session. */
suspend fun abort(id: String, dir: String) {
durable { KiloSessionRpcApi.getInstance().abort(id, dir) }
call { abort(id, dir) }
}
/** Load message history for a session. */
suspend fun messages(id: String, dir: String): List<MessageWithPartsDto> =
durable { KiloSessionRpcApi.getInstance().messages(id, dir) }
call { messages(id, dir) }
/** Subscribe to streaming chat events for a session. */
fun events(id: String, dir: String): Flow<ChatEventDto> = flow {
durable {
KiloSessionRpcApi.getInstance()
.events(id, dir)
.collect { emit(it) }
fun events(id: String, dir: String): Flow<ChatEventDto> {
val api = rpc
return if (api != null) flow {
api.events(id, dir).collect { emit(it) }
} else flow {
durable { KiloSessionRpcApi.getInstance().events(id, dir).collect { emit(it) } }
}
}
/** Update config (model, agent/mode, temperature). */
suspend fun updateConfig(dir: String, config: ConfigUpdateDto) {
durable { KiloSessionRpcApi.getInstance().updateConfig(dir, config) }
call { updateConfig(dir, config) }
}
}
@@ -0,0 +1,19 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
class AppWatchingTest : SessionModelTestBase() {
fun `test app state change fires AppChanged`() {
val m = model()
val events = collect(m)
flushEdt()
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
flushEdt()
assertTrue(events.any { it is SessionEvent.AppChanged })
assertEquals(KiloAppStatusDto.READY, m.chat.app.status)
}
}
@@ -0,0 +1,41 @@
package ai.kilocode.client.chat.model
class ConfigSelectionTest : SessionModelTestBase() {
fun `test selectModel updates ChatModel and calls updateConfig`() {
val m = model()
collect(m)
flushEdt()
edt { m.selectModel("kilo", "gpt-5") }
flushEdt()
assertEquals("kilo/gpt-5", m.chat.model)
assertEquals(1, rpc.configs.size)
assertEquals("kilo/gpt-5", rpc.configs[0].second.model)
}
fun `test selectAgent updates ChatModel and calls updateConfig`() {
val m = model()
collect(m)
flushEdt()
edt { m.selectAgent("plan") }
flushEdt()
assertEquals("plan", m.chat.agent)
assertEquals(1, rpc.configs.size)
assertEquals("plan", rpc.configs[0].second.agent)
}
fun `test selectModel fires WorkspaceReady event`() {
val m = model()
val events = collect(m)
flushEdt()
edt { m.selectModel("kilo", "gpt-5") }
flushEdt()
assertTrue(events.any { it is SessionEvent.WorkspaceReady })
}
}
@@ -0,0 +1,31 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.MessageWithPartsDto
class HistoryLoadingTest : SessionModelTestBase() {
fun `test existing session loads history on init`() {
val m = msg("msg1", "ses_test", "user")
val p = part("prt1", "ses_test", "msg1", "text", text = "hello")
rpc.history.add(MessageWithPartsDto(m, listOf(p)))
val model = model("ses_test")
val events = collect(model)
flushEdt()
assertTrue(events.any { it is SessionEvent.HistoryLoaded })
assertNotNull(model.chat.message("msg1"))
assertEquals("hello", model.chat.part("msg1", "prt1")?.text?.toString())
}
fun `test non-empty history shows messages view`() {
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList()))
val model = model("ses_test")
val events = collect(model)
flushEdt()
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
assertTrue(model.chat.showMessages)
}
}
@@ -0,0 +1,60 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.util.Disposer
class ListenerLifecycleTest : SessionModelTestBase() {
fun `test listener removed on parent dispose`() {
val m = model()
val disposable = Disposer.newDisposable("listener-parent")
Disposer.register(parent, disposable)
val events = mutableListOf<SessionEvent>()
m.addListener(disposable) { events.add(it) }
edt { m.prompt("before") }
flushEdt()
val before = events.size
Disposer.dispose(disposable)
edt { m.prompt("after") }
flushEdt()
assertEquals(before, events.size)
}
fun `test all listeners notified`() {
val m = model()
val events1 = mutableListOf<SessionEvent>()
val events2 = mutableListOf<SessionEvent>()
val d1 = Disposer.newDisposable("l1")
val d2 = Disposer.newDisposable("l2")
Disposer.register(parent, d1)
Disposer.register(parent, d2)
m.addListener(d1) { events1.add(it) }
m.addListener(d2) { events2.add(it) }
edt { m.prompt("go") }
flushEdt()
assertTrue(events1.isNotEmpty())
assertTrue(events2.isNotEmpty())
assertEquals(events1.map { it::class }, events2.map { it::class })
}
fun `test session status busy fires BusyChanged`() {
val m = model()
val events = collect(m)
edt { m.prompt("go") }
flushEdt()
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy", null))
flushEdt()
assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy })
}
}
@@ -0,0 +1,55 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.ChatEventDto
class MessageListTest : SessionModelTestBase() {
fun `test MessageUpdated adds message to ChatModel`() {
val (m, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flushEdt()
assertTrue(events.any { it is SessionEvent.MessageAdded && it.id == "msg1" })
assertNotNull(m.chat.message("msg1"))
}
fun `test PartUpdated text fires PartUpdated event`() {
val (m, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flushEdt()
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")))
flushEdt()
assertTrue(events.any { it is SessionEvent.PartUpdated && it.messageId == "msg1" && it.partId == "prt1" })
}
fun `test PartDelta appends text to ChatModel`() {
val (m, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flushEdt()
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "))
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"))
flushEdt()
val p = m.chat.part("msg1", "prt1")
assertNotNull(p)
assertEquals("hello world", p!!.text.toString())
}
fun `test MessageRemoved removes from ChatModel`() {
val (m, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user")))
flushEdt()
assertNotNull(m.chat.message("msg1"))
emit(ChatEventDto.MessageRemoved("ses_test", "msg1"))
flushEdt()
assertNull(m.chat.message("msg1"))
}
}
@@ -0,0 +1,43 @@
package ai.kilocode.client.chat.model
class SessionCreationTest : SessionModelTestBase() {
fun `test prompt creates session on first call`() {
val m = model()
val events = collect(m)
edt { m.prompt("hello") }
flushEdt()
assertEquals(1, rpc.creates)
assertEquals(1, rpc.prompts.size)
assertEquals("ses_test", rpc.prompts[0].first)
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
}
fun `test prompt reuses existing session`() {
val m = model()
edt { m.prompt("first") }
flushEdt()
edt { m.prompt("second") }
flushEdt()
assertEquals(1, rpc.creates)
assertEquals(2, rpc.prompts.size)
assertEquals("ses_test", rpc.prompts[1].first)
}
fun `test prompt with existing ID skips creation`() {
val m = model("existing")
collect(m)
flushEdt()
edt { m.prompt("hello") }
flushEdt()
assertEquals(0, rpc.creates)
assertEquals(1, rpc.prompts.size)
assertEquals("existing", rpc.prompts[0].first)
}
}
@@ -0,0 +1,162 @@
package ai.kilocode.client.chat.model
import ai.kilocode.client.KiloAppService
import ai.kilocode.client.KiloProjectService
import ai.kilocode.client.KiloSessionService
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeProjectRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
/**
* Base class for [SessionModel] tests.
*
* Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase],
* real frontend services wired to fake RPC backends, and shared helpers.
*/
abstract class SessionModelTestBase : BasePlatformTestCase() {
protected lateinit var rpc: FakeSessionRpcApi
protected lateinit var appRpc: FakeAppRpcApi
protected lateinit var projectRpc: FakeProjectRpcApi
protected lateinit var sessions: KiloSessionService
protected lateinit var app: KiloAppService
protected lateinit var workspace: KiloProjectService
protected lateinit var scope: CoroutineScope
protected lateinit var parent: Disposable
override fun setUp() {
super.setUp()
rpc = FakeSessionRpcApi()
appRpc = FakeAppRpcApi()
projectRpc = FakeProjectRpcApi()
scope = CoroutineScope(SupervisorJob())
parent = Disposer.newDisposable("test")
sessions = KiloSessionService(project, scope, rpc)
app = KiloAppService(scope, appRpc)
workspace = KiloProjectService(project, scope, projectRpc)
}
override fun tearDown() {
try {
Disposer.dispose(parent)
scope.cancel()
} finally {
super.tearDown()
}
}
// ------ Model creation ------
protected fun model(id: String? = null) =
SessionModel(parent, id, sessions, workspace, app, scope)
// ------ Event collection ------
/** Attach a listener that collects events and asserts EDT. */
protected fun collect(m: SessionModel): MutableList<SessionEvent> {
val events = mutableListOf<SessionEvent>()
val disposable = Disposer.newDisposable("listener")
Disposer.register(parent, disposable)
m.addListener(disposable) { event ->
assertTrue("Listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread)
events.add(event)
}
return events
}
// ------ EDT + coroutine helpers ------
/** Let coroutines settle, then drain all pending EDT events. */
protected fun flushEdt() = runBlocking {
repeat(5) {
delay(100)
edt { UIUtil.dispatchAllInvocationEvents() }
}
}
protected fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
/** Emit a chat event into the fake RPC flow. */
protected fun emit(event: ChatEventDto) = runBlocking {
rpc.events.emit(event)
}
/** Create a model, attach listener, send initial prompt, and flush. */
protected fun prompted(): Pair<SessionModel, MutableList<SessionEvent>> {
val m = model()
val events = collect(m)
edt { m.prompt("go") }
flushEdt()
return m to events
}
// ------ DTO factories ------
protected fun msg(id: String, sid: String, role: String) = MessageDto(
id = id,
sessionID = sid,
role = role,
time = MessageTimeDto(created = 0.0),
)
protected fun part(
id: String,
sid: String,
mid: String,
type: String,
text: String? = null,
tool: String? = null,
) = PartDto(
id = id,
sessionID = sid,
messageID = mid,
type = type,
text = text,
tool = tool,
)
protected fun workspaceReady(
agents: List<AgentDto> = listOf(AgentDto(name = "code", displayName = "Code", mode = "code")),
default: String = "code",
providers: List<ProviderDto> = listOf(
ProviderDto(
id = "kilo",
name = "Kilo",
models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")),
),
),
connected: List<String> = listOf("kilo"),
defaults: Map<String, String> = mapOf("kilo" to "gpt-5"),
) = KiloWorkspaceStateDto(
status = KiloWorkspaceStatusDto.READY,
agents = AgentsDto(agents = agents, all = agents, default = default),
providers = ProvidersDto(providers = providers, connected = connected, defaults = defaults),
)
}
@@ -0,0 +1,43 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.ChatEventDto
class StatusComputationTest : SessionModelTestBase() {
fun `test status shows tool-specific text`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flushEdt()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flushEdt()
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash")))
flushEdt()
val status = events.filterIsInstance<SessionEvent.StatusChanged>()
.lastOrNull { it.text != null && it.text != "Considering next steps..." }
assertNotNull(status)
assertEquals("Running commands...", status!!.text)
}
fun `test PartUpdated after TurnClose does not fire StatusChanged`() {
val (_, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flushEdt()
emit(ChatEventDto.TurnOpen("ses_test"))
flushEdt()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
flushEdt()
val before = events.filterIsInstance<SessionEvent.StatusChanged>().size
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late")))
flushEdt()
val after = events.filterIsInstance<SessionEvent.StatusChanged>().size
assertEquals(before, after)
}
}
@@ -0,0 +1,52 @@
package ai.kilocode.client.chat.model
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.MessageErrorDto
class TurnLifecycleTest : SessionModelTestBase() {
fun `test TurnOpen fires BusyChanged true`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flushEdt()
assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy })
assertTrue(events.any { it is SessionEvent.StatusChanged && it.text == "Considering next steps..." })
}
fun `test TurnClose fires BusyChanged false and clears status`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flushEdt()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
flushEdt()
val last = events.filterIsInstance<SessionEvent.BusyChanged>().last()
assertFalse(last.busy)
val status = events.filterIsInstance<SessionEvent.StatusChanged>().last()
assertNull(status.text)
}
fun `test Error fires Error event with message`() {
val (_, events) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request")))
flushEdt()
val err = events.filterIsInstance<SessionEvent.Error>().firstOrNull()
assertNotNull(err)
assertEquals("Bad Request", err!!.message)
}
fun `test Error with null message falls back to type`() {
val (_, events) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null)))
flushEdt()
val err = events.filterIsInstance<SessionEvent.Error>().first()
assertEquals("timeout", err.message)
}
}
@@ -0,0 +1,26 @@
package ai.kilocode.client.chat.model
class ViewSwitchingTest : SessionModelTestBase() {
fun `test first prompt shows messages view`() {
val m = model()
val events = collect(m)
edt { m.prompt("hello") }
flushEdt()
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
}
fun `test ViewChanged not fired twice`() {
val m = model()
val events = collect(m)
edt { m.prompt("first") }
flushEdt()
edt { m.prompt("second") }
flushEdt()
assertEquals(1, events.count { it is SessionEvent.ViewChanged && it.show })
}
}
@@ -0,0 +1,32 @@
package ai.kilocode.client.chat.model
class WorkspaceWatchingTest : SessionModelTestBase() {
fun `test workspace ready populates agents and models`() {
val m = model()
val events = collect(m)
flushEdt()
projectRpc.state.value = workspaceReady()
flushEdt()
assertEquals(1, m.chat.agents.size)
assertEquals("code", m.chat.agents[0].name)
assertEquals(1, m.chat.models.size)
assertEquals("gpt-5", m.chat.models[0].id)
assertTrue(m.chat.ready)
assertTrue(events.any { it is SessionEvent.WorkspaceReady })
}
fun `test workspace ready sets default agent and model`() {
val m = model()
collect(m)
flushEdt()
projectRpc.state.value = workspaceReady()
flushEdt()
assertEquals("code", m.chat.agent)
assertEquals("gpt-5", m.chat.model)
}
}
@@ -0,0 +1,15 @@
package ai.kilocode.client.testing
import com.intellij.openapi.application.ApplicationManager
/**
* Assert that the current thread is NOT the EDT.
* Used in fake RPC implementations to verify that RPC calls
* are never made from the dispatch thread.
*/
fun assertNotEdt(method: String) {
val app = ApplicationManager.getApplication() ?: return
if (app.isDispatchThread) {
throw AssertionError("RPC method '$method' must not be called on the EDT")
}
}
@@ -0,0 +1,47 @@
package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Fake [KiloAppRpcApi] for testing.
*
* Push state changes via [state]. Health check returns [health].
*
* Every `suspend` method asserts it is NOT called on the EDT.
*/
class FakeAppRpcApi : KiloAppRpcApi {
val state = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED))
var health = HealthDto(healthy = true, version = "1.0.0")
var connected = false
private set
override suspend fun connect() {
assertNotEdt("connect")
connected = true
}
override suspend fun state(): Flow<KiloAppStateDto> {
assertNotEdt("state")
return state
}
override suspend fun health(): HealthDto {
assertNotEdt("health")
return health
}
override suspend fun restart() {
assertNotEdt("restart")
}
override suspend fun reinstall() {
assertNotEdt("reinstall")
}
}
@@ -0,0 +1,35 @@
package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloProjectRpcApi
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Fake [KiloProjectRpcApi] for testing.
*
* Push workspace state changes via [state].
* Directory resolution returns [directory].
*
* Every `suspend` method asserts it is NOT called on the EDT.
*/
class FakeProjectRpcApi : KiloProjectRpcApi {
var directory = "/test"
val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING))
override suspend fun directory(hint: String): String {
assertNotEdt("directory")
return directory
}
override suspend fun state(directory: String): Flow<KiloWorkspaceStateDto> {
assertNotEdt("state")
return state
}
override suspend fun reload(directory: String) {
assertNotEdt("reload")
}
}
@@ -0,0 +1,114 @@
package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Fake [KiloSessionRpcApi] for testing.
*
* Configurable return values and call tracking. Push events
* via [events] and statuses via [statuses].
*
* Every `suspend` method asserts it is NOT called on the EDT —
* RPC calls must happen on background threads.
*/
class FakeSessionRpcApi : KiloSessionRpcApi {
/** The session returned by [create] and [get]. */
var session = SessionDto(
id = "ses_test",
projectID = "proj_test",
directory = "/test",
title = "Test Session",
version = "1",
time = SessionTimeDto(created = 0.0, updated = 0.0),
)
/** Message history returned by [messages]. */
val history = mutableListOf<MessageWithPartsDto>()
/** Push chat events here; tests collect from [events]. */
val events = MutableSharedFlow<ChatEventDto>(extraBufferCapacity = 64, replay = 64)
/** Push status updates here. */
val statuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
// --- Call tracking ---
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val aborts = mutableListOf<Pair<String, String>>()
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
var creates = 0
private set
// --- Implementation ---
override suspend fun create(directory: String): SessionDto {
assertNotEdt("create")
creates++
return session
}
override suspend fun list(directory: String): SessionListDto {
assertNotEdt("list")
return SessionListDto(emptyList(), emptyMap())
}
override suspend fun get(id: String, directory: String): SessionDto {
assertNotEdt("get")
return session
}
override suspend fun delete(id: String, directory: String) {
assertNotEdt("delete")
}
override suspend fun statuses(): Flow<Map<String, SessionStatusDto>> {
assertNotEdt("statuses")
return statuses
}
override suspend fun setDirectory(id: String, directory: String) {
assertNotEdt("setDirectory")
}
override suspend fun getDirectory(id: String, fallback: String): String {
assertNotEdt("getDirectory")
return fallback
}
override suspend fun prompt(id: String, directory: String, prompt: PromptDto) {
assertNotEdt("prompt")
prompts.add(Triple(id, directory, prompt))
}
override suspend fun abort(id: String, directory: String) {
assertNotEdt("abort")
aborts.add(id to directory)
}
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> {
assertNotEdt("messages")
return history.toList()
}
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> {
assertNotEdt("events")
return events
}
override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) {
assertNotEdt("updateConfig")
configs.add(directory to config)
}
}