mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
+24
-21
@@ -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 {
|
||||
|
||||
+25
-15
@@ -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)
|
||||
}
|
||||
|
||||
+37
-27
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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 })
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
+60
@@ -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 })
|
||||
}
|
||||
}
|
||||
+55
@@ -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"))
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+162
@@ -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),
|
||||
)
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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 })
|
||||
}
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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")
|
||||
}
|
||||
}
|
||||
+47
@@ -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")
|
||||
}
|
||||
}
|
||||
+35
@@ -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")
|
||||
}
|
||||
}
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user