From 37cd85212127549b81f96f4d57390ed3cceb463d Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Apr 2026 15:33:17 -0400 Subject: [PATCH 01/27] chore(jetbrains): upgrade IntelliJ platform to 2026.1 and RPC plugin to 2.3.20-RC2 --- packages/kilo-jetbrains/gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 9d81facbccf..b7a8cd546f0 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -intellij-platform = "2025.3" +intellij-platform = "2026.1" intellij-gradle-plugin = "2.14.0" -intellij-rpc-plugin = "2.3.20-0.1" +intellij-rpc-plugin = "2.3.20-RC2-0.1" kotlin-jvm-plugin = "2.3.20" kotlin-serialization-plugin = "2.3.20" kotlin-serialization = "1.11.0" From 397e3faa743bd7fb8068b279176aa7de964f0116 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Apr 2026 17:48:44 -0400 Subject: [PATCH 02/27] refactor(jetbrains): extract MessageListModel MVC and rename SessionModel to SessionManager Introduce clean MVC separation for chat message list: - MessageListModel holds message state, fires MessageModelEvent to MessageListUi - SessionUi acts as controller, translating SessionManager events into model mutations - Rename SessionModel -> SessionManager, MessageListPanel -> MessageListUi - Extract MessageModel and MessageModelEvent to top-level files --- .../kilocode/client/app/KiloSessionService.kt | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 69 ++--- .../client/session/model/MessageListModel.kt | 114 ++++++++ .../client/session/model/MessageModel.kt | 8 + .../client/session/model/MessageModelEvent.kt | 28 ++ .../client/session/model/SessionEvent.kt | 6 +- .../{SessionModel.kt => SessionManager.kt} | 10 +- .../client/session/model/SessionState.kt | 8 +- .../{MessageListPanel.kt => MessageListUi.kt} | 129 ++++++--- .../kilocode/client/session/ui/StatusPanel.kt | 10 +- .../client/session/model/AppWatchingTest.kt | 2 +- .../session/model/ConfigSelectionTest.kt | 2 +- .../session/model/HistoryLoadingTest.kt | 2 +- .../session/model/ListenerLifecycleTest.kt | 2 +- .../session/model/MessageListModelTest.kt | 273 ++++++++++++++++++ .../client/session/model/MessageListTest.kt | 2 +- .../session/model/SessionCreationTest.kt | 2 +- ...lTestBase.kt => SessionManagerTestBase.kt} | 10 +- .../session/model/StatusComputationTest.kt | 2 +- .../client/session/model/TurnLifecycleTest.kt | 2 +- .../client/session/model/ViewSwitchingTest.kt | 2 +- .../session/model/WorkspaceWatchingTest.kt | 2 +- 22 files changed, 567 insertions(+), 120 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{SessionModel.kt => SessionManager.kt} (97%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{MessageListPanel.kt => MessageListUi.kt} (51%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/{SessionModelTestBase.kt => SessionManagerTestBase.kt} (94%) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 45c81c2e2c9..01d221a1494 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.model.SessionModel] owns the + * session IDs. [ai.kilocode.client.session.model.SessionManager] owns the * active session concept. */ @Service(Service.Level.PROJECT) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2a9aec5e9e3..08b6541cf1c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,10 +3,11 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.model.MessageListModel import ai.kilocode.client.session.model.SessionEvent -import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionManager import ai.kilocode.client.session.ui.LabelPicker -import ai.kilocode.client.session.ui.MessageListPanel +import ai.kilocode.client.session.ui.MessageListUi import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable @@ -19,15 +20,17 @@ import java.awt.CardLayout import javax.swing.JPanel /** - * Main chat panel — reacts to [SessionModel] events. + * Main chat panel — reacts to [SessionManager] events. * * Uses [CardLayout] in the center to switch between the empty panel * (shown before the first prompt) and the scrollable message list. * * All business logic (app/workspace watching, session lifecycle, event - * handling, status computation) lives in [SessionModel]. Welcome - * rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. This class handles layout, prompt - * wiring, message list updates, card switching, picker population, + * handling, status computation) lives in [SessionManager]. Welcome + * rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. + * Message list rendering lives in [MessageListUi], driven by + * [MessageListModel] events. This class handles layout, prompt + * wiring, model mutations, card switching, picker population, * busy state, and scrolling. */ class SessionUi( @@ -43,9 +46,10 @@ class SessionUi( private const val MESSAGES = "messages" } - private val model = SessionModel(this, null, sessions, workspace, app, cs) + private val model = SessionManager(this, null, sessions, workspace, app, cs) private val status = StatusPanel(this, model) - private val messages = MessageListPanel() + private val messageList = MessageListModel() + private val messages = MessageListUi(this, messageList) private val cards = CardLayout() private val center = JPanel(cards) @@ -82,57 +86,50 @@ class SessionUi( } } - // React to model events — no coroutines, pure EDT + // React to model events — no coroutines, pure EDT. + // Message-related events are forwarded to MessageListModel; + // MessageListUi listens to the model and renders autonomously. model.addListener(this) { event -> when (event) { is SessionEvent.MessageAdded -> { val msg = model.chat.message(event.id) ?: return@addListener - messages.addMessage(msg.info) - refreshMessages() + messageList.addMessage(msg.info) + scrollToBottom() } is SessionEvent.MessageRemoved -> { - messages.removeMessage(event.id) - refreshMessages() + messageList.removeMessage(event.id) + scrollToBottom() } is SessionEvent.PartUpdated -> { val part = model.chat.part(event.messageId, event.partId) ?: return@addListener - messages.updatePartText(event.messageId, event.partId, part.text.toString()) - refreshMessages() + messageList.setPartText(event.messageId, event.partId, part.text.toString()) + scrollToBottom() } is SessionEvent.PartDelta -> { - messages.appendDelta(event.messageId, event.partId, event.delta) - refreshMessages() + messageList.appendDelta(event.messageId, event.partId, event.delta) + scrollToBottom() } is SessionEvent.StatusChanged -> { - messages.setStatus(event.text) - refreshMessages() + messageList.setStatus(event.text) + scrollToBottom() } is SessionEvent.Error -> { - messages.addError(event.message) - refreshMessages() + messageList.addError(event.message) + scrollToBottom() } is SessionEvent.HistoryLoaded -> { - messages.clear() - for (msg in model.chat.messages()) { - messages.addMessage(msg.info) - for ((partId, part) in msg.parts) { - if (part.dto.type == "text" && part.text.isNotEmpty()) { - messages.updatePartText(msg.info.id, partId, part.text.toString()) - } - } - } - refreshMessages() + messageList.loadHistory(model.chat.messages()) + scrollToBottom() } is SessionEvent.Cleared -> { - messages.clear() - refreshMessages() + messageList.clear() } is SessionEvent.WorkspaceReady -> { @@ -173,12 +170,6 @@ class SessionUi( prompt.clear() } - private fun refreshMessages() { - messages.revalidate() - messages.repaint() - scrollToBottom() - } - private fun scrollToBottom() { val bar = scroll.verticalScrollBar bar.value = bar.maximum diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt new file mode 100644 index 00000000000..891eefd9947 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt @@ -0,0 +1,114 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer + +/** + * Pure data model for the message list. + * + * Holds messages, parts, status, and errors. All mutations fire + * [MessageModelEvent]s to registered [MessageModelEvent.Listener]s. + * + * **EDT-only access** — no synchronization. The caller ([SessionUi]) + * guarantees all reads and writes happen on the EDT. + */ +class MessageListModel { + + private val entries = LinkedHashMap() + private val _errors = mutableListOf() + + /** Current status text. Null means hidden. */ + var status: String? = null + private set + + /** Accumulated error messages. */ + val errors: List get() = _errors + + private val listeners = mutableListOf() + + // --- Listener management --- + + fun addListener(parent: Disposable, listener: MessageModelEvent.Listener) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + // --- Read --- + + fun entries(): Collection = entries.values + + fun entry(id: String): MessageModel? = entries[id] + + fun isEmpty(): Boolean = entries.isEmpty() + + // --- Mutations --- + + fun addMessage(info: MessageDto) { + if (entries.containsKey(info.id)) return + entries[info.id] = MessageModel(info) + fire(MessageModelEvent.MessageAdded(info)) + } + + fun removeMessage(id: String) { + if (entries.remove(id) == null) return + fire(MessageModelEvent.MessageRemoved(id)) + } + + fun setPartText(messageId: String, partId: String, text: String) { + val entry = entries[messageId] ?: return + entry.parts.getOrPut(partId) { StringBuilder() }.apply { + clear() + append(text) + } + fire(MessageModelEvent.PartText(messageId, partId, text)) + } + + fun appendDelta(messageId: String, partId: String, delta: String) { + val entry = entries[messageId] ?: return + entry.parts.getOrPut(partId) { StringBuilder() }.append(delta) + fire(MessageModelEvent.PartDelta(messageId, partId, delta)) + } + + fun addError(message: String) { + _errors.add(message) + fire(MessageModelEvent.Error(message)) + } + + fun setStatus(text: String?) { + status = text + fire(MessageModelEvent.StatusChanged(text)) + } + + fun clear() { + entries.clear() + _errors.clear() + status = null + fire(MessageModelEvent.Cleared) + } + + /** + * Bulk-load message history. Clears existing entries first, + * populates from [SessionState] data, and fires a single + * [MessageModelEvent.HistoryLoaded]. + */ + fun loadHistory(messages: Collection) { + entries.clear() + _errors.clear() + status = null + for (msg in messages) { + val entry = MessageModel(msg.info) + for ((partId, part) in msg.parts) { + if (part.dto.type == "text" && part.text.isNotEmpty()) { + entry.parts[partId] = StringBuilder(part.text) + } + } + entries[msg.info.id] = entry + } + fire(MessageModelEvent.HistoryLoaded) + } + + private fun fire(event: MessageModelEvent) { + for (l in listeners) l.onEvent(event) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt new file mode 100644 index 00000000000..6bb2f216b05 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt @@ -0,0 +1,8 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto + +/** A single message with its accumulated part text. */ +class MessageModel(val info: MessageDto) { + val parts = LinkedHashMap() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt new file mode 100644 index 00000000000..fc82d6619ff --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt @@ -0,0 +1,28 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto + +/** + * Change events fired by [MessageListModel]. + * + * Events carry the data needed for rendering so [MessageListUi][ai.kilocode.client.session.ui.MessageListUi] + * can update without reading back from the model (except for [HistoryLoaded]). + */ +sealed class MessageModelEvent { + data class MessageAdded(val info: MessageDto) : MessageModelEvent() + data class MessageRemoved(val id: String) : MessageModelEvent() + data class PartText(val messageId: String, val partId: String, val text: String) : MessageModelEvent() + data class PartDelta(val messageId: String, val partId: String, val delta: String) : MessageModelEvent() + data class Error(val message: String) : MessageModelEvent() + data class StatusChanged(val text: String?) : MessageModelEvent() + data object HistoryLoaded : MessageModelEvent() + data object Cleared : MessageModelEvent() + + /** + * Listener for [MessageModelEvent]s fired by [MessageListModel]. + * All callbacks are guaranteed to run on the EDT. + */ + fun interface Listener { + fun onEvent(event: MessageModelEvent) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt index f45903f0555..9b8da099abe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.model /** - * Change events fired by [SessionModel] on the EDT. + * Change events fired by [SessionManager] on the EDT. * * Events carry IDs so the UI knows **which** message/part changed. * The UI can read full data from [SessionState] directly (safe — same @@ -37,9 +37,9 @@ sealed class SessionEvent { } /** - * Listener for [SessionEvent]s fired by [SessionModel]. + * Listener for [SessionEvent]s fired by [SessionManager]. * All callbacks are guaranteed to run on the EDT. */ -fun interface SessionModelListener { +fun interface SessionManagerListener { fun onEvent(event: SessionEvent) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt index 74c78e1f2e7..077126ccfb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt @@ -25,11 +25,11 @@ import kotlinx.coroutines.launch * ensures event subscription happens *before* the prompt is sent, * eliminating race conditions. * - * Owns [SessionState] and the listener list. All model mutations and + * Owns [SessionState] and the listener list. All state mutations and * listener notifications happen on the EDT — [fire] auto-dispatches * via `invokeLater` when called from a background thread. */ -class SessionModel( +class SessionManager( parent: Disposable, id: String?, private val sessions: KiloSessionService, @@ -39,7 +39,7 @@ class SessionModel( ) : Disposable { companion object { - private val LOG = Logger.getInstance(SessionModel::class.java) + private val LOG = Logger.getInstance(SessionManager::class.java) } init { @@ -48,7 +48,7 @@ class SessionModel( val chat = SessionState() - private val listeners = mutableListOf() + private val listeners = mutableListOf() /** The session ID owned by this model. Null until created or passed in. */ private var sessionId: String? = id @@ -70,7 +70,7 @@ class SessionModel( * Register a listener whose lifetime is tied to [parent]. * When [parent] is disposed the listener is auto-removed. */ - fun addListener(parent: Disposable, listener: SessionModelListener) { + fun addListener(parent: Disposable, listener: SessionManagerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 69effbcb9dc..3d5379371a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -12,19 +12,19 @@ import ai.kilocode.rpc.dto.PartDto * Pure data holder for the active session's messages, parts, and * workspace state (agents, models, selection). * - * **EDT-only access** — no synchronization. [SessionModel] guarantees + * **EDT-only access** — no synchronization. [SessionManager] guarantees * all reads and writes happen on the EDT. */ class SessionState { private val messages = LinkedHashMap() - // --- App lifecycle state (set by SessionModel, read by EmptyChatUi) --- + // --- App lifecycle state (set by SessionManager, read by EmptyChatUi) --- var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) var version: String? = null - // --- Workspace state (set by SessionModel, read by UI) --- + // --- Workspace state (set by SessionManager, read by UI) --- var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) var agents: List = emptyList() @@ -45,7 +45,7 @@ class SessionState { fun isEmpty(): Boolean = messages.isEmpty() - // --- Write (called by SessionModel on EDT) --- + // --- Write (called by SessionManager on EDT) --- /** * Add a message. Returns false if the message already exists. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt similarity index 51% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index 1f8cb192078..54fe211c145 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -1,6 +1,9 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.model.MessageListModel +import ai.kilocode.client.session.model.MessageModelEvent import ai.kilocode.rpc.dto.MessageDto +import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel @@ -17,13 +20,19 @@ import javax.swing.border.MatteBorder * Scrollable panel displaying chat messages aligned to the top, * with an optional animated status indicator at the bottom. * + * Passive view — all rendering is driven by [MessageModelEvent]s + * from the [MessageListModel]. No public mutation methods. + * * Inner panel uses [BoxLayout.Y_AXIS] for stacking, wrapped in a * [BorderLayout.NORTH] so messages stay top-aligned when the scroll * viewport is taller than the content. */ -class MessageListPanel : JPanel(BorderLayout()) { +class MessageListUi( + parent: Disposable, + private val model: MessageListModel, +) : JPanel(BorderLayout()) { - private val panels = LinkedHashMap() + private val blocks = LinkedHashMap() private val inner = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -31,88 +40,112 @@ class MessageListPanel : JPanel(BorderLayout()) { border = JBUI.Borders.empty(4, 8) } - private val statusLabel = JBLabel().apply { + private val label = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } - private val status = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { + private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { isOpaque = false isVisible = false border = JBUI.Borders.empty(6, 0) alignmentX = LEFT_ALIGNMENT add(JBLabel(AnimatedIcon.Default())) - add(statusLabel) + add(label) } init { isOpaque = true background = UIUtil.getPanelBackground() - inner.add(status) + inner.add(spinner) add(inner, BorderLayout.NORTH) + + model.addListener(parent) { event -> + when (event) { + is MessageModelEvent.MessageAdded -> onAdded(event.info) + is MessageModelEvent.MessageRemoved -> onRemoved(event.id) + is MessageModelEvent.PartText -> onPartText(event.messageId, event.partId, event.text) + is MessageModelEvent.PartDelta -> onPartDelta(event.messageId, event.partId, event.delta) + is MessageModelEvent.Error -> onError(event.message) + is MessageModelEvent.StatusChanged -> onStatus(event.text) + is MessageModelEvent.HistoryLoaded -> onHistory() + is MessageModelEvent.Cleared -> onCleared() + } + } } - fun addMessage(info: MessageDto) { - if (panels.containsKey(info.id)) return + private fun onAdded(info: MessageDto) { + if (blocks.containsKey(info.id)) return val block = MessageBlock(info) - panels[info.id] = block - // Insert before the status row (which is always last) + blocks[info.id] = block inner.add(block, inner.componentCount - 1) - revalidate() - repaint() + refresh() } - fun updatePartText(messageID: String, partID: String, text: String) { - panels[messageID]?.setText(partID, text) - revalidate() - repaint() - } - - fun appendDelta(messageID: String, partID: String, delta: String) { - panels[messageID]?.appendDelta(partID, delta) - revalidate() - repaint() - } - - fun removeMessage(messageID: String) { - val block = panels.remove(messageID) ?: return + private fun onRemoved(id: String) { + val block = blocks.remove(id) ?: return inner.remove(block) - revalidate() - repaint() + refresh() } - fun addError(msg: String) { - val label = JBLabel(msg).apply { + private fun onPartText(messageId: String, partId: String, text: String) { + blocks[messageId]?.setText(partId, text) + refresh() + } + + private fun onPartDelta(messageId: String, partId: String, delta: String) { + blocks[messageId]?.appendDelta(partId, delta) + refresh() + } + + private fun onError(msg: String) { + val err = JBLabel(msg).apply { foreground = JBColor.RED font = JBUI.Fonts.label() border = JBUI.Borders.empty(4, 0) alignmentX = LEFT_ALIGNMENT } - inner.add(label, inner.componentCount - 1) - revalidate() - repaint() + inner.add(err, inner.componentCount - 1) + refresh() } - /** - * Show or hide the working status indicator at the bottom of the list. - * Pass null to hide, a string to show with animated spinner. - */ - fun setStatus(text: String?) { + private fun onStatus(text: String?) { if (text != null) { - statusLabel.text = text - status.isVisible = true + label.text = text + spinner.isVisible = true } else { - status.isVisible = false + spinner.isVisible = false } - revalidate() - repaint() + refresh() } - fun clear() { - panels.clear() + private fun onHistory() { + clear() + for (entry in model.entries()) { + val block = MessageBlock(entry.info) + blocks[entry.info.id] = block + inner.add(block, inner.componentCount - 1) + for ((partId, text) in entry.parts) { + if (text.isNotEmpty()) { + block.setText(partId, text.toString()) + } + } + } + refresh() + } + + private fun onCleared() { + clear() + refresh() + } + + private fun clear() { + blocks.clear() inner.removeAll() - // Re-add status row (always last) - inner.add(status) - status.isVisible = false + inner.add(spinner) + spinner.isVisible = false + } + + private fun refresh() { revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index fc543dd03db..8304c1a1a7c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -1,8 +1,8 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionEvent -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelListener +import ai.kilocode.client.session.model.SessionManager +import ai.kilocode.client.session.model.SessionManagerListener import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -29,7 +29,7 @@ import javax.swing.SwingConstants /** * Welcome panel showing app + workspace initialization progress. * - * Pure view — listens to [SessionModel] events and reads + * Pure view — listens to [SessionManager] events and reads * [SessionState][ai.kilocode.client.session.model.SessionState] for data. * No coroutines, no service references. * @@ -39,8 +39,8 @@ import javax.swing.SwingConstants */ class StatusPanel( parent: Disposable, - private val model: SessionModel, -) : JPanel(GridBagLayout()), SessionModelListener, Disposable { + private val model: SessionManager, +) : JPanel(GridBagLayout()), SessionManagerListener, Disposable { init { Disposer.register(parent, this) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index d22150f9040..8909ff584ab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto -class AppWatchingTest : SessionModelTestBase() { +class AppWatchingTest : SessionManagerTestBase() { fun `test app state change fires AppChanged`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index 370aad40e41..d023ae891cc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -class ConfigSelectionTest : SessionModelTestBase() { +class ConfigSelectionTest : SessionManagerTestBase() { fun `test selectModel updates ChatModel and calls updateConfig`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index 84a9b277996..a94e266372c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.MessageWithPartsDto -class HistoryLoadingTest : SessionModelTestBase() { +class HistoryLoadingTest : SessionManagerTestBase() { fun `test existing session loads history on init`() { val m = msg("msg1", "ses_test", "user") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index 88187e7ffc3..e4232d73876 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer -class ListenerLifecycleTest : SessionModelTestBase() { +class ListenerLifecycleTest : SessionManagerTestBase() { fun `test listener removed on parent dispose`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt new file mode 100644 index 00000000000..d4bc87a154f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt @@ -0,0 +1,273 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.UsefulTestCase + +class MessageListModelTest : UsefulTestCase() { + + private lateinit var model: MessageListModel + private lateinit var parent: Disposable + private lateinit var events: MutableList + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = MessageListModel() + events = mutableListOf() + model.addListener(parent) { events.add(it) } + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + // --- addMessage --- + + fun `test addMessage stores entry and fires MessageAdded`() { + val info = msg("m1", "user") + model.addMessage(info) + + assertNotNull(model.entry("m1")) + assertEquals(1, events.size) + val event = events[0] as MessageModelEvent.MessageAdded + assertEquals("m1", event.info.id) + } + + fun `test addMessage duplicate is ignored`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.addMessage(msg("m1", "user")) + + assertEquals(1, model.entries().size) + assertTrue(events.isEmpty()) + } + + // --- removeMessage --- + + fun `test removeMessage removes entry and fires MessageRemoved`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.removeMessage("m1") + + assertNull(model.entry("m1")) + assertEquals(1, events.size) + assertTrue(events[0] is MessageModelEvent.MessageRemoved) + assertEquals("m1", (events[0] as MessageModelEvent.MessageRemoved).id) + } + + fun `test removeMessage unknown id is noop`() { + model.removeMessage("unknown") + assertTrue(events.isEmpty()) + } + + // --- setPartText --- + + fun `test setPartText creates part and fires PartText`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.setPartText("m1", "p1", "hello") + + val entry = model.entry("m1")!! + assertEquals("hello", entry.parts["p1"].toString()) + assertEquals(1, events.size) + val event = events[0] as MessageModelEvent.PartText + assertEquals("m1", event.messageId) + assertEquals("p1", event.partId) + assertEquals("hello", event.text) + } + + fun `test setPartText replaces existing text`() { + model.addMessage(msg("m1", "assistant")) + model.setPartText("m1", "p1", "old") + events.clear() + + model.setPartText("m1", "p1", "new") + + assertEquals("new", model.entry("m1")!!.parts["p1"].toString()) + assertEquals(1, events.size) + } + + fun `test setPartText unknown message is noop`() { + model.setPartText("unknown", "p1", "text") + assertTrue(events.isEmpty()) + } + + // --- appendDelta --- + + fun `test appendDelta appends to existing part and fires PartDelta`() { + model.addMessage(msg("m1", "assistant")) + model.setPartText("m1", "p1", "hello ") + events.clear() + + model.appendDelta("m1", "p1", "world") + + assertEquals("hello world", model.entry("m1")!!.parts["p1"].toString()) + assertEquals(1, events.size) + val event = events[0] as MessageModelEvent.PartDelta + assertEquals("m1", event.messageId) + assertEquals("p1", event.partId) + assertEquals("world", event.delta) + } + + fun `test appendDelta creates part if missing`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.appendDelta("m1", "p1", "first") + + assertEquals("first", model.entry("m1")!!.parts["p1"].toString()) + assertEquals(1, events.size) + assertTrue(events[0] is MessageModelEvent.PartDelta) + } + + fun `test appendDelta unknown message is noop`() { + model.appendDelta("unknown", "p1", "delta") + assertTrue(events.isEmpty()) + } + + // --- addError --- + + fun `test addError stores error and fires Error`() { + model.addError("something broke") + + assertEquals(listOf("something broke"), model.errors) + assertEquals(1, events.size) + val event = events[0] as MessageModelEvent.Error + assertEquals("something broke", event.message) + } + + fun `test addError accumulates errors`() { + model.addError("first") + model.addError("second") + + assertEquals(listOf("first", "second"), model.errors) + assertEquals(2, events.size) + } + + // --- setStatus --- + + fun `test setStatus stores status and fires StatusChanged`() { + model.setStatus("thinking") + + assertEquals("thinking", model.status) + assertEquals(1, events.size) + val event = events[0] as MessageModelEvent.StatusChanged + assertEquals("thinking", event.text) + } + + fun `test setStatus null hides status`() { + model.setStatus("working") + events.clear() + + model.setStatus(null) + + assertNull(model.status) + assertEquals(1, events.size) + assertNull((events[0] as MessageModelEvent.StatusChanged).text) + } + + // --- clear --- + + fun `test clear removes entries errors status and fires Cleared`() { + model.addMessage(msg("m1", "user")) + model.addError("oops") + model.setStatus("busy") + events.clear() + + model.clear() + + assertTrue(model.isEmpty()) + assertTrue(model.errors.isEmpty()) + assertNull(model.status) + assertEquals(1, events.size) + assertTrue(events[0] is MessageModelEvent.Cleared) + } + + // --- loadHistory --- + + fun `test loadHistory populates entries and fires HistoryLoaded`() { + model.addMessage(msg("old", "user")) + events.clear() + + val part = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "hello") + val history = listOf( + MessageData( + msg("m1", "assistant"), + linkedMapOf("p1" to PartData(part, StringBuilder("hello"))), + ), + ) + + model.loadHistory(history) + + assertNull(model.entry("old")) + assertNotNull(model.entry("m1")) + assertEquals("hello", model.entry("m1")!!.parts["p1"].toString()) + assertEquals(1, events.size) + assertTrue(events[0] is MessageModelEvent.HistoryLoaded) + } + + fun `test loadHistory skips non-text parts`() { + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") + val tool = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "tool", text = "hidden") + val history = listOf( + MessageData( + msg("m1", "assistant"), + linkedMapOf( + "p1" to PartData(text, StringBuilder("visible")), + "p2" to PartData(tool, StringBuilder("hidden")), + ), + ), + ) + + model.loadHistory(history) + + val entry = model.entry("m1")!! + assertTrue(entry.parts.containsKey("p1")) + assertFalse(entry.parts.containsKey("p2")) + } + + // --- Listener lifecycle --- + + fun `test listener auto-removed on dispose`() { + val child = Disposer.newDisposable("child") + Disposer.register(parent, child) + + val extra = mutableListOf() + model.addListener(child) { extra.add(it) } + + model.addMessage(msg("m1", "user")) + assertEquals(1, extra.size) + + Disposer.dispose(child) + extra.clear() + + model.addMessage(msg("m2", "user")) + assertTrue(extra.isEmpty()) + } + + // --- Helpers --- + + private fun msg(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun linkedMapOf(vararg pairs: Pair): LinkedHashMap { + val map = LinkedHashMap() + for ((k, v) in pairs) map[k] = v + return map + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index d155dc83f32..850cea19c40 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.ChatEventDto -class MessageListTest : SessionModelTestBase() { +class MessageListTest : SessionManagerTestBase() { fun `test MessageUpdated adds message to ChatModel`() { val (m, events) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt index d1a02f01798..857b5608416 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -class SessionCreationTest : SessionModelTestBase() { +class SessionCreationTest : SessionManagerTestBase() { fun `test prompt creates session on first call`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt index d7b4ea1b2b3..a57e0908cd1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt @@ -30,12 +30,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionModel] tests. + * Base class for [SessionManager] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. */ -abstract class SessionModelTestBase : BasePlatformTestCase() { +abstract class SessionManagerTestBase : BasePlatformTestCase() { protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi @@ -76,12 +76,12 @@ abstract class SessionModelTestBase : BasePlatformTestCase() { // ------ Model creation ------ protected fun model(id: String? = null) = - SessionModel(parent, id, sessions, workspace, app, scope) + SessionManager(parent, id, sessions, workspace, app, scope) // ------ Event collection ------ /** Attach a listener that collects events and asserts EDT. */ - protected fun collect(m: SessionModel): MutableList { + protected fun collect(m: SessionManager): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("listener") Disposer.register(parent, disposable) @@ -112,7 +112,7 @@ abstract class SessionModelTestBase : BasePlatformTestCase() { } /** Create a model, attach listener, send initial prompt, and flush. */ - protected fun prompted(): Pair> { + protected fun prompted(): Pair> { val m = model() val events = collect(m) edt { m.prompt("go") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt index 08d5845348b..832911f5bfe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.ChatEventDto -class StatusComputationTest : SessionModelTestBase() { +class StatusComputationTest : SessionManagerTestBase() { fun `test status shows tool-specific text`() { val (_, events) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt index dff16b01c58..965a20c40f1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -4,7 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto -class TurnLifecycleTest : SessionModelTestBase() { +class TurnLifecycleTest : SessionManagerTestBase() { fun `test TurnOpen fires BusyChanged true`() { val (_, events) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt index 996140dec5f..cb45256231e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -class ViewSwitchingTest : SessionModelTestBase() { +class ViewSwitchingTest : SessionManagerTestBase() { fun `test first prompt shows messages view`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt index afb1f66c54f..f142abb1ba2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -class WorkspaceWatchingTest : SessionModelTestBase() { +class WorkspaceWatchingTest : SessionManagerTestBase() { fun `test workspace ready populates agents and models`() { val m = model() From 14e1084cf20f1a3adff7114a130339173c9b2fb3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Apr 2026 18:16:20 -0400 Subject: [PATCH 03/27] fix(jetbrains): deduplicate consecutive identical error messages in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated identical errors from the CLI no longer pile up as separate labels — only the first occurrence renders until a different error appears. --- .../client/session/model/MessageListModel.kt | 1 + .../session/model/MessageListModelTest.kt | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt index 891eefd9947..182375a1d09 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt @@ -71,6 +71,7 @@ class MessageListModel { } fun addError(message: String) { + if (_errors.lastOrNull() == message) return _errors.add(message) fire(MessageModelEvent.Error(message)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt index d4bc87a154f..83c226b304a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt @@ -147,7 +147,7 @@ class MessageListModelTest : UsefulTestCase() { assertEquals("something broke", event.message) } - fun `test addError accumulates errors`() { + fun `test addError accumulates distinct errors`() { model.addError("first") model.addError("second") @@ -155,6 +155,24 @@ class MessageListModelTest : UsefulTestCase() { assertEquals(2, events.size) } + fun `test addError deduplicates consecutive identical errors`() { + model.addError("same") + model.addError("same") + model.addError("same") + + assertEquals(listOf("same"), model.errors) + assertEquals(1, events.size) + } + + fun `test addError allows same error after different one`() { + model.addError("a") + model.addError("b") + model.addError("a") + + assertEquals(listOf("a", "b", "a"), model.errors) + assertEquals(3, events.size) + } + // --- setStatus --- fun `test setStatus stores status and fires StatusChanged`() { From 0ba7b74c69f8950a5133a7072a48951f64de6ab4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 13:56:30 -0400 Subject: [PATCH 04/27] feat(jetbrains): add MdView markdown rendering component Add a standalone markdown rendering component for the JetBrains plugin using commonmark-java. This will be used in the chat UI to render assistant messages as formatted markdown instead of plain text. - Abstract MdView class with factory method and private HTML implementation - Configurable styling (font, colors, opacity/transparency) - Link click listeners via inner LinkListener interface - Streaming-friendly set/append/clear API - 44 unit tests covering rendering, styling, links, and transparency --- .../kilo-jetbrains/frontend/build.gradle.kts | 5 + .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 299 +++++++++++++++ .../ai/kilocode/client/ui/md/MdViewTest.kt | 354 ++++++++++++++++++ .../kilo-jetbrains/gradle/libs.versions.toml | 5 + 4 files changed, 663 insertions(+) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index d2408e8549e..735d87eff95 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(project(":shared")) + implementation(libs.commonmark) + implementation(libs.commonmark.autolink) + implementation(libs.commonmark.tables) + implementation(libs.commonmark.strikethrough) + testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt new file mode 100644 index 00000000000..715662a6fd2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -0,0 +1,299 @@ +package ai.kilocode.client.ui.md + +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import java.awt.Point +import javax.swing.JComponent +import javax.swing.JEditorPane +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.HTMLEditorKit + +/** + * Markdown rendering component that hides the concrete rendering strategy + * (HTML, StyledDocument, etc.) behind a uniform API. + * + * Callers interact only with markdown source text and semantic events — + * never with underlying widget types. + * + * Create instances via [MdView.html]. + * All public methods must be called on the EDT. + */ +abstract class MdView private constructor() { + + /** The Swing component to embed in layouts. */ + abstract val component: JComponent + + /** Replace the entire content with new markdown source. */ + abstract fun set(text: String) + + /** Append a streaming delta to the current content. */ + abstract fun append(delta: String) + + /** Reset to empty. */ + abstract fun clear() + + abstract fun addLinkListener(listener: LinkListener) + + abstract fun removeLinkListener(listener: LinkListener) + + // -- styling ---------------------------------------------------------- + + /** Body text font family and size. */ + abstract var font: Font + + /** Body text foreground color. */ + abstract var foreground: Color + + /** Overall background color. */ + abstract var background: Color + + /** Link foreground color. */ + abstract var linkColor: Color + + /** Inline code background color. */ + abstract var codeBg: Color + + /** Code block (pre) background color. */ + abstract var preBg: Color + + /** Code block (pre) foreground color. */ + abstract var preFg: Color + + /** Code / pre font family. */ + abstract var codeFont: String + + /** Blockquote left-border and text color. */ + abstract var quoteBorder: Color + abstract var quoteFg: Color + + /** Table / th / td border color. */ + abstract var tableBorder: Color + + /** + * Whether the component paints its own background. + * When `false`, the body background CSS is omitted and the underlying + * Swing component is set to non-opaque so the parent's background + * shows through. + */ + abstract var opaque: Boolean + + /** + * Semantic event fired when a user clicks a link inside rendered markdown. + */ + data class LinkEvent( + val href: String, + val point: Point? = null, + ) + + /** + * Callback for link activations inside a [MdView]. + */ + fun interface LinkListener { + fun onLink(event: LinkEvent) + } + + // -- test helpers (package-visible) ------------------------------------ + + /** Returns the current raw markdown source. */ + internal abstract fun markdown(): String + + /** Returns the rendered HTML body (not Swing-rewritten). */ + internal abstract fun html(): String + + /** Returns the full wrapped HTML including CSS. */ + internal abstract fun styledHtml(): String + + /** Fire a synthetic link activation for testing. */ + internal abstract fun simulateLink(href: String) + + companion object { + /** Create a [MdView] backed by commonmark-java → HTML → JEditorPane. */ + fun html(): MdView = HtmlImpl() + } + + // -- private HTML implementation --------------------------------------- + + private class HtmlImpl : MdView() { + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + private var wrapped = "" + private var dirty = false + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder() + .extensions(extensions) + .build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .build() + + private val pane: JEditorPane = JEditorPane().apply { + isEditable = false + contentType = "text/html" + editorKit = HTMLEditorKit() + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + border = JBUI.Borders.empty() + + addHyperlinkListener { e -> + if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { + val href = e.description ?: return@addHyperlinkListener + val pt = e.inputEvent?.let { Point(it.component.x, it.component.y) } + val event = LinkEvent(href, pt) + for (l in listeners) { + l.onLink(event) + } + } + } + } + + // -- style fields with defaults ----------------------------------- + + override var font: Font = JBUI.Fonts.label() + set(value) { field = value; markDirty() } + + override var foreground: Color = UIUtil.getLabelForeground() + set(value) { field = value; pane.foreground = value; markDirty() } + + override var background: Color = UIUtil.getPanelBackground() + set(value) { field = value; pane.background = value; markDirty() } + + override var linkColor: Color = Color(0x58, 0x9D, 0xF6) + set(value) { field = value; markDirty() } + + override var codeBg: Color = Color(0x3C, 0x3F, 0x41) + set(value) { field = value; markDirty() } + + override var preBg: Color = Color(0x2B, 0x2B, 0x2B) + set(value) { field = value; markDirty() } + + override var preFg: Color = Color(0xA9, 0xB7, 0xC6) + set(value) { field = value; markDirty() } + + override var codeFont: String = "JetBrains Mono" + set(value) { field = value; markDirty() } + + override var quoteBorder: Color = Color(0x55, 0x55, 0x55) + set(value) { field = value; markDirty() } + + override var quoteFg: Color = Color(0x99, 0x99, 0x99) + set(value) { field = value; markDirty() } + + override var tableBorder: Color = Color(0x55, 0x55, 0x55) + set(value) { field = value; markDirty() } + + override var opaque: Boolean = true + set(value) { + field = value + pane.isOpaque = value + markDirty() + } + + init { + pane.font = font + pane.foreground = foreground + pane.background = background + } + + override val component: JComponent get() = pane + + override fun set(text: String) { + source.clear() + source.append(text) + render() + } + + override fun append(delta: String) { + source.append(delta) + render() + } + + override fun clear() { + source.clear() + rendered = "" + wrapped = "" + pane.text = "" + } + + override fun addLinkListener(listener: LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun styledHtml(): String = wrapped + + override fun simulateLink(href: String) { + val event = LinkEvent(href) + for (l in listeners) { + l.onLink(event) + } + } + + private fun markDirty() { + dirty = true + if (source.isNotEmpty()) render() + } + + private fun render() { + dirty = false + val md = source.toString() + val body = renderer.render(parser.parse(md)) + rendered = body + wrapped = wrap(body) + pane.text = wrapped + pane.caretPosition = 0 + } + + private fun wrap(body: String): String { + val fg = hex(foreground) + val size = font.size + val family = font.family + val codeSize = (size - 1).coerceAtLeast(1) + val bgCss = if (opaque) "background: ${hex(background)}; " else "" + return """ + + + $body + + """.trimIndent() + } + + companion object { + private fun hex(c: Color): String = + String.format("#%02x%02x%02x", c.red, c.green, c.blue) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt new file mode 100644 index 00000000000..675b04168af --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -0,0 +1,354 @@ +package ai.kilocode.client.ui.md + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font + +/** + * Tests for [MdView] created via [MdView.html] factory. + * + * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that + * Swing/HTMLEditorKit initialisation works correctly. + */ +class MdViewTest : BasePlatformTestCase() { + + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdView.html() + } + + // ---- set ---- + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test set replaces previous content`() { + view.set("first") + view.set("second") + assertEquals("second", view.markdown()) + } + + fun `test set renders bold`() { + view.set("hello **world**") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue("Expected 'world' inside strong", html.contains("world")) + } + + fun `test set renders italic`() { + view.set("hello *world*") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders inline code`() { + view.set("use `foo()` here") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue("Expected foo() in code", html.contains("foo()")) + } + + fun `test set renders fenced code block`() { + view.set("```kotlin\nval x = 1\n```") + val html = view.html() + assertTrue("Expected
 in rendered HTML", html.contains("
"))
+        assertTrue("Expected  in rendered HTML", html.contains("

")) + } + + fun `test set renders unordered list`() { + view.set("- one\n- two\n- three") + val html = view.html() + assertTrue("Expected
    in rendered HTML", html.contains("
      ")) + assertTrue("Expected
    • in rendered HTML", html.contains("
    • ")) + } + + fun `test set renders ordered list`() { + view.set("1. one\n2. two\n3. three") + val html = view.html() + assertTrue("Expected
        in rendered HTML", html.contains("
          ")) + } + + fun `test set renders blockquote`() { + view.set("> quoted text") + val html = view.html() + assertTrue("Expected
          in rendered HTML", html.contains("
          ")) + } + + fun `test set renders strikethrough`() { + view.set("~~deleted~~") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders table`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("
          ")) + assertTrue("Expected
          in rendered HTML", html.contains("")) + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders autolink`() { + view.set("Visit https://example.com for details") + val html = view.html() + assertTrue("Expected autolinked URL", html.contains(" after append", html.contains("")) + } + + fun `test append after set extends content`() { + view.set("first ") + view.append("second") + assertEquals("first second", view.markdown()) + } + + // ---- clear ---- + + fun `test clear resets source`() { + view.set("some content") + view.clear() + assertEquals("", view.markdown()) + } + + fun `test clear resets rendered html`() { + view.set("some content") + view.clear() + val html = view.html() + assertFalse("Expected no 'some content' after clear", html.contains("some content")) + } + + // ---- link listener ---- + + fun `test link listener receives event on activation`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("[click](https://example.com)") + view.simulateLink("https://example.com") + assertEquals(1, received.size) + assertEquals("https://example.com", received[0].href) + } + + fun `test multiple link listeners all receive event`() { + val first = mutableListOf() + val second = mutableListOf() + view.addLinkListener { first.add(it) } + view.addLinkListener { second.add(it) } + view.simulateLink("https://a.com") + assertEquals(1, first.size) + assertEquals(1, second.size) + } + + fun `test removed listener does not receive events`() { + val received = mutableListOf() + val listener = MdView.LinkListener { received.add(it) } + view.addLinkListener(listener) + view.removeLinkListener(listener) + view.simulateLink("https://example.com") + assertTrue("Removed listener should not fire", received.isEmpty()) + } + + // ---- component ---- + + fun `test component is not null`() { + assertNotNull(view.component) + } + + // ---- complex markdown ---- + + fun `test complex markdown with mixed elements`() { + val md = """ + # Heading + + Some **bold** and *italic* text with `code`. + + - item one + - item two + + ``` + code block + ``` + + > blockquote + + [link](https://example.com) + """.trimIndent() + + view.set(md) + val html = view.html() + assertTrue(html.contains("

          ")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("
            ")) + assertTrue(html.contains("
            "))
            +        assertTrue(html.contains("
            ")) + assertTrue(html.contains("")) + assertTrue(html.contains("Done.")) + } + + // ---- styling ---- + + fun `test foreground color appears in CSS`() { + view.foreground = Color(0xAA, 0xBB, 0xCC) + view.set("text") + assertTrue(view.styledHtml().contains("#aabbcc")) + } + + fun `test background color appears in CSS`() { + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertTrue(view.styledHtml().contains("#112233")) + } + + fun `test link color appears in CSS`() { + view.linkColor = Color(0xFF, 0x00, 0x77) + view.set("[a](https://x.com)") + assertTrue(view.styledHtml().contains("#ff0077")) + } + + fun `test code background appears in CSS`() { + view.codeBg = Color(0x10, 0x20, 0x30) + view.set("`code`") + assertTrue(view.styledHtml().contains("#102030")) + } + + fun `test pre background and foreground appear in CSS`() { + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.preFg = Color(0xD0, 0xE0, 0xF0) + view.set("```\ncode\n```") + val css = view.styledHtml() + assertTrue(css.contains("#0a0b0c")) + assertTrue(css.contains("#d0e0f0")) + } + + fun `test code font family appears in CSS`() { + view.codeFont = "Fira Code" + view.set("`x`") + assertTrue(view.styledHtml().contains("Fira Code")) + } + + fun `test blockquote colors appear in CSS`() { + view.quoteBorder = Color(0xAA, 0x00, 0x00) + view.quoteFg = Color(0x00, 0xBB, 0x00) + view.set("> quote") + val css = view.styledHtml() + assertTrue(css.contains("#aa0000")) + assertTrue(css.contains("#00bb00")) + } + + fun `test table border color appears in CSS`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + view.set("| a |\n|---|\n| 1 |") + assertTrue(view.styledHtml().contains("#123456")) + } + + fun `test font family appears in CSS`() { + view.font = Font("Courier New", Font.PLAIN, 14) + view.set("text") + assertTrue(view.styledHtml().contains("Courier New")) + } + + fun `test font size appears in CSS`() { + view.font = Font("Arial", Font.PLAIN, 18) + view.set("text") + assertTrue(view.styledHtml().contains("18pt")) + } + + fun `test style change re-renders existing content`() { + view.set("hello") + view.foreground = Color(0xDE, 0xAD, 0x00) + assertTrue(view.styledHtml().contains("#dead00")) + assertTrue(view.styledHtml().contains("hello")) + } + + fun `test style change without content does not crash`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeFont = "Monospaced" + // no content set — should not throw + assertEquals("", view.markdown()) + } + + // ---- opaque / transparent ---- + + fun `test opaque true includes background in CSS`() { + view.opaque = true + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertTrue(view.styledHtml().contains("background: #112233")) + } + + fun `test opaque false omits background from body CSS`() { + view.opaque = false + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertFalse(view.styledHtml().contains("background: #112233")) + } + + fun `test opaque false does not affect pre background`() { + view.opaque = false + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.set("```\ncode\n```") + assertTrue(view.styledHtml().contains("#0a0b0c")) + } + + fun `test opaque toggle re-renders`() { + view.background = Color(0xFE, 0xFE, 0xFE) + view.set("hello") + view.opaque = false + assertFalse(view.styledHtml().contains("background: #fefefe")) + view.opaque = true + assertTrue(view.styledHtml().contains("background: #fefefe")) + } + + fun `test component is not opaque when opaque is false`() { + view.opaque = false + assertFalse(view.component.isOpaque) + } + + fun `test component is opaque when opaque is true`() { + view.opaque = true + assertTrue(view.component.isOpaque) + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 9d81facbccf..fd3098941a7 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -8,8 +8,13 @@ kotlin-serialization = "1.11.0" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" +commonmark = "0.28.0" [libraries] +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } 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" } From b5aa402355d9dac4a7567eb8cc558de0eb2b3d1a Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 14:18:08 -0400 Subject: [PATCH 05/27] fix(jetbrains): sanitize HTML output and fix link click point - Enable escapeHtml and sanitizeUrls on the commonmark HtmlRenderer to prevent raw HTML injection and dangerous URL schemes - Fix LinkEvent.point to use MouseEvent.point instead of the component's origin coordinates --- .../src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 715662a6fd2..63ef31a20cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -140,6 +140,8 @@ abstract class MdView private constructor() { private val renderer: HtmlRenderer = HtmlRenderer.builder() .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) .build() private val pane: JEditorPane = JEditorPane().apply { @@ -152,7 +154,7 @@ abstract class MdView private constructor() { addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { val href = e.description ?: return@addHyperlinkListener - val pt = e.inputEvent?.let { Point(it.component.x, it.component.y) } + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point val event = LinkEvent(href, pt) for (l in listeners) { l.onLink(event) From a385e6a5fdbba2d651c97da3b3619fb09cadb866 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 14:20:42 -0400 Subject: [PATCH 06/27] chore(jetbrains): remove redundant comments from MdView --- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 64 +------------------ 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 63ef31a20cc..5b3d6b20f35 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -17,109 +17,55 @@ import javax.swing.text.html.HTMLEditorKit /** * Markdown rendering component that hides the concrete rendering strategy - * (HTML, StyledDocument, etc.) behind a uniform API. + * behind a uniform API. Create instances via [MdView.html]. * - * Callers interact only with markdown source text and semantic events — - * never with underlying widget types. - * - * Create instances via [MdView.html]. * All public methods must be called on the EDT. */ abstract class MdView private constructor() { - /** The Swing component to embed in layouts. */ abstract val component: JComponent - - /** Replace the entire content with new markdown source. */ abstract fun set(text: String) - - /** Append a streaming delta to the current content. */ abstract fun append(delta: String) - - /** Reset to empty. */ abstract fun clear() - abstract fun addLinkListener(listener: LinkListener) - abstract fun removeLinkListener(listener: LinkListener) - // -- styling ---------------------------------------------------------- - - /** Body text font family and size. */ abstract var font: Font - - /** Body text foreground color. */ abstract var foreground: Color - - /** Overall background color. */ abstract var background: Color - - /** Link foreground color. */ abstract var linkColor: Color - - /** Inline code background color. */ abstract var codeBg: Color - - /** Code block (pre) background color. */ abstract var preBg: Color - - /** Code block (pre) foreground color. */ abstract var preFg: Color - - /** Code / pre font family. */ abstract var codeFont: String - - /** Blockquote left-border and text color. */ abstract var quoteBorder: Color abstract var quoteFg: Color - - /** Table / th / td border color. */ abstract var tableBorder: Color /** - * Whether the component paints its own background. - * When `false`, the body background CSS is omitted and the underlying - * Swing component is set to non-opaque so the parent's background - * shows through. + * When `false`, body background CSS is omitted and the Swing component + * is set to non-opaque so the parent's background shows through. */ abstract var opaque: Boolean - /** - * Semantic event fired when a user clicks a link inside rendered markdown. - */ data class LinkEvent( val href: String, val point: Point? = null, ) - /** - * Callback for link activations inside a [MdView]. - */ fun interface LinkListener { fun onLink(event: LinkEvent) } - // -- test helpers (package-visible) ------------------------------------ - - /** Returns the current raw markdown source. */ internal abstract fun markdown(): String - - /** Returns the rendered HTML body (not Swing-rewritten). */ internal abstract fun html(): String - - /** Returns the full wrapped HTML including CSS. */ internal abstract fun styledHtml(): String - - /** Fire a synthetic link activation for testing. */ internal abstract fun simulateLink(href: String) companion object { - /** Create a [MdView] backed by commonmark-java → HTML → JEditorPane. */ fun html(): MdView = HtmlImpl() } - // -- private HTML implementation --------------------------------------- - private class HtmlImpl : MdView() { private val listeners = mutableListOf() @@ -163,8 +109,6 @@ abstract class MdView private constructor() { } } - // -- style fields with defaults ----------------------------------- - override var font: Font = JBUI.Fonts.label() set(value) { field = value; markDirty() } @@ -240,9 +184,7 @@ abstract class MdView private constructor() { } override fun markdown(): String = source.toString() - override fun html(): String = rendered - override fun styledHtml(): String = wrapped override fun simulateLink(href: String) { From b7850a0aa2eea6513cbd652eb1af7c459cc5c8e6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 15:23:43 -0400 Subject: [PATCH 07/27] refactor(jetbrains): unify session state into single SessionPhase model Collapse three independent state fields (phase, prompt, status) into a single SessionPhase sealed class with 6 mutually exclusive variants: Idle, Working, Prompting, Retry, Offline, Error. Reorganize model types into subpackages (message, question, permission), rename SessionEvent to SessionManagerEvent keeping only lifecycle events, and eliminate the duplicate SessionModel in SessionUi. Wire up permission/question/status SSE events in the backend parser and add corresponding ChatEventDto variants. --- .kilo/plans/1776368757895-gentle-pixel.md | 684 ++++++++++++++++++ .kilo/plans/1776447841515-misty-eagle.md | 276 +++++++ .../kilocode/backend/cli/KiloCliDataParser.kt | 84 +++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 6 + .../ai/kilocode/client/session/SessionUi.kt | 131 +--- .../client/session/model/MessageListModel.kt | 115 --- .../client/session/model/MessageModel.kt | 8 - .../client/session/model/MessageModelEvent.kt | 28 - .../client/session/model/PromptState.kt | 18 + .../client/session/model/SessionEvent.kt | 45 -- .../client/session/model/SessionManager.kt | 220 +++--- .../session/model/SessionManagerEvent.kt | 27 + .../client/session/model/SessionModel.kt | 183 +++++ .../client/session/model/SessionModelEvent.kt | 25 + .../client/session/model/SessionPhase.kt | 26 + .../client/session/model/SessionState.kt | 121 ---- .../client/session/model/StatusState.kt | 7 + .../client/session/model/ToolCallRef.kt | 6 + .../client/session/model/message/Content.kt | 25 + .../client/session/model/message/Message.kt | 34 + .../session/model/permission/Permission.kt | 36 + .../client/session/model/question/Question.kt | 25 + .../client/session/ui/MessageListUi.kt | 180 +++-- .../kilocode/client/session/ui/StatusPanel.kt | 10 +- .../client/session/model/AppWatchingTest.kt | 2 +- .../session/model/ConfigSelectionTest.kt | 2 +- .../session/model/HistoryLoadingTest.kt | 15 +- .../session/model/ListenerLifecycleTest.kt | 19 +- .../session/model/MessageListModelTest.kt | 291 -------- .../client/session/model/MessageListTest.kt | 22 +- .../session/model/SessionCreationTest.kt | 2 +- .../session/model/SessionManagerTestBase.kt | 24 +- .../client/session/model/SessionModelTest.kt | 397 ++++++++++ .../session/model/StatusComputationTest.kt | 21 +- .../client/session/model/TurnLifecycleTest.kt | 35 +- .../client/session/model/ViewSwitchingTest.kt | 4 +- .../session/model/WorkspaceWatchingTest.kt | 2 +- .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 86 +++ 38 files changed, 2303 insertions(+), 939 deletions(-) create mode 100644 .kilo/plans/1776368757895-gentle-pixel.md create mode 100644 .kilo/plans/1776447841515-misty-eagle.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt diff --git a/.kilo/plans/1776368757895-gentle-pixel.md b/.kilo/plans/1776368757895-gentle-pixel.md new file mode 100644 index 00000000000..166d0f453ff --- /dev/null +++ b/.kilo/plans/1776368757895-gentle-pixel.md @@ -0,0 +1,684 @@ +# Unified SessionModel Architecture + +## Goal + +Restructure the JetBrains chat frontend model layer so there is a **single source of truth** called `SessionModel`. + +`SessionModel` should own: + +- all message content with typed per-part models +- session runtime state (`idle` / `busy`) +- tool execution state (`pending` / `running` / `completed` / `error`) +- prompt-blocking interactive state through a single `PromptState?` +- permission request state +- question request state + +UI work is explicitly deferred until the model architecture is in place. + +--- + +## Design Decision + +Use a **single `PromptState?`** with sealed variants instead of separate `question` and `permission` fields. + +This keeps the state machine explicit and prevents the model from representing impossible UI states such as “active question and active permission at the same time” unless we intentionally expand the architecture later. + +Use **typed state objects and enums** instead of loose booleans and nullable strings. + +--- + +## Current Problems + +### Split responsibility + +- `SessionManager` currently owns runtime/controller state: `busy`, `partType`, `tool`, subscriptions, prompt sending, history loading +- `SessionState` owns raw chat history and workspace/app config state, which creates an unnecessary extra entity +- `MessageListModel` owns a second copy of render-focused message state + +This creates two competing models: + +1. `SessionState` as session truth for messages +2. `MessageListModel` as UI truth for messages, status, questions + +### Naming mismatch + +- `SessionManager` is actually doing controller + derived state work +- `MessageListModel` is closer to the model you want to become `SessionModel` +- `SessionState` duplicates responsibilities that should belong to `SessionModel` + +### Untyped prompt/request state + +- questions were added as `question: QuestionState?` +- permissions do not exist yet +- status is still modeled as `status: String?` +- runtime turn state is only `TurnState`, but prompt-blocking flow is not unified + +--- + +## Target Architecture + +### High-level split + +Keep **orchestration** and **model** separate while preserving the current `SessionManager` name: + +- `SessionManager` + - remains the orchestration class + - owns RPC subscriptions/actions + - converts CLI/RPC DTOs into `SessionModel` mutations + - contains no authoritative session state except coroutine/job wiring + +- `SessionModel` (rename from current `MessageListModel`) + - owns the full frontend session state used by the chat UI + - publishes `SessionModelEvent` + - becomes the only model the future UI should render from + - directly handles message add/remove/update, delta streaming, history loading, phase changes, prompt state, permissions, questions, errors, and status + +There is **no `SessionState` / `SessionCache` / extra backing entity** in the target architecture. + +The key point: after the refactor, `SessionManager` mutates `SessionModel` directly and the UI reads only `SessionModel`. + +--- + +## Core Model Shape + +### `SessionModel` (renamed from `MessageListModel`) + +```kotlin +class SessionModel { + val messages: Collection + + var phase: SessionPhase + private set + + var prompt: PromptState? + private set + + var status: StatusState? + private set + + val errors: List + + fun addMessage(info: MessageInfo) + fun removeMessage(id: String) + fun updateContent(messageId: String, dto: PartDto) + fun appendDelta(messageId: String, partId: String, delta: String) + fun loadHistory(history: List) + + fun setPhase(phase: SessionPhase) + fun setStatus(state: StatusState?) + fun addError(err: ErrorState) + + fun askQuestion(state: QuestionState) + fun askPermission(state: PermissionState) + fun resolvePrompt(id: String) + + fun clear() +} +``` + +`SessionModel` directly absorbs the current responsibilities split across `MessageListModel` and `SessionState`. + +### Session-level state types + +```kotlin +enum class SessionPhase { + IDLE, + BUSY, +} + +sealed class StatusState { + data class Thinking(val text: String) : StatusState() + data class Working(val text: String) : StatusState() +} + +data class ErrorState( + val message: String, + val kind: String? = null, +) +``` + +Notes: + +- `SessionPhase` replaces ad-hoc `busy: Boolean` +- `StatusState?` replaces raw `status: String?` +- `ErrorState` replaces raw `List` and gives room for typed error handling later + +--- + +## Message Models + +### `MessageModel` + +Keep a typed message object with ordered parts. + +```kotlin +class MessageModel( + val info: MessageInfo, +) { + val parts: LinkedHashMap +} +``` + +### `MessageInfo` + +Introduce a frontend/domain message info model instead of exposing DTOs directly as the long-term model surface. + +```kotlin +data class MessageInfo( + val id: String, + val role: String, + val created: Double, + val completed: Double? = null, + val agent: String? = null, + val providerId: String? = null, + val modelId: String? = null, + val parentId: String? = null, + val cost: Double? = null, +) +``` + +This can be built from `MessageDto` by the `SessionManager`/model boundary. + +`SessionModel` owns conversion from DTO-shaped history/event data into domain message state. `SessionManager` forwards DTOs in, but does not store them. + +--- + +## Content Models + +The user asked that “everything we put into session model has its own models, such as Text, Reasoning, Question & etc”. + +To make that explicit, split the current `PartModel` and prompt-blocking models under a common naming scheme. + +### Message content inside messages + +```kotlin +sealed class ContentModel(val id: String) { + class Text(id: String) : ContentModel(id) { + val content = StringBuilder() + } + + class Reasoning(id: String) : ContentModel(id) { + val content = StringBuilder() + } + + class Tool(id: String, val name: String) : ContentModel(id) { + var state: ToolExecState = ToolExecState.PENDING + var title: String? = null + } + + class Compaction(id: String) : ContentModel(id) +} + +enum class ToolExecState { + PENDING, + RUNNING, + COMPLETED, + ERROR, +} +``` + +Notes: + +- Rename current `PartModel` to `ContentModel` +- Rename current `ToolState` to `ToolExecState` to avoid confusion with future permission/question state + +### Prompt-blocking interaction state + +```kotlin +sealed class PromptState { + abstract val id: String + + data class Question( + override val id: String, + val state: QuestionState, + ) : PromptState() + + data class Permission( + override val id: String, + val state: PermissionState, + ) : PromptState() +} +``` + +This becomes the session model’s authoritative prompt gate. + +--- + +## Question Models + +The current `QuestionState.kt` is directionally correct, but should be framed as part of the unified domain model. + +```kotlin +data class QuestionState( + val id: String, + val items: List, + val tool: ToolCallRef? = null, +) + +data class QuestionItem( + val question: String, + val header: String, + val options: List, + val multiple: Boolean, + val custom: Boolean, +) + +data class QuestionOption( + val label: String, + val description: String, +) +``` + +Notes: + +- `questions` -> `items` reads better as a nested structure +- `ToolRef` should become a shared `ToolCallRef` + +--- + +## Permission Models + +Based on the VS Code model and CLI schema, add typed permission models now even before UI work. + +### Core permission state + +```kotlin +data class PermissionState( + val id: String, + val sessionId: String, + val name: String, + val patterns: List, + val always: List, + val meta: PermissionMeta, + val message: String? = null, + val tool: ToolCallRef? = null, + val state: PermissionRequestState = PermissionRequestState.PENDING, +) + +enum class PermissionRequestState { + PENDING, + RESPONDING, + RESOLVED, + ERROR, +} +``` + +### Tool association + +```kotlin +data class ToolCallRef( + val messageId: String, + val callId: String, +) +``` + +### Permission metadata + +Start with a generic container, but typed enough to avoid `Map` as the final model surface. + +```kotlin +data class PermissionMeta( + val rules: List = emptyList(), + val diff: String? = null, + val filePath: String? = null, + val fileDiff: PermissionFileDiff? = null, + val raw: Map = emptyMap(), +) + +data class PermissionFileDiff( + val file: String, + val patch: String? = null, + val before: String? = null, + val after: String? = null, + val additions: Int, + val deletions: Int, +) +``` + +### Permission response intent + +This is useful even before UI implementation. + +```kotlin +enum class PermissionReply { + ONCE, + ALWAYS, + REJECT, +} +``` + +### Why model permission state now + +VS Code effectively tracks permissions using: + +- pending queue +- responding set +- resolution/error events + +For JetBrains, since you want a true state machine and single point of truth, encode that lifecycle directly on the model rather than split it across multiple collections. + +--- + +## Events + +Rename `MessageModelEvent` to `SessionModelEvent` once the rename happens. + +```kotlin +sealed class SessionModelEvent { + data class MessageAdded(val info: MessageInfo) : SessionModelEvent() + data class MessageRemoved(val id: String) : SessionModelEvent() + + data class ContentAdded(val messageId: String, val content: ContentModel) : SessionModelEvent() + data class ContentUpdated(val messageId: String, val content: ContentModel) : SessionModelEvent() + data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() + + data class PhaseChanged(val phase: SessionPhase) : SessionModelEvent() + data class StatusChanged(val status: StatusState?) : SessionModelEvent() + data class PromptChanged(val prompt: PromptState?) : SessionModelEvent() + data class ErrorAdded(val err: ErrorState) : SessionModelEvent() + + data object HistoryLoaded : SessionModelEvent() + data object Cleared : SessionModelEvent() +} +``` + +Key change: + +- instead of separate `QuestionAsked` / `QuestionResolved` and future `Permission...` events, use one authoritative `PromptChanged(prompt)` event because prompt state is single-source and exclusive + +This is a cleaner state-machine API. + +--- + +## Renames + +### Rename `MessageListModel` → `SessionModel` + +This is the user-requested rename, but it should come with the responsibility transfer described above. + +### Keep `SessionManager` + +Do not rename `SessionManager`. + +It remains the orchestration class: + +- `SessionManager` = orchestration / RPC / subscriptions +- `SessionModel` = state + +### Remove `SessionState` + +`SessionState` should be deleted, not renamed. + +Reason: + +- the user explicitly does not want an extra entity +- `SessionModel` should be the point of truth +- message add/remove/update/history logic should live in `SessionModel` +- runtime session state should live in `SessionModel` +- prompt state should live in `SessionModel` + +If temporary helpers are needed during the refactor, keep them as private conversion functions inside `SessionManager` or `SessionModel`, not as a long-lived separate class. + +--- + +## Migration Plan + +### Phase 1: Naming + model consolidation + +1. Rename current `MessageListModel` to `SessionModel` +2. Keep `SessionManager` name and narrow its responsibilities to orchestration only +3. Rename current `MessageModelEvent` to `SessionModelEvent` +4. Rename `PartModel` to `ContentModel` +5. Rename `ToolState` to `ToolExecState` +6. Delete `SessionState` and move its responsibilities into `SessionModel` + +This is mostly mechanical, but important before deeper changes. + +### Phase 2: Expand `SessionModel` to true source of truth + +Move the following session-facing state into `SessionModel`: + +- `phase` / busy-idle runtime state +- `status` +- `errors` +- active prompt state +- typed messages / contents +- message history loading from `MessageWithPartsDto` +- message/part lookup APIs currently served by `SessionState` + +`SessionManager` should mutate only `SessionModel`; the UI should read only `SessionModel`. + +### Phase 3: Add typed prompt models + +1. Replace `question: QuestionState?` with `prompt: PromptState?` +2. Add `PermissionState`, `PermissionMeta`, `PermissionFileDiff`, `PermissionReply`, `ToolCallRef` +3. Add mutations: + +```kotlin +fun setPrompt(state: PromptState?) +fun askQuestion(state: QuestionState) +fun askPermission(state: PermissionState) +fun resolvePrompt(id: String) +fun failPrompt(id: String) +``` + +Recommended actual internal API: + +- keep `setPrompt` private +- expose `askQuestion`, `askPermission`, `resolvePrompt`, `setPermissionState` + +### Phase 4: Remove remaining `SessionState` usage + +At that point `SessionManager` should stop making the UI read back through former cache-style lookups like: + +- `model.chat.part(...)` +- `model.chat.message(...)` + +Instead, `SessionManager` should transform DTOs directly into `SessionModel` mutations. + +Target: + +- no `SessionState` +- no `SessionCache` +- no second backing entity behind `SessionModel` + +--- + +## Permission State Machine + +Since you explicitly want pending/running/question/permission state represented in the model, this is the recommended permission lifecycle. + +### States + +```kotlin +enum class PermissionRequestState { + PENDING, + RESPONDING, + RESOLVED, + ERROR, +} +``` + +### Transitions + +```text +null + -> PENDING on permission.asked +PENDING + -> RESPONDING when user submits response +RESPONDING + -> RESOLVED on permission.replied +RESPONDING + -> ERROR if reply RPC fails +ERROR + -> RESPONDING retry +RESOLVED + -> null when prompt is dismissed from model/UI flow +``` + +Because `PromptState` is exclusive, resolved prompts can often transition directly to `null` rather than lingering in a visible resolved state. + +--- + +## Question State Machine + +```text +null + -> Question(PENDING) on question.asked +Question(PENDING) + -> Question(RESPONDING) when user submits/rejects +Question(RESPONDING) + -> null on question.replied/question.rejected +Question(RESPONDING) + -> Question(ERROR) on reply/reject RPC failure +Question(ERROR) + -> Question(RESPONDING) retry +``` + +This suggests `QuestionState` may also need a lifecycle field, not just payload. + +Recommended update: + +```kotlin +enum class QuestionRequestState { + PENDING, + RESPONDING, + ERROR, +} +``` + +Then: + +```kotlin +data class QuestionState( + val id: String, + val items: List, + val tool: ToolCallRef? = null, + val state: QuestionRequestState = QuestionRequestState.PENDING, +) +``` + +--- + +## Model-First Test Plan + +Do this before UI work. + +### `SessionModelTest.kt` (renamed from current `MessageListModelTest.kt`) + +#### Naming and shape + +- rename the test file/class to `SessionModelTest` +- keep it lightweight (`UsefulTestCase`) + +### Content/message tests + +- message add/remove still work after rename +- `updatePart` creates `ContentModel.Text` +- `updatePart` creates `ContentModel.Reasoning` +- `updatePart` creates `ContentModel.Tool` +- tool state transitions: pending -> running -> completed -> error +- compaction creation works +- unknown content type ignored +- deltas append only to text/reasoning + +### Session state tests + +- initial phase is `IDLE` +- `setPhase(BUSY)` updates state and fires event +- `setStatus(Thinking(...))` stores typed status and fires event +- clear resets phase/status/errors/messages/prompt + +### Question prompt tests + +- `askQuestion` sets `prompt` to `PromptState.Question` +- asking a second question replaces the existing prompt +- `resolvePrompt(id)` clears matching question prompt +- wrong id is noop +- question lifecycle can move `PENDING -> RESPONDING -> ERROR` + +### Permission prompt tests + +- `askPermission` sets `prompt` to `PromptState.Permission` +- permission fields (`patterns`, `always`, `tool`, `meta`) are preserved +- permission lifecycle can move `PENDING -> RESPONDING -> ERROR` +- `resolvePrompt(id)` clears matching permission prompt + +### Exclusivity tests + +- asking a permission replaces a question prompt +- asking a question replaces a permission prompt +- prompt state is always at most one variant + +### Event tests + +- `PromptChanged(prompt)` fires on question set +- `PromptChanged(prompt)` fires on permission set +- `PromptChanged(null)` fires on resolve + +--- + +## Implementation Order + +### Step 1 + +Mechanical rename pass: + +1. `MessageListModel` -> `SessionModel` +2. keep `SessionManager` name +3. `MessageModelEvent` -> `SessionModelEvent` +4. `PartModel` -> `ContentModel` +5. `ToolState` -> `ToolExecState` +6. remove `SessionState` and fold its responsibilities into `SessionModel` + +### Step 2 + +Introduce the unified session model types: + +1. `SessionPhase` +2. `StatusState` +3. `ErrorState` +4. `PromptState` +5. `QuestionRequestState` +6. `PermissionRequestState` +7. `PermissionState` +8. `PermissionMeta` +9. `PermissionFileDiff` +10. `ToolCallRef` + +### Step 3 + +Refactor `SessionModel` API: + +1. replace raw `status: String?` +2. replace `question: QuestionState?` with `prompt: PromptState?` +3. replace raw `errors: List` with `List` +4. rename content-related events and methods +5. absorb `SessionState.load`, `message`, `messages`, and `part` responsibilities directly into `SessionModel` + +### Step 4 + +Refactor `SessionManager` wiring so it mutates only `SessionModel`. + +### Step 5 + +Add model-only tests for permissions, prompt exclusivity, and typed runtime state. + +### Step 6 + +After this model layer stabilizes, start UI work for: + +- permission prompt panel +- question prompt panel +- rendering tool-associated prompts inline or docked + +--- + +## Recommendation + +Implement this in **two commits/phases** even if done in one branch: + +1. **Pure model rename + state-shape refactor** +2. **SessionManager rewiring to the new model API** + +That keeps the migration understandable and makes review significantly easier. diff --git a/.kilo/plans/1776447841515-misty-eagle.md b/.kilo/plans/1776447841515-misty-eagle.md new file mode 100644 index 00000000000..21e4e1aaa7a --- /dev/null +++ b/.kilo/plans/1776447841515-misty-eagle.md @@ -0,0 +1,276 @@ +# JetBrains Session Model — Unified Phase Cleanup + +## Goal + +Collapse the JetBrains `SessionModel`'s three independent state fields (`phase`, `prompt`, `status`) into a single `SessionPhase` sealed class. The UI should see exactly one "what is this session doing?" value at all times. No contradictory combinations possible. + +--- + +## Current Model (Problems) + +**`ContentModel.kt:28-33`** — State types: + +```kotlin +enum class SessionPhase { IDLE, BUSY } + +sealed class StatusState { + data class Thinking(val text: String) : StatusState() + data class Working(val text: String) : StatusState() +} +``` + +**`QuestionState.kt:63-75`** — Prompt overlay: + +```kotlin +sealed class PromptState { + data class Question(override val id: String, val state: QuestionState) : PromptState() + data class Permission(override val id: String, val state: PermissionState) : PromptState() +} +``` + +**`SessionModel.kt:35-42`** — Three independent fields: + +```kotlin +var phase: SessionPhase = SessionPhase.IDLE // IDLE or BUSY +var prompt: PromptState? = null // Question, Permission, or null +var status: StatusState? = null // Thinking, Working, or null +``` + +### Problems + +1. **`phase = BUSY` + `prompt = Permission(...)` is a real combo** — but the phase says "busy" when the session is actually blocked waiting for the user. The UI must check both. + +2. **`phase = BUSY` + `status = null` can happen** — between TurnOpen and the first PartUpdated, status is set but could be cleared/stale from a previous event. + +3. **Three fields create combinatorial states** — `2 × 3 × 3 = 18` combos, most invalid. Only ~6 are real. + +4. **Permission/Question events are not in `ChatEventDto`** — `SessionModel.askQuestion()` and `SessionModel.askPermission()` exist but `SessionManager.handle()` has no cases for them. These features are unwired. + +5. **`SessionManager` line 128-136 tracks `session.status` via a Flow BUT also sets phase from TurnOpen/TurnClose** — two competing sources of truth for the same field, creating race potential. + +--- + +## Proposed Model + +Replace the three fields with a single `SessionPhase` sealed class: + +```kotlin +sealed class SessionPhase { + /** Nothing happening. Ready for user input. */ + data object Idle : SessionPhase() + + /** LLM is actively processing. */ + data class Working(val status: StatusState) : SessionPhase() + + /** Waiting for user to answer a question or approve a permission. */ + data class Prompting(val prompt: PromptState) : SessionPhase() + + /** Request failed, retrying with backoff. */ + data class Retry(val attempt: Int, val message: String, val next: Long) : SessionPhase() + + /** Cannot reach the provider. */ + data class Offline(val requestId: String, val message: String) : SessionPhase() + + /** Turn ended with an error. User can still send a new prompt. */ + data class Error(val message: String, val kind: String? = null) : SessionPhase() +} +``` + +6 variants. Mutually exclusive. Matches how the CLI behaves — when a turn errors, the session stops and the error is the final state of that turn. The user can start a new turn (Error → Working), but the session is NOT idle — it's visibly in an error state until the user acts. + +The existing `ErrorState` data class and the `_errors` accumulated list can be removed from `SessionModel`. When the phase is `Error`, the UI renders the error message from the phase itself. Previous errors are visible in the message history (each assistant message has an `error` field in the CLI). There's no need to track a separate error list in the model. + +### What Gets Removed + +| Old field | Absorbed into | +| ---------------------------- | -------------------------------- | +| `phase: SessionPhase` (enum) | The sealed class itself | +| `prompt: PromptState?` | `SessionPhase.Prompting(prompt)` | +| `status: StatusState?` | `SessionPhase.Working(status)` | +| `_errors: List` | `SessionPhase.Error(message)` | + +The `PromptState` and `StatusState` types remain as-is — they become data _inside_ the relevant phase variant rather than independent model fields. `ErrorState` is no longer needed as a separate type. + +### State Transitions + +``` + ┌──── Retry ◄────┐ + │ │ +Idle ───► Working ───► Prompting + ▲ │ │ │ + │ │ └──► Offline │ + │ ▼ │ +Error ◄─────┴────────────────┘ + │ + └──► Working (user sends new prompt) +``` + +The valid transitions, matching CLI behavior: + +| From | To | Trigger | +| --------- | --------- | --------------------------------------------- | +| Idle | Working | TurnOpen / user sends prompt | +| Working | Idle | TurnClose (completed) | +| Working | Error | ChatEventDto.Error | +| Working | Prompting | permission.asked, question.asked | +| Working | Retry | session.status = retry | +| Working | Offline | session.status = offline | +| Prompting | Working | permission.replied, question.replied | +| Prompting | Idle | permission.rejected, question.rejected | +| Retry | Working | retry succeeds (session.status = busy) | +| Retry | Idle | abort during retry | +| Offline | Working | connectivity restored (session.status = busy) | +| Error | Working | TurnOpen / user sends new prompt | + +--- + +## Implementation Steps + +### Step 1: Update `SessionPhase` type + +**File**: `frontend/.../model/ContentModel.kt` + +Change `SessionPhase` from a 2-value enum to a sealed class with 6 variants. Keep `StatusState` as-is (it becomes the payload of `Working`). Remove `ErrorState` — its data is absorbed into `SessionPhase.Error`. + +### Step 2: Remove `prompt` and `status` from SessionModel + +**File**: `frontend/.../model/SessionModel.kt` + +- Remove `var prompt: PromptState?` field +- Remove `var status: StatusState?` field +- Remove `_errors: MutableList` field and `errors` getter +- Remove `askQuestion()`, `askPermission()`, `resolvePrompt()`, `setStatus()`, `addError()` methods +- All state changes go through `setPhase()` — one setter, one event + +Update `loadHistory()` and `clear()` to only reset `phase = Idle`. + +### Step 3: Add permission/question events to ChatEventDto + +**File**: `shared/.../dto/ChatDto.kt` + +Add new event variants: + +```kotlin +@SerialName("permission.asked") +data class PermissionAsked(val sessionID: String, val request: PermissionRequestDto) : ChatEventDto() + +@SerialName("permission.replied") +data class PermissionReplied(val sessionID: String, val requestID: String) : ChatEventDto() + +@SerialName("question.asked") +data class QuestionAsked(val sessionID: String, val request: QuestionRequestDto) : ChatEventDto() + +@SerialName("question.replied") +data class QuestionReplied(val sessionID: String, val requestID: String) : ChatEventDto() + +@SerialName("question.rejected") +data class QuestionRejected(val sessionID: String, val requestID: String) : ChatEventDto() + +@SerialName("session.status") +data class SessionStatus(val sessionID: String, val status: SessionStatusDto) : ChatEventDto() +``` + +Add corresponding DTOs for `PermissionRequestDto` and `QuestionRequestDto` based on the CLI's `Permission.Request` and `Question.Request` schemas. + +### Step 4: Update SessionManager.handle() + +**File**: `frontend/.../model/SessionManager.kt` + +Update the event handler to drive all transitions through `setPhase()`: + +- `TurnOpen` → `setPhase(Working(Thinking("Considering...")))` +- `TurnClose` → `setPhase(Idle)` +- `Error` → `setPhase(Error(message, kind))` +- `PartUpdated` while `phase is Working` → `setPhase(Working(status()))` (update status text) +- `PermissionAsked` → `setPhase(Prompting(Permission(...)))` +- `PermissionReplied` → `setPhase(Working(Thinking("Considering...")))` (resumes) +- `QuestionAsked` → `setPhase(Prompting(Question(...)))` +- `QuestionReplied` → `setPhase(Working(Thinking("Considering...")))` (resumes) +- `SessionStatus(type=retry)` → `setPhase(Retry(...))` +- `SessionStatus(type=offline)` → `setPhase(Offline(...))` +- `SessionStatus(type=idle)` → `setPhase(Idle)` +- `SessionStatus(type=busy)` → `setPhase(Working(...))` only if currently Idle or Error + +Remove the `statuses.collect` flow (lines 127-137) — session status changes should come through `ChatEventDto.SessionStatus` instead of a separate Flow, keeping one event source. + +### Step 5: Simplify SessionModelEvent + +**File**: `frontend/.../model/SessionModelEvent.kt` + +- Remove `StatusChanged(status)` and `PromptChanged(prompt)` events +- `PhaseChanged(phase)` is the only transition event needed +- The UI reacts to `PhaseChanged` and reads the phase variant to decide what to show + +### Step 6: Simplify SessionEvent + +**File**: `frontend/.../model/SessionEvent.kt` + +- Remove `StatusChanged(text)`, `BusyChanged(busy)`, and `Error(message)` events +- Replace with `PhaseChanged(phase: SessionPhase)` +- UI derives busy from `phase !is Idle && phase !is Error`, status text from `phase` variant +- Errors are visible via `phase is Error` — no separate error event needed + +### Step 7: Wire up backend SSE parsing + +**File**: `backend/.../cli/KiloCliDataParser.kt` (or wherever SSE events are parsed) + +Map the raw SSE events (`permission.asked`, `question.asked`, `session.status`, etc.) to the new `ChatEventDto` variants. Currently only `session.turn.open`, `session.turn.close`, `part.updated`, `part.delta`, `message.updated`, `message.removed`, and `error` are handled. + +### Step 8: Update SessionUi and other UI consumers + +**Files**: `frontend/.../session/SessionUi.kt`, `frontend/.../session/ui/StatusPanel.kt`, `frontend/.../session/ui/PromptPanel.kt` + +Switch on a single `phase` value: + +```kotlin +when (val phase = chat.phase) { + is SessionPhase.Idle -> showPromptInput() + is SessionPhase.Working -> showSpinner(phase.status) + is SessionPhase.Prompting -> showPromptDock(phase.prompt) + is SessionPhase.Retry -> showRetryCountdown(phase) + is SessionPhase.Offline -> showOfflineMessage(phase) + is SessionPhase.Error -> showError(phase.message) +} +``` + +### Step 9: Update tests + +Update existing tests (`TurnLifecycleTest`, `StatusComputationTest`) to assert on `PhaseChanged` instead of `BusyChanged` + `StatusChanged`. Add new tests: + +- Permission asked → phase transitions to `Prompting(Permission(...))` +- Permission replied → phase transitions back to `Working` +- Question asked → phase transitions to `Prompting(Question(...))` +- Question rejected → phase transitions to `Idle` +- Error event → phase transitions to `Error(message)` +- New prompt from Error state → phase transitions to `Working` +- Retry status → phase transitions to `Retry` +- Phase is always exactly one variant (no contradictory combos) + +--- + +## Files Changed + +| File | Change | +| ------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `frontend/.../model/ContentModel.kt` | `SessionPhase` becomes sealed class with 6 variants; remove `ErrorState` | +| `frontend/.../model/SessionModel.kt` | Remove `prompt`, `status`, `_errors` fields; collapse into `setPhase()` | +| `frontend/.../model/SessionModelEvent.kt` | Remove `StatusChanged`, `PromptChanged`, `ErrorAdded`; keep `PhaseChanged` | +| `frontend/.../model/SessionEvent.kt` | Remove `StatusChanged`, `BusyChanged`, `Error`; add `PhaseChanged` | +| `frontend/.../model/SessionManager.kt` | Rewrite `handle()` to drive all state through `setPhase()`; remove `statuses.collect` | +| `frontend/.../model/QuestionState.kt` | No change (PromptState stays as-is, used as payload) | +| `shared/.../dto/ChatDto.kt` | Add permission/question/status event variants | +| `shared/.../dto/SessionDto.kt` | Add permission/question DTOs if needed | +| `backend/.../cli/KiloCliDataParser.kt` | Parse new SSE event types into ChatEventDto variants | +| `frontend/.../session/SessionUi.kt` | Switch on single `phase` | +| `frontend/.../session/ui/StatusPanel.kt` | Derive text from `phase` | +| `frontend/.../session/ui/PromptPanel.kt` | Derive dock from `phase` | +| `frontend/src/test/.../TurnLifecycleTest.kt` | Update assertions | +| `frontend/src/test/.../StatusComputationTest.kt` | Update assertions | +| New test file | Phase transition / invariant tests | + +## What This Does NOT Change + +- CLI backend (`packages/opencode/`) — no changes +- Server API / SSE events — consumed as-is +- `PermissionState`, `QuestionState`, `StatusState` types — reused as phase payloads +- `ContentModel`, `MessageModel` — untouched diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 5a1860e5dea..68977bd985c 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -7,12 +7,17 @@ import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TokensDto +import ai.kilocode.rpc.dto.ToolRefDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -112,6 +117,43 @@ object KiloCliDataParser { ChatEventDto.Error(sid, err) } + "permission.asked" -> { + val sid = props.str("sessionID") ?: return null + val request = parsePermissionRequest(props) ?: return null + ChatEventDto.PermissionAsked(sid, request) + } + + "permission.replied" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.PermissionReplied(sid, rid) + } + + "question.asked" -> { + val sid = props.str("sessionID") ?: return null + val request = parseQuestionRequest(props) ?: return null + ChatEventDto.QuestionAsked(sid, request) + } + + "question.replied" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.QuestionReplied(sid, rid) + } + + "question.rejected" -> { + val sid = props.str("sessionID") ?: return null + val rid = props.str("requestID") ?: return null + ChatEventDto.QuestionRejected(sid, rid) + } + + "session.status" -> { + val sid = props.str("sessionID") ?: return null + val st = props["status"]?.jsonObject ?: return null + val dto = SessionStatusDto(st.str("type") ?: "idle", st.str("message")) + ChatEventDto.SessionStatusChanged(sid, dto) + } + else -> null } } @@ -268,6 +310,48 @@ object KiloCliDataParser { return MessageErrorDto(type, msg) } + internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? { + val id = obj.str("id") ?: return null + val sid = obj.str("sessionID") ?: return null + val permission = obj.str("permission") ?: return null + val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val meta = obj["metadata"]?.jsonObject?.let { m -> + m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") } + } ?: emptyMap() + val ref = obj["tool"]?.jsonObject?.let { t -> + val mid = t.str("messageID") ?: return@let null + val cid = t.str("callID") ?: return@let null + ToolRefDto(mid, cid) + } + return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref) + } + + internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? { + val id = obj.str("id") ?: return null + val sid = obj.str("sessionID") ?: return null + val questions = obj["questions"]?.jsonArray?.map { q -> + val qo = q.jsonObject + val options = qo["options"]?.jsonArray?.map { o -> + val oo = o.jsonObject + QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "") + } ?: emptyList() + QuestionInfoDto( + question = qo.str("question") ?: "", + header = qo.str("header") ?: "", + options = options, + multiple = qo.str("multiple") == "true", + custom = qo.str("custom") != "false", + ) + } ?: emptyList() + val ref = obj["tool"]?.jsonObject?.let { t -> + val mid = t.str("messageID") ?: return@let null + val cid = t.str("callID") ?: return@let null + ToolRefDto(mid, cid) + } + return QuestionRequestDto(id, sid, questions, ref) + } + private fun parseSessionObject(obj: JsonObject): SessionDto { val time = obj["time"]?.jsonObject val summary = obj["summary"]?.jsonObject diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index c0c7c5c823d..33927bc280c 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -92,6 +92,12 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.TurnClose -> event.sessionID is ChatEventDto.Error -> event.sessionID is ChatEventDto.MessageRemoved -> event.sessionID + is ChatEventDto.PermissionAsked -> event.sessionID + is ChatEventDto.PermissionReplied -> event.sessionID + is ChatEventDto.QuestionAsked -> event.sessionID + is ChatEventDto.QuestionReplied -> event.sessionID + is ChatEventDto.QuestionRejected -> event.sessionID + is ChatEventDto.SessionStatusChanged -> event.sessionID } sid == id } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 08b6541cf1c..c0ab0ea8cea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,9 +3,10 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.model.MessageListModel -import ai.kilocode.client.session.model.SessionEvent import ai.kilocode.client.session.model.SessionManager +import ai.kilocode.client.session.model.SessionManagerEvent +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionPhase import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.MessageListUi import ai.kilocode.client.session.ui.PromptPanel @@ -19,20 +20,7 @@ import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.JPanel -/** - * Main chat panel — reacts to [SessionManager] events. - * - * Uses [CardLayout] in the center to switch between the empty panel - * (shown before the first prompt) and the scrollable message list. - * - * All business logic (app/workspace watching, session lifecycle, event - * handling, status computation) lives in [SessionManager]. Welcome - * rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. - * Message list rendering lives in [MessageListUi], driven by - * [MessageListModel] events. This class handles layout, prompt - * wiring, model mutations, card switching, picker population, - * busy state, and scrolling. - */ +/** Main chat panel — reacts to [SessionManager] events. */ class SessionUi( project: Project, workspace: Workspace, @@ -48,8 +36,7 @@ class SessionUi( private val model = SessionManager(this, null, sessions, workspace, app, cs) private val status = StatusPanel(this, model) - private val messageList = MessageListModel() - private val messages = MessageListUi(this, messageList) + private val messages = MessageListUi(this, model.chat) private val cards = CardLayout() private val center = JPanel(cards) @@ -61,13 +48,12 @@ class SessionUi( } private val prompt = PromptPanel( - project = project, - onSend = { text -> send(text) }, - onAbort = { model.abort() }, + project = project, + onSend = { text -> send(text) }, + onAbort = { model.abort() }, ) init { - // Layout center.add(status, STATUS) center.add(scroll, MESSAGES) cards.show(center, STATUS) @@ -75,91 +61,46 @@ class SessionUi( add(center, BorderLayout.CENTER) add(prompt, BorderLayout.SOUTH) - // Wire picker callbacks via typed model methods - prompt.mode.onSelect = { item -> - model.selectAgent(item.id) - } - prompt.model.onSelect = { item -> + prompt.mode.onSelect = { item -> model.selectAgent(item.id) } + prompt.model.onSelect = picker@{ item -> val group = item.group - if (group != null) { - model.selectModel(group, item.id) - } + if (group == null) return@picker + model.selectModel(group, item.id) } - // React to model events — no coroutines, pure EDT. - // Message-related events are forwarded to MessageListModel; - // MessageListUi listens to the model and renders autonomously. + // Lifecycle events from the manager (app/workspace state, view switching) model.addListener(this) { event -> when (event) { - is SessionEvent.MessageAdded -> { - val msg = model.chat.message(event.id) ?: return@addListener - messageList.addMessage(msg.info) - scrollToBottom() - } - - is SessionEvent.MessageRemoved -> { - messageList.removeMessage(event.id) - scrollToBottom() - } - - is SessionEvent.PartUpdated -> { - val part = model.chat.part(event.messageId, event.partId) ?: return@addListener - messageList.setPartText(event.messageId, event.partId, part.text.toString()) - scrollToBottom() - } - - is SessionEvent.PartDelta -> { - messageList.appendDelta(event.messageId, event.partId, event.delta) - scrollToBottom() - } - - is SessionEvent.StatusChanged -> { - messageList.setStatus(event.text) - scrollToBottom() - } - - is SessionEvent.Error -> { - messageList.addError(event.message) - scrollToBottom() - } - - is SessionEvent.HistoryLoaded -> { - messageList.loadHistory(model.chat.messages()) - scrollToBottom() - } - - is SessionEvent.Cleared -> { - messageList.clear() - } - - is SessionEvent.WorkspaceReady -> { + is SessionManagerEvent.WorkspaceReady -> { val c = model.chat - prompt.mode.setItems( - c.agents.map { LabelPicker.Item(it.name, it.display) }, - c.agent, - ) + prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent) val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - // chat.model is "provider/modelId", picker items use modelId only. - // Find the matching item and pass its id for selection. - val selected = c.model?.let { full -> - items.firstOrNull { "${it.group}/${it.id}" == full }?.id - } + val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) prompt.setReady(c.ready) } - is SessionEvent.ViewChanged -> { - cards.show(center, if (event.show) MESSAGES else STATUS) - } + is SessionManagerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) - is SessionEvent.BusyChanged -> { - prompt.setBusy(event.busy) - } + is SessionManagerEvent.AppChanged, + is SessionManagerEvent.WorkspaceChanged -> {} + } + } - is SessionEvent.AppChanged, - is SessionEvent.WorkspaceChanged -> { - // Handled by StatusPanel + // Model events — phase drives the prompt busy state + model.chat.addListener(this) { event -> + when (event) { + is SessionModelEvent.PhaseChanged -> { + val busy = event.phase is SessionPhase.Working || event.phase is SessionPhase.Prompting + prompt.setBusy(busy) + scrollToBottom() } + is SessionModelEvent.MessageAdded, + is SessionModelEvent.ContentAdded, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentDelta -> scrollToBottom() + is SessionModelEvent.HistoryLoaded -> scrollToBottom() + else -> {} } } } @@ -175,7 +116,5 @@ class SessionUi( bar.value = bar.maximum } - override fun dispose() { - // All children (status, model) disposed by Disposer - } + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt deleted file mode 100644 index 182375a1d09..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageListModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.MessageDto -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer - -/** - * Pure data model for the message list. - * - * Holds messages, parts, status, and errors. All mutations fire - * [MessageModelEvent]s to registered [MessageModelEvent.Listener]s. - * - * **EDT-only access** — no synchronization. The caller ([SessionUi]) - * guarantees all reads and writes happen on the EDT. - */ -class MessageListModel { - - private val entries = LinkedHashMap() - private val _errors = mutableListOf() - - /** Current status text. Null means hidden. */ - var status: String? = null - private set - - /** Accumulated error messages. */ - val errors: List get() = _errors - - private val listeners = mutableListOf() - - // --- Listener management --- - - fun addListener(parent: Disposable, listener: MessageModelEvent.Listener) { - listeners.add(listener) - Disposer.register(parent) { listeners.remove(listener) } - } - - // --- Read --- - - fun entries(): Collection = entries.values - - fun entry(id: String): MessageModel? = entries[id] - - fun isEmpty(): Boolean = entries.isEmpty() - - // --- Mutations --- - - fun addMessage(info: MessageDto) { - if (entries.containsKey(info.id)) return - entries[info.id] = MessageModel(info) - fire(MessageModelEvent.MessageAdded(info)) - } - - fun removeMessage(id: String) { - if (entries.remove(id) == null) return - fire(MessageModelEvent.MessageRemoved(id)) - } - - fun setPartText(messageId: String, partId: String, text: String) { - val entry = entries[messageId] ?: return - entry.parts.getOrPut(partId) { StringBuilder() }.apply { - clear() - append(text) - } - fire(MessageModelEvent.PartText(messageId, partId, text)) - } - - fun appendDelta(messageId: String, partId: String, delta: String) { - val entry = entries[messageId] ?: return - entry.parts.getOrPut(partId) { StringBuilder() }.append(delta) - fire(MessageModelEvent.PartDelta(messageId, partId, delta)) - } - - fun addError(message: String) { - if (_errors.lastOrNull() == message) return - _errors.add(message) - fire(MessageModelEvent.Error(message)) - } - - fun setStatus(text: String?) { - status = text - fire(MessageModelEvent.StatusChanged(text)) - } - - fun clear() { - entries.clear() - _errors.clear() - status = null - fire(MessageModelEvent.Cleared) - } - - /** - * Bulk-load message history. Clears existing entries first, - * populates from [SessionState] data, and fires a single - * [MessageModelEvent.HistoryLoaded]. - */ - fun loadHistory(messages: Collection) { - entries.clear() - _errors.clear() - status = null - for (msg in messages) { - val entry = MessageModel(msg.info) - for ((partId, part) in msg.parts) { - if (part.dto.type == "text" && part.text.isNotEmpty()) { - entry.parts[partId] = StringBuilder(part.text) - } - } - entries[msg.info.id] = entry - } - fire(MessageModelEvent.HistoryLoaded) - } - - private fun fire(event: MessageModelEvent) { - for (l in listeners) l.onEvent(event) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt deleted file mode 100644 index 6bb2f216b05..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.MessageDto - -/** A single message with its accumulated part text. */ -class MessageModel(val info: MessageDto) { - val parts = LinkedHashMap() -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt deleted file mode 100644 index fc82d6619ff..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/MessageModelEvent.kt +++ /dev/null @@ -1,28 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.MessageDto - -/** - * Change events fired by [MessageListModel]. - * - * Events carry the data needed for rendering so [MessageListUi][ai.kilocode.client.session.ui.MessageListUi] - * can update without reading back from the model (except for [HistoryLoaded]). - */ -sealed class MessageModelEvent { - data class MessageAdded(val info: MessageDto) : MessageModelEvent() - data class MessageRemoved(val id: String) : MessageModelEvent() - data class PartText(val messageId: String, val partId: String, val text: String) : MessageModelEvent() - data class PartDelta(val messageId: String, val partId: String, val delta: String) : MessageModelEvent() - data class Error(val message: String) : MessageModelEvent() - data class StatusChanged(val text: String?) : MessageModelEvent() - data object HistoryLoaded : MessageModelEvent() - data object Cleared : MessageModelEvent() - - /** - * Listener for [MessageModelEvent]s fired by [MessageListModel]. - * All callbacks are guaranteed to run on the EDT. - */ - fun interface Listener { - fun onEvent(event: MessageModelEvent) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt new file mode 100644 index 00000000000..d82f794af5f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt @@ -0,0 +1,18 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.session.model.permission.Permission +import ai.kilocode.client.session.model.question.Question + +sealed class PromptState { + abstract val id: String + + data class Asking( + override val id: String, + val question: Question, + ) : PromptState() + + data class Permitting( + override val id: String, + val permission: Permission, + ) : PromptState() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt deleted file mode 100644 index 9b8da099abe..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt +++ /dev/null @@ -1,45 +0,0 @@ -package ai.kilocode.client.session.model - -/** - * Change events fired by [SessionManager] on the EDT. - * - * Events carry IDs so the UI knows **which** message/part changed. - * The UI can read full data from [SessionState] directly (safe — same - * EDT thread). [PartDelta] also carries the delta string so the - * view can append efficiently without reading the whole text. - */ -sealed class SessionEvent { - - // Message lifecycle - data class MessageAdded(val id: String) : SessionEvent() - data class MessageRemoved(val id: String) : SessionEvent() - - // Part changes - data class PartUpdated(val messageId: String, val partId: String) : SessionEvent() - data class PartDelta(val messageId: String, val partId: String, val delta: String) : SessionEvent() - - // Session state - data class StatusChanged(val text: String?) : SessionEvent() - data class BusyChanged(val busy: Boolean) : SessionEvent() - data class Error(val message: String) : SessionEvent() - - // Bulk operations - data object HistoryLoaded : SessionEvent() - data object Cleared : SessionEvent() - - // App + workspace lifecycle (every state transition) - data object AppChanged : SessionEvent() - data object WorkspaceChanged : SessionEvent() - - // Workspace ready (pickers populated) - data object WorkspaceReady : SessionEvent() - data class ViewChanged(val show: Boolean) : SessionEvent() -} - -/** - * Listener for [SessionEvent]s fired by [SessionManager]. - * All callbacks are guaranteed to run on the EDT. - */ -fun interface SessionManagerListener { - fun onEvent(event: SessionEvent) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt index 077126ccfb1..e9b078ece77 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt @@ -4,10 +4,17 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.permission.Permission +import ai.kilocode.client.session.model.permission.PermissionMeta +import ai.kilocode.client.session.model.question.Question +import ai.kilocode.client.session.model.question.QuestionItem +import ai.kilocode.client.session.model.question.QuestionOption import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionRequestDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger @@ -18,16 +25,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** - * Session lifecycle controller for a single session. + * Session lifecycle orchestrator for a single session. * * Accepts an optional [id] — if non-null, loads that session immediately. - * If null, lazily creates a session on the first [prompt] call. This - * ensures event subscription happens *before* the prompt is sent, - * eliminating race conditions. + * If null, lazily creates a session on the first [prompt] call. This ensures + * event subscription happens before the prompt is sent, eliminating races. * - * Owns [SessionState] and the listener list. All state mutations and - * listener notifications happen on the EDT — [fire] auto-dispatches - * via `invokeLater` when called from a background thread. + * Owns [SessionModel] — the single source of truth for chat content and + * phase. UIs observe model changes via [SessionModelEvent] on [chat]. + * Lifecycle events (app/workspace state, view switching) are published + * via [SessionManagerEvent] to registered listeners. */ class SessionManager( parent: Disposable, @@ -46,42 +53,21 @@ class SessionManager( Disposer.register(parent, this) } - val chat = SessionState() + val chat = SessionModel() private val listeners = mutableListOf() - - /** The session ID owned by this model. Null until created or passed in. */ private var sessionId: String? = id - - /** Resolved project directory for RPC calls. */ private val directory: String get() = workspace.directory - // Status computation state (EDT-only) private var partType: String? = null private var tool: String? = null - private var busy: Boolean = false - - // Coroutine job for the current event subscription private var eventJob: Job? = null - // --- Listener management (EDT) --- - - /** - * Register a listener whose lifetime is tied to [parent]. - * When [parent] is disposed the listener is auto-removed. - */ fun addListener(parent: Disposable, listener: SessionManagerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } - // --- Actions (called from EDT) --- - - /** - * Send a prompt. If no session exists, creates one first, - * subscribes to events, then sends the prompt — all in one - * coroutine to avoid race conditions. - */ fun prompt(text: String) { showMessages() cs.launch { @@ -96,8 +82,8 @@ class SessionManager( } catch (e: Exception) { LOG.warn("prompt failed", e) edt { - fire(SessionEvent.Error(e.message ?: KiloBundle.message("session.error.prompt"))) - fire(SessionEvent.BusyChanged(false)) + val msg = e.message ?: KiloBundle.message("session.error.prompt") + chat.setPhase(SessionPhase.Error(msg)) } } } @@ -123,7 +109,7 @@ class SessionManager( LOG.warn("selectAgent failed", e) } } - fire(SessionEvent.WorkspaceReady) + fire(SessionManagerEvent.WorkspaceReady) } fun selectModel(provider: String, id: String) { @@ -135,28 +121,15 @@ class SessionManager( LOG.warn("selectModel failed", e) } } - fire(SessionEvent.WorkspaceReady) + fire(SessionManagerEvent.WorkspaceReady) } - // --- Internal: coroutine → EDT bridge --- - init { - // If we have a session ID, load it immediately if (sessionId != null) { loadHistory() subscribeEvents() } - // Watch session statuses for busy/idle - cs.launch { - sessions.statuses.collect { statuses -> - val id = sessionId ?: return@collect - val st = statuses[id] - edt { fire(SessionEvent.BusyChanged(st?.type == "busy")) } - } - } - - // Watch app lifecycle state app.connect() cs.launch { app.state.collect { state -> @@ -164,17 +137,16 @@ class SessionManager( edt { chat.app = state chat.version = app.version - fire(SessionEvent.AppChanged) + fire(SessionManagerEvent.AppChanged) } } } - // Watch workspace state for providers/agents and lifecycle cs.launch { workspace.state.collect { state -> edt { chat.workspace = state - fire(SessionEvent.WorkspaceChanged) + fire(SessionManagerEvent.WorkspaceChanged) if (state.status == KiloWorkspaceStatusDto.READY) { chat.agents = state.agents?.agents?.map { @@ -191,15 +163,11 @@ class SessionManager( } } ?: emptyList() - if (chat.agent == null) { - chat.agent = state.agents?.default - } - if (chat.model == null) { - chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value - } + if (chat.agent == null) chat.agent = state.agents?.default + if (chat.model == null) chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value chat.ready = true - fire(SessionEvent.WorkspaceReady) + fire(SessionManagerEvent.WorkspaceReady) } } } @@ -212,9 +180,8 @@ class SessionManager( try { val history = sessions.messages(id, directory) edt { - chat.load(history) + chat.loadHistory(history) if (!chat.isEmpty()) showMessages() - fire(SessionEvent.HistoryLoaded) } } catch (e: Exception) { LOG.warn("loadHistory failed", e) @@ -235,84 +202,103 @@ class SessionManager( private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.MessageUpdated -> { - chat.addMessage(event.info) + chat.addMessage(event.info) ?: return showMessages() - fire(SessionEvent.MessageAdded(event.info.id)) } is ChatEventDto.PartUpdated -> { partType = event.part.type tool = event.part.tool - chat.updatePart(event.part.messageID, event.part) - if (busy) { - fire(SessionEvent.StatusChanged(status())) - } - if (event.part.type == "text" && event.part.text != null) { - fire(SessionEvent.PartUpdated(event.part.messageID, event.part.id)) + chat.updateContent(event.part.messageID, event.part) + if (chat.phase is SessionPhase.Working) { + chat.setPhase(SessionPhase.Working(status())) } } is ChatEventDto.PartDelta -> { if (event.field == "text") { chat.appendDelta(event.messageID, event.partID, event.delta) - fire(SessionEvent.PartDelta(event.messageID, event.partID, event.delta)) } } is ChatEventDto.TurnOpen -> { partType = null tool = null - busy = true - fire(SessionEvent.StatusChanged(KiloBundle.message("session.status.considering"))) - fire(SessionEvent.BusyChanged(true)) + chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) } is ChatEventDto.TurnClose -> { partType = null tool = null - busy = false - fire(SessionEvent.StatusChanged(null)) - fire(SessionEvent.BusyChanged(false)) + chat.setPhase(SessionPhase.Idle) } is ChatEventDto.Error -> { + partType = null + tool = null val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - busy = false - fire(SessionEvent.Error(msg)) - fire(SessionEvent.StatusChanged(null)) - fire(SessionEvent.BusyChanged(false)) + chat.setPhase(SessionPhase.Error(msg, event.error?.type)) } is ChatEventDto.MessageRemoved -> { chat.removeMessage(event.messageID) - fire(SessionEvent.MessageRemoved(event.messageID)) + } + + is ChatEventDto.PermissionAsked -> { + chat.setPhase(SessionPhase.Prompting(toPermissionPrompt(event.request))) + } + + is ChatEventDto.PermissionReplied -> { + if (chat.phase is SessionPhase.Prompting) { + chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) + } + } + + is ChatEventDto.QuestionAsked -> { + chat.setPhase(SessionPhase.Prompting(toQuestionPrompt(event.request))) + } + + is ChatEventDto.QuestionReplied -> { + if (chat.phase is SessionPhase.Prompting) { + chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) + } + } + + is ChatEventDto.QuestionRejected -> { + if (chat.phase is SessionPhase.Prompting) { + chat.setPhase(SessionPhase.Idle) + } + } + + is ChatEventDto.SessionStatusChanged -> { + val phase = when (event.status.type) { + "idle" -> SessionPhase.Idle + "busy" -> { + val current = chat.phase + if (current is SessionPhase.Idle || current is SessionPhase.Error) + SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering"))) + else return // already in a more specific phase + } + "retry" -> SessionPhase.Retry(0, event.status.message ?: "", 0) + "offline" -> SessionPhase.Offline("", event.status.message ?: "") + else -> return + } + chat.setPhase(phase) } } } - // --- View switching (EDT) --- - private fun showMessages() { if (!chat.showMessages) { chat.showMessages = true - fire(SessionEvent.ViewChanged(true)) + fire(SessionManagerEvent.ViewChanged(true)) } } - private fun hideMessages() { - if (chat.showMessages) { - chat.showMessages = false - fire(SessionEvent.ViewChanged(false)) - } - } - - /** - * Compute a human-readable status from the last streaming part. - */ - private fun status(): String = when (partType) { - "reasoning" -> KiloBundle.message("session.status.thinking") - "text" -> KiloBundle.message("session.status.writing") - "tool" -> when (tool) { + private fun status(): StatusState = when (partType) { + "reasoning" -> StatusState.Thinking(KiloBundle.message("session.status.thinking")) + "text" -> StatusState.Working(KiloBundle.message("session.status.writing")) + "tool" -> StatusState.Working(when (tool) { "task" -> KiloBundle.message("session.status.delegating") "todowrite", "todoread" -> KiloBundle.message("session.status.planning") "read" -> KiloBundle.message("session.status.gathering") @@ -321,22 +307,17 @@ class SessionManager( "edit", "write" -> KiloBundle.message("session.status.editing") "bash" -> KiloBundle.message("session.status.commands") else -> KiloBundle.message("session.status.considering") - } - else -> KiloBundle.message("session.status.considering") + }) + else -> StatusState.Thinking(KiloBundle.message("session.status.considering")) } - /** - * Notify all listeners. If called from the EDT, listeners run - * immediately. If called from a background thread, the notification - * is dispatched via `invokeLater`. - */ - private fun fire(event: SessionEvent) { + private fun fire(event: SessionManagerEvent) { val application = ApplicationManager.getApplication() if (application.isDispatchThread) { for (l in listeners) l.onEvent(event) - } else { - application.invokeLater { for (l in listeners) l.onEvent(event) } + return } + application.invokeLater { for (l in listeners) l.onEvent(event) } } private fun edt(block: () -> Unit) { @@ -348,3 +329,32 @@ class SessionManager( cs.cancel() } } + +private fun toPermissionPrompt(dto: PermissionRequestDto): PromptState.Permitting { + val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } + val perm = Permission( + id = dto.id, + sessionId = dto.sessionID, + name = dto.permission, + patterns = dto.patterns, + always = dto.always, + meta = PermissionMeta(raw = dto.metadata), + tool = ref, + ) + return PromptState.Permitting(dto.id, perm) +} + +private fun toQuestionPrompt(dto: QuestionRequestDto): PromptState.Asking { + val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } + val items = dto.questions.map { + QuestionItem( + question = it.question, + header = it.header, + options = it.options.map { opt -> QuestionOption(opt.label, opt.description) }, + multiple = it.multiple, + custom = it.custom, + ) + } + val q = Question(id = dto.id, items = items, tool = ref) + return PromptState.Asking(dto.id, q) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt new file mode 100644 index 00000000000..b58f1512f6e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.session.model + +/** + * Lifecycle events fired by [SessionManager] on the EDT. + * + * These cover app/workspace state changes and view switching — things + * outside the [SessionModel] domain. For model mutations (messages, + * parts, phase), listen to [SessionModelEvent] on [SessionModel] directly. + */ +sealed class SessionManagerEvent { + + // App + workspace lifecycle (every state transition) + data object AppChanged : SessionManagerEvent() + data object WorkspaceChanged : SessionManagerEvent() + + // Workspace ready (pickers populated) + data object WorkspaceReady : SessionManagerEvent() + data class ViewChanged(val show: Boolean) : SessionManagerEvent() +} + +/** + * Listener for [SessionManagerEvent]s fired by [SessionManager]. + * All callbacks are guaranteed to run on the EDT. + */ +fun interface SessionManagerListener { + fun onEvent(event: SessionManagerEvent) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt new file mode 100644 index 00000000000..fe2294e0a6b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -0,0 +1,183 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.session.model.message.Compaction +import ai.kilocode.client.session.model.message.Content +import ai.kilocode.client.session.model.message.Message +import ai.kilocode.client.session.model.message.Reasoning +import ai.kilocode.client.session.model.message.Text +import ai.kilocode.client.session.model.message.Tool +import ai.kilocode.client.session.model.message.ToolExecState +import ai.kilocode.client.session.model.message.toInfo +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer + +/** + * Pure session model — single source of truth for chat content and runtime state. + * + * **EDT-only access** — no synchronization. [SessionManager] guarantees all + * reads and writes happen on the EDT. + */ +class SessionModel { + + private val entries = LinkedHashMap() + + var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + var version: String? = null + + var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + var agents: List = emptyList() + var models: List = emptyList() + var agent: String? = null + var model: String? = null + var ready: Boolean = false + var showMessages: Boolean = false + + var phase: SessionPhase = SessionPhase.Idle + private set + + private val listeners = mutableListOf() + + fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + fun messages(): Collection = entries.values + + fun message(id: String): Message? = entries[id] + + fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId) + + fun isEmpty(): Boolean = entries.isEmpty() + + fun addMessage(info: MessageDto): Message? { + if (entries.containsKey(info.id)) return null + val msg = Message(info.toInfo()) + entries[info.id] = msg + fire(SessionModelEvent.MessageAdded(msg)) + return msg + } + + fun removeMessage(id: String) { + if (entries.remove(id) == null) return + fire(SessionModelEvent.MessageRemoved(id)) + } + + fun updateContent(messageId: String, dto: PartDto) { + val msg = entries[messageId] ?: return + val existing = msg.parts[dto.id] + if (existing != null) { + updateExisting(messageId, existing, dto) + return + } + val content = fromDto(dto) ?: return + msg.parts[dto.id] = content + fire(SessionModelEvent.ContentAdded(messageId, content)) + } + + fun appendDelta(messageId: String, contentId: String, delta: String) { + val msg = entries[messageId] ?: return + val existing = msg.parts[contentId] + if (existing != null) { + val buf = when (existing) { + is Text -> existing.content + is Reasoning -> existing.content + else -> return + } + buf.append(delta) + } else { + val content = Text(contentId) + content.content.append(delta) + msg.parts[contentId] = content + fire(SessionModelEvent.ContentAdded(messageId, content)) + } + fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) + } + + fun setPhase(phase: SessionPhase) { + this.phase = phase + fire(SessionModelEvent.PhaseChanged(phase)) + } + + fun loadHistory(history: List) { + entries.clear() + phase = SessionPhase.Idle + for (msg in history) { + val item = Message(msg.info.toInfo()) + for (part in msg.parts) { + val content = fromDto(part, part.text) + if (content != null) item.parts[content.id] = content + } + entries[msg.info.id] = item + } + fire(SessionModelEvent.HistoryLoaded) + } + + fun clear() { + entries.clear() + phase = SessionPhase.Idle + fire(SessionModelEvent.Cleared) + } + + private fun updateExisting(messageId: String, existing: Content, dto: PartDto) { + when (existing) { + is Text -> { + val text = dto.text ?: return + existing.content.clear() + existing.content.append(text) + } + is Reasoning -> { + val text = dto.text ?: return + existing.content.clear() + existing.content.append(text) + } + is Tool -> { + existing.state = parseToolState(dto.state) + existing.title = dto.title + } + is Compaction -> return + } + fire(SessionModelEvent.ContentUpdated(messageId, existing)) + } + + private fun fromDto(dto: PartDto, text: CharSequence? = null): Content? { + val content = text ?: dto.text + return when (dto.type) { + "text" -> Text(dto.id).apply { + if (content != null && content.isNotEmpty()) this.content.append(content) + } + "reasoning" -> Reasoning(dto.id).apply { + if (content != null && content.isNotEmpty()) this.content.append(content) + } + "tool" -> Tool(dto.id, dto.tool ?: "unknown").apply { + state = parseToolState(dto.state) + title = dto.title + } + "compaction" -> Compaction(dto.id) + else -> null + } + } + + private fun fire(event: SessionModelEvent) { + for (l in listeners) l.onEvent(event) + } +} + +private fun parseToolState(raw: String?): ToolExecState = when (raw) { + "pending" -> ToolExecState.PENDING + "running" -> ToolExecState.RUNNING + "completed" -> ToolExecState.COMPLETED + "error" -> ToolExecState.ERROR + else -> ToolExecState.PENDING +} + +data class AgentItem(val name: String, val display: String) + +data class ModelItem(val id: String, val display: String, val provider: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt new file mode 100644 index 00000000000..f7a5e041355 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.session.model.message.Content +import ai.kilocode.client.session.model.message.Message + +/** + * Change events fired by [SessionModel]. + * + * Events carry the data needed for rendering so UI can update without + * reading back from the model except for [HistoryLoaded]. + */ +sealed class SessionModelEvent { + data class MessageAdded(val info: Message) : SessionModelEvent() + data class MessageRemoved(val id: String) : SessionModelEvent() + data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() + data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() + data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() + data class PhaseChanged(val phase: SessionPhase) : SessionModelEvent() + data object HistoryLoaded : SessionModelEvent() + data object Cleared : SessionModelEvent() + + fun interface Listener { + fun onEvent(event: SessionModelEvent) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt new file mode 100644 index 00000000000..16b946c0244 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.session.model + +/** + * Single source of truth for what a session is doing right now. + * + * Variants are mutually exclusive — the UI switches on a single value. + */ +sealed class SessionPhase { + /** Nothing happening. Ready for user input. */ + data object Idle : SessionPhase() + + /** LLM is actively processing. */ + data class Working(val status: StatusState) : SessionPhase() + + /** Waiting for user to answer a question or approve a permission. */ + data class Prompting(val prompt: PromptState) : SessionPhase() + + /** Request failed, retrying with backoff. */ + data class Retry(val attempt: Int, val message: String, val next: Long) : SessionPhase() + + /** Cannot reach the provider. */ + data class Offline(val requestId: String, val message: String) : SessionPhase() + + /** Turn ended with an error. User can still send a new prompt. */ + data class Error(val message: String, val kind: String? = null) : SessionPhase() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt deleted file mode 100644 index 3d5379371a0..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ /dev/null @@ -1,121 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.MessageDto -import ai.kilocode.rpc.dto.MessageWithPartsDto -import ai.kilocode.rpc.dto.PartDto - -/** - * Pure data holder for the active session's messages, parts, and - * workspace state (agents, models, selection). - * - * **EDT-only access** — no synchronization. [SessionManager] guarantees - * all reads and writes happen on the EDT. - */ -class SessionState { - - private val messages = LinkedHashMap() - - // --- App lifecycle state (set by SessionManager, read by EmptyChatUi) --- - - var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) - var version: String? = null - - // --- Workspace state (set by SessionManager, read by UI) --- - - var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - var agents: List = emptyList() - var models: List = emptyList() - var agent: String? = null - var model: String? = null - var ready: Boolean = false - var showMessages: Boolean = false - - // --- Read --- - - fun message(id: String): MessageData? = messages[id] - - fun messages(): Collection = messages.values - - fun part(messageId: String, partId: String): PartData? = - messages[messageId]?.parts?.get(partId) - - fun isEmpty(): Boolean = messages.isEmpty() - - // --- Write (called by SessionManager on EDT) --- - - /** - * Add a message. Returns false if the message already exists. - */ - fun addMessage(info: MessageDto): Boolean { - if (messages.containsKey(info.id)) return false - messages[info.id] = MessageData(info, LinkedHashMap()) - return true - } - - /** - * Remove a message by ID. Returns false if not found. - */ - fun removeMessage(id: String): Boolean = - messages.remove(id) != null - - /** - * Create or replace a part entry and set its text from [PartDto.text]. - */ - fun updatePart(messageId: String, part: PartDto) { - val msg = messages[messageId] ?: return - val text = StringBuilder(part.text ?: "") - msg.parts[part.id] = PartData(part, text) - } - - /** - * Append a text delta to an existing part. Creates the part if missing. - */ - fun appendDelta(messageId: String, partId: String, delta: String) { - val msg = messages[messageId] ?: return - val existing = msg.parts[partId] - if (existing != null) { - existing.text.append(delta) - } else { - msg.parts[partId] = PartData( - PartDto(id = partId, sessionID = "", messageID = messageId, type = "text"), - StringBuilder(delta), - ) - } - } - - /** - * Bulk-load message history from RPC DTOs. Clears existing data first. - */ - fun load(history: List) { - messages.clear() - for (msg in history) { - val parts = LinkedHashMap() - for (part in msg.parts) { - parts[part.id] = PartData(part, StringBuilder(part.text ?: "")) - } - messages[msg.info.id] = MessageData(msg.info, parts) - } - } - - fun clear() { - messages.clear() - } -} - -data class MessageData( - val info: MessageDto, - val parts: LinkedHashMap, -) - -class PartData( - val dto: PartDto, - val text: StringBuilder, -) - -data class AgentItem(val name: String, val display: String) - -data class ModelItem(val id: String, val display: String, val provider: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt new file mode 100644 index 00000000000..08d3445ae80 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt @@ -0,0 +1,7 @@ +package ai.kilocode.client.session.model + +/** Describes the LLM activity when `SessionPhase.Working`. */ +sealed class StatusState { + data class Thinking(val text: String) : StatusState() + data class Working(val text: String) : StatusState() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt new file mode 100644 index 00000000000..8f5227ddee1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt @@ -0,0 +1,6 @@ +package ai.kilocode.client.session.model + +data class ToolCallRef( + val messageId: String, + val callId: String, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt new file mode 100644 index 00000000000..57962eb6991 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.model.message + +/** Typed content within a message. */ +sealed class Content(val id: String) + +/** Streamed text content from the assistant. */ +class Text(id: String) : Content(id) { + val content = StringBuilder() +} + +/** Model reasoning / chain-of-thought. */ +class Reasoning(id: String) : Content(id) { + val content = StringBuilder() +} + +/** Tool invocation with lifecycle state. */ +class Tool(id: String, val name: String) : Content(id) { + var state: ToolExecState = ToolExecState.PENDING + var title: String? = null +} + +/** Context compaction marker. */ +class Compaction(id: String) : Content(id) + +enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt new file mode 100644 index 00000000000..386fae89c52 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.session.model.message + +import ai.kilocode.rpc.dto.MessageDto + +data class MessageInfo( + val id: String, + val role: String, + val created: Double, + val completed: Double? = null, + val agent: String? = null, + val providerId: String? = null, + val modelId: String? = null, + val parentId: String? = null, + val cost: Double? = null, +) + +/** A single message with its typed contents. */ +class Message( + val info: MessageInfo, +) { + val parts = LinkedHashMap() +} + +fun MessageDto.toInfo() = MessageInfo( + id = id, + role = role, + created = time.created, + completed = time.completed, + agent = agent, + providerId = providerID, + modelId = modelID, + parentId = parentID, + cost = cost, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt new file mode 100644 index 00000000000..0ffbdf6513b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session.model.permission + +import ai.kilocode.client.session.model.ToolCallRef + +enum class PermissionRequestState { PENDING, RESPONDING, RESOLVED, ERROR } + +enum class PermissionReply { ONCE, ALWAYS, REJECT } + +data class Permission( + val id: String, + val sessionId: String, + val name: String, + val patterns: List, + val always: List, + val meta: PermissionMeta, + val message: String? = null, + val tool: ToolCallRef? = null, + val state: PermissionRequestState = PermissionRequestState.PENDING, +) + +data class PermissionMeta( + val rules: List = emptyList(), + val diff: String? = null, + val filePath: String? = null, + val fileDiff: PermissionFileDiff? = null, + val raw: Map = emptyMap(), +) + +data class PermissionFileDiff( + val file: String, + val patch: String? = null, + val before: String? = null, + val after: String? = null, + val additions: Int, + val deletions: Int, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt new file mode 100644 index 00000000000..d5ea7541b58 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.model.question + +import ai.kilocode.client.session.model.ToolCallRef + +enum class QuestionRequestState { PENDING, RESPONDING, ERROR } + +data class Question( + val id: String, + val items: List, + val tool: ToolCallRef? = null, + val state: QuestionRequestState = QuestionRequestState.PENDING, +) + +data class QuestionItem( + val question: String, + val header: String, + val options: List, + val multiple: Boolean, + val custom: Boolean, +) + +data class QuestionOption( + val label: String, + val description: String, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index 54fe211c145..f43fd766456 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -1,8 +1,16 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.model.MessageListModel -import ai.kilocode.client.session.model.MessageModelEvent -import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionPhase +import ai.kilocode.client.session.model.StatusState +import ai.kilocode.client.session.model.message.Compaction +import ai.kilocode.client.session.model.message.Content +import ai.kilocode.client.session.model.message.Message +import ai.kilocode.client.session.model.message.Reasoning +import ai.kilocode.client.session.model.message.Text +import ai.kilocode.client.session.model.message.Tool +import ai.kilocode.client.session.model.message.ToolExecState import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.JBColor @@ -20,16 +28,12 @@ import javax.swing.border.MatteBorder * Scrollable panel displaying chat messages aligned to the top, * with an optional animated status indicator at the bottom. * - * Passive view — all rendering is driven by [MessageModelEvent]s - * from the [MessageListModel]. No public mutation methods. - * - * Inner panel uses [BoxLayout.Y_AXIS] for stacking, wrapped in a - * [BorderLayout.NORTH] so messages stay top-aligned when the scroll - * viewport is taller than the content. + * Passive view — all rendering is driven by [SessionModelEvent]s + * from the [SessionModel]. No public mutation methods. */ class MessageListUi( parent: Disposable, - private val model: MessageListModel, + private val model: SessionModel, ) : JPanel(BorderLayout()) { private val blocks = LinkedHashMap() @@ -61,22 +65,22 @@ class MessageListUi( model.addListener(parent) { event -> when (event) { - is MessageModelEvent.MessageAdded -> onAdded(event.info) - is MessageModelEvent.MessageRemoved -> onRemoved(event.id) - is MessageModelEvent.PartText -> onPartText(event.messageId, event.partId, event.text) - is MessageModelEvent.PartDelta -> onPartDelta(event.messageId, event.partId, event.delta) - is MessageModelEvent.Error -> onError(event.message) - is MessageModelEvent.StatusChanged -> onStatus(event.text) - is MessageModelEvent.HistoryLoaded -> onHistory() - is MessageModelEvent.Cleared -> onCleared() + is SessionModelEvent.MessageAdded -> onAdded(event.info) + is SessionModelEvent.MessageRemoved -> onRemoved(event.id) + is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) + is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) + is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) + is SessionModelEvent.PhaseChanged -> onPhase(event.phase) + is SessionModelEvent.HistoryLoaded -> onHistory() + is SessionModelEvent.Cleared -> onCleared() } } } - private fun onAdded(info: MessageDto) { - if (blocks.containsKey(info.id)) return + private fun onAdded(info: Message) { + if (blocks.containsKey(info.info.id)) return val block = MessageBlock(info) - blocks[info.id] = block + blocks[info.info.id] = block inner.add(block, inner.componentCount - 1) refresh() } @@ -87,48 +91,54 @@ class MessageListUi( refresh() } - private fun onPartText(messageId: String, partId: String, text: String) { - blocks[messageId]?.setText(partId, text) + private fun onContentAdded(messageId: String, content: Content) { + blocks[messageId]?.addContent(content) refresh() } - private fun onPartDelta(messageId: String, partId: String, delta: String) { - blocks[messageId]?.appendDelta(partId, delta) + private fun onContentUpdated(messageId: String, content: Content) { + blocks[messageId]?.updateContent(content) refresh() } - private fun onError(msg: String) { - val err = JBLabel(msg).apply { - foreground = JBColor.RED - font = JBUI.Fonts.label() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - inner.add(err, inner.componentCount - 1) + private fun onContentDelta(messageId: String, contentId: String, delta: String) { + blocks[messageId]?.appendDelta(contentId, delta) refresh() } - private fun onStatus(text: String?) { - if (text != null) { - label.text = text - spinner.isVisible = true - } else { - spinner.isVisible = false + private fun onPhase(phase: SessionPhase) { + when (phase) { + is SessionPhase.Working -> { + label.text = when (val s = phase.status) { + is StatusState.Thinking -> s.text + is StatusState.Working -> s.text + } + spinner.isVisible = true + } + is SessionPhase.Error -> { + spinner.isVisible = false + val err = JBLabel(phase.message).apply { + foreground = JBColor.RED + font = JBUI.Fonts.label() + border = JBUI.Borders.empty(4, 0) + alignmentX = LEFT_ALIGNMENT + } + inner.add(err, inner.componentCount - 1) + } + else -> { + spinner.isVisible = false + } } refresh() } private fun onHistory() { clear() - for (entry in model.entries()) { - val block = MessageBlock(entry.info) + for (entry in model.messages()) { + val block = MessageBlock(entry) blocks[entry.info.id] = block inner.add(block, inner.componentCount - 1) - for ((partId, text) in entry.parts) { - if (text.isNotEmpty()) { - block.setText(partId, text.toString()) - } - } + for ((_, content) in entry.parts) block.addContent(content) } refresh() } @@ -151,19 +161,16 @@ class MessageListUi( } } -/** - * A single message block — text content only, no role header. - * User messages get a thin top border as separator. - */ -private class MessageBlock(info: MessageDto) : JPanel() { - private val parts = LinkedHashMap() +private class MessageBlock(info: Message) : JPanel() { + private val areas = LinkedHashMap() + private val labels = LinkedHashMap() init { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false alignmentX = LEFT_ALIGNMENT - border = if (info.role == "user") { + border = if (info.info.role == "user") { JBUI.Borders.compound( MatteBorder(1, 0, 0, 0, JBColor.border()), JBUI.Borders.empty(8, 0, 4, 0), @@ -173,15 +180,53 @@ private class MessageBlock(info: MessageDto) : JPanel() { } } - fun setText(partID: String, text: String) { - val area = parts.getOrPut(partID) { createArea().also { add(it) } } - area.text = text + fun addContent(content: Content) { + when (content) { + is Text -> { + val area = createArea() + if (content.content.isNotEmpty()) area.text = content.content.toString() + areas[content.id] = area + add(area) + } + is Reasoning -> { + val area = createArea().apply { + foreground = UIUtil.getContextHelpForeground() + } + if (content.content.isNotEmpty()) area.text = content.content.toString() + areas[content.id] = area + add(area) + } + is Tool -> { + val lbl = createToolLabel(content) + labels[content.id] = lbl + add(lbl) + } + is Compaction -> { + val lbl = JBLabel("Context compacted").apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(4, 0) + alignmentX = LEFT_ALIGNMENT + } + labels[content.id] = lbl + add(lbl) + } + } revalidate() } - fun appendDelta(partID: String, delta: String) { - val area = parts.getOrPut(partID) { createArea().also { add(it) } } - area.append(delta) + fun updateContent(content: Content) { + when (content) { + is Text -> areas[content.id]?.text = content.content.toString() + is Reasoning -> areas[content.id]?.text = content.content.toString() + is Tool -> labels[content.id]?.text = toolText(content) + is Compaction -> {} + } + revalidate() + } + + fun appendDelta(contentId: String, delta: String) { + areas[contentId]?.append(delta) revalidate() } @@ -195,4 +240,21 @@ private class MessageBlock(info: MessageDto) : JPanel() { border = JBUI.Borders.empty() alignmentX = LEFT_ALIGNMENT } + + private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(2, 0) + alignmentX = LEFT_ALIGNMENT + } + + private fun toolText(content: Tool): String { + val icon = when (content.state) { + ToolExecState.PENDING -> "\u23F3" + ToolExecState.RUNNING -> "\u25B6" + ToolExecState.COMPLETED -> "\u2713" + ToolExecState.ERROR -> "\u2717" + } + return "$icon ${content.title ?: content.name}" + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index 8304c1a1a7c..5c8f5518df1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.model.SessionEvent import ai.kilocode.client.session.model.SessionManager +import ai.kilocode.client.session.model.SessionManagerEvent import ai.kilocode.client.session.model.SessionManagerListener import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.KiloAppStateDto @@ -30,7 +30,7 @@ import javax.swing.SwingConstants * Welcome panel showing app + workspace initialization progress. * * Pure view — listens to [SessionManager] events and reads - * [SessionState][ai.kilocode.client.session.model.SessionState] for data. + * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. * No coroutines, no service references. * * Uses icon+label rows for each resource being loaded. Icons act as @@ -113,15 +113,15 @@ class StatusPanel( model.addListener(this, this) } - override fun onEvent(event: SessionEvent) { + override fun onEvent(event: SessionManagerEvent) { when (event) { - is SessionEvent.AppChanged -> { + is SessionManagerEvent.AppChanged -> { renderApp(model.chat.app) revalidate() repaint() } - is SessionEvent.WorkspaceChanged -> { + is SessionManagerEvent.WorkspaceChanged -> { renderWorkspace(model.chat.workspace) revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index 8909ff584ab..08ecaf334e4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -13,7 +13,7 @@ class AppWatchingTest : SessionManagerTestBase() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) flush() - assertTrue(events.any { it is SessionEvent.AppChanged }) + assertTrue(events.any { it is SessionManagerEvent.AppChanged }) assertEquals(KiloAppStatusDto.READY, m.chat.app.status) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index d023ae891cc..8e3567c0d63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -36,6 +36,6 @@ class ConfigSelectionTest : SessionManagerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(events.any { it is SessionEvent.WorkspaceReady }) + assertTrue(events.any { it is SessionManagerEvent.WorkspaceReady }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index a94e266372c..8be12d9bad2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -1,21 +1,24 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.model.message.Text import ai.kilocode.rpc.dto.MessageWithPartsDto class HistoryLoadingTest : SessionManagerTestBase() { 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 part = part("prt1", "ses_test", "msg1", "text", text = "hello") + rpc.history.add(MessageWithPartsDto(m, listOf(part))) val model = model("ses_test") - val events = collect(model) + val events = collectModel(model) flush() - assertTrue(events.any { it is SessionEvent.HistoryLoaded }) + assertTrue(events.any { it is SessionModelEvent.HistoryLoaded }) assertNotNull(model.chat.message("msg1")) - assertEquals("hello", model.chat.part("msg1", "prt1")?.text?.toString()) + val content = model.chat.content("msg1", "prt1") + assertTrue(content is Text) + assertEquals("hello", (content as Text).content.toString()) } fun `test non-empty history shows messages view`() { @@ -25,7 +28,7 @@ class HistoryLoadingTest : SessionManagerTestBase() { val events = collect(model) flush() - assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) assertTrue(model.chat.showMessages) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index e4232d73876..297350fcf27 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer @@ -10,7 +11,7 @@ class ListenerLifecycleTest : SessionManagerTestBase() { val disposable = Disposer.newDisposable("listener-parent") Disposer.register(parent, disposable) - val events = mutableListOf() + val events = mutableListOf() m.addListener(disposable) { events.add(it) } edt { m.prompt("before") } @@ -27,8 +28,8 @@ class ListenerLifecycleTest : SessionManagerTestBase() { fun `test all listeners notified`() { val m = model() - val events1 = mutableListOf() - val events2 = mutableListOf() + val events1 = mutableListOf() + val events2 = mutableListOf() val d1 = Disposer.newDisposable("l1") val d2 = Disposer.newDisposable("l2") Disposer.register(parent, d1) @@ -45,16 +46,12 @@ class ListenerLifecycleTest : SessionManagerTestBase() { assertEquals(events1.map { it::class }, events2.map { it::class }) } - fun `test session status busy fires BusyChanged`() { - val m = model() - val events = collect(m) + fun `test session status busy fires PhaseChanged to Working`() { + val (_, _, model) = prompted() - edt { m.prompt("go") } + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) flush() - rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy", null)) - flush() - - assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) + assertTrue(model.any { it is SessionModelEvent.PhaseChanged && (it.phase is SessionPhase.Working) }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt deleted file mode 100644 index 83c226b304a..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListModelTest.kt +++ /dev/null @@ -1,291 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.rpc.dto.MessageDto -import ai.kilocode.rpc.dto.MessageTimeDto -import ai.kilocode.rpc.dto.PartDto -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.UsefulTestCase - -class MessageListModelTest : UsefulTestCase() { - - private lateinit var model: MessageListModel - private lateinit var parent: Disposable - private lateinit var events: MutableList - - override fun setUp() { - super.setUp() - parent = Disposer.newDisposable("test") - model = MessageListModel() - events = mutableListOf() - model.addListener(parent) { events.add(it) } - } - - override fun tearDown() { - try { - Disposer.dispose(parent) - } finally { - super.tearDown() - } - } - - // --- addMessage --- - - fun `test addMessage stores entry and fires MessageAdded`() { - val info = msg("m1", "user") - model.addMessage(info) - - assertNotNull(model.entry("m1")) - assertEquals(1, events.size) - val event = events[0] as MessageModelEvent.MessageAdded - assertEquals("m1", event.info.id) - } - - fun `test addMessage duplicate is ignored`() { - model.addMessage(msg("m1", "user")) - events.clear() - - model.addMessage(msg("m1", "user")) - - assertEquals(1, model.entries().size) - assertTrue(events.isEmpty()) - } - - // --- removeMessage --- - - fun `test removeMessage removes entry and fires MessageRemoved`() { - model.addMessage(msg("m1", "assistant")) - events.clear() - - model.removeMessage("m1") - - assertNull(model.entry("m1")) - assertEquals(1, events.size) - assertTrue(events[0] is MessageModelEvent.MessageRemoved) - assertEquals("m1", (events[0] as MessageModelEvent.MessageRemoved).id) - } - - fun `test removeMessage unknown id is noop`() { - model.removeMessage("unknown") - assertTrue(events.isEmpty()) - } - - // --- setPartText --- - - fun `test setPartText creates part and fires PartText`() { - model.addMessage(msg("m1", "assistant")) - events.clear() - - model.setPartText("m1", "p1", "hello") - - val entry = model.entry("m1")!! - assertEquals("hello", entry.parts["p1"].toString()) - assertEquals(1, events.size) - val event = events[0] as MessageModelEvent.PartText - assertEquals("m1", event.messageId) - assertEquals("p1", event.partId) - assertEquals("hello", event.text) - } - - fun `test setPartText replaces existing text`() { - model.addMessage(msg("m1", "assistant")) - model.setPartText("m1", "p1", "old") - events.clear() - - model.setPartText("m1", "p1", "new") - - assertEquals("new", model.entry("m1")!!.parts["p1"].toString()) - assertEquals(1, events.size) - } - - fun `test setPartText unknown message is noop`() { - model.setPartText("unknown", "p1", "text") - assertTrue(events.isEmpty()) - } - - // --- appendDelta --- - - fun `test appendDelta appends to existing part and fires PartDelta`() { - model.addMessage(msg("m1", "assistant")) - model.setPartText("m1", "p1", "hello ") - events.clear() - - model.appendDelta("m1", "p1", "world") - - assertEquals("hello world", model.entry("m1")!!.parts["p1"].toString()) - assertEquals(1, events.size) - val event = events[0] as MessageModelEvent.PartDelta - assertEquals("m1", event.messageId) - assertEquals("p1", event.partId) - assertEquals("world", event.delta) - } - - fun `test appendDelta creates part if missing`() { - model.addMessage(msg("m1", "assistant")) - events.clear() - - model.appendDelta("m1", "p1", "first") - - assertEquals("first", model.entry("m1")!!.parts["p1"].toString()) - assertEquals(1, events.size) - assertTrue(events[0] is MessageModelEvent.PartDelta) - } - - fun `test appendDelta unknown message is noop`() { - model.appendDelta("unknown", "p1", "delta") - assertTrue(events.isEmpty()) - } - - // --- addError --- - - fun `test addError stores error and fires Error`() { - model.addError("something broke") - - assertEquals(listOf("something broke"), model.errors) - assertEquals(1, events.size) - val event = events[0] as MessageModelEvent.Error - assertEquals("something broke", event.message) - } - - fun `test addError accumulates distinct errors`() { - model.addError("first") - model.addError("second") - - assertEquals(listOf("first", "second"), model.errors) - assertEquals(2, events.size) - } - - fun `test addError deduplicates consecutive identical errors`() { - model.addError("same") - model.addError("same") - model.addError("same") - - assertEquals(listOf("same"), model.errors) - assertEquals(1, events.size) - } - - fun `test addError allows same error after different one`() { - model.addError("a") - model.addError("b") - model.addError("a") - - assertEquals(listOf("a", "b", "a"), model.errors) - assertEquals(3, events.size) - } - - // --- setStatus --- - - fun `test setStatus stores status and fires StatusChanged`() { - model.setStatus("thinking") - - assertEquals("thinking", model.status) - assertEquals(1, events.size) - val event = events[0] as MessageModelEvent.StatusChanged - assertEquals("thinking", event.text) - } - - fun `test setStatus null hides status`() { - model.setStatus("working") - events.clear() - - model.setStatus(null) - - assertNull(model.status) - assertEquals(1, events.size) - assertNull((events[0] as MessageModelEvent.StatusChanged).text) - } - - // --- clear --- - - fun `test clear removes entries errors status and fires Cleared`() { - model.addMessage(msg("m1", "user")) - model.addError("oops") - model.setStatus("busy") - events.clear() - - model.clear() - - assertTrue(model.isEmpty()) - assertTrue(model.errors.isEmpty()) - assertNull(model.status) - assertEquals(1, events.size) - assertTrue(events[0] is MessageModelEvent.Cleared) - } - - // --- loadHistory --- - - fun `test loadHistory populates entries and fires HistoryLoaded`() { - model.addMessage(msg("old", "user")) - events.clear() - - val part = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "hello") - val history = listOf( - MessageData( - msg("m1", "assistant"), - linkedMapOf("p1" to PartData(part, StringBuilder("hello"))), - ), - ) - - model.loadHistory(history) - - assertNull(model.entry("old")) - assertNotNull(model.entry("m1")) - assertEquals("hello", model.entry("m1")!!.parts["p1"].toString()) - assertEquals(1, events.size) - assertTrue(events[0] is MessageModelEvent.HistoryLoaded) - } - - fun `test loadHistory skips non-text parts`() { - val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") - val tool = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "tool", text = "hidden") - val history = listOf( - MessageData( - msg("m1", "assistant"), - linkedMapOf( - "p1" to PartData(text, StringBuilder("visible")), - "p2" to PartData(tool, StringBuilder("hidden")), - ), - ), - ) - - model.loadHistory(history) - - val entry = model.entry("m1")!! - assertTrue(entry.parts.containsKey("p1")) - assertFalse(entry.parts.containsKey("p2")) - } - - // --- Listener lifecycle --- - - fun `test listener auto-removed on dispose`() { - val child = Disposer.newDisposable("child") - Disposer.register(parent, child) - - val extra = mutableListOf() - model.addListener(child) { extra.add(it) } - - model.addMessage(msg("m1", "user")) - assertEquals(1, extra.size) - - Disposer.dispose(child) - extra.clear() - - model.addMessage(msg("m2", "user")) - assertTrue(extra.isEmpty()) - } - - // --- Helpers --- - - private fun msg(id: String, role: String) = MessageDto( - id = id, - sessionID = "ses", - role = role, - time = MessageTimeDto(created = 0.0), - ) - - private fun linkedMapOf(vararg pairs: Pair): LinkedHashMap { - val map = LinkedHashMap() - for ((k, v) in pairs) map[k] = v - return map - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 850cea19c40..438129bd904 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -1,21 +1,22 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.model.message.Text import ai.kilocode.rpc.dto.ChatEventDto class MessageListTest : SessionManagerTestBase() { fun `test MessageUpdated adds message to ChatModel`() { - val (m, events) = prompted() + val (m, _, model) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) flush() - assertTrue(events.any { it is SessionEvent.MessageAdded && it.id == "msg1" }) + assertTrue(model.any { it is SessionModelEvent.MessageAdded }) assertNotNull(m.chat.message("msg1")) } - fun `test PartUpdated text fires PartUpdated event`() { - val (m, events) = prompted() + fun `test PartUpdated text updates ChatModel`() { + val (m, _, model) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) flush() @@ -23,11 +24,13 @@ class MessageListTest : SessionManagerTestBase() { emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) flush() - assertTrue(events.any { it is SessionEvent.PartUpdated && it.messageId == "msg1" && it.partId == "prt1" }) + assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) + val p = m.chat.content("msg1", "prt1") + assertTrue(p is Text) } fun `test PartDelta appends text to ChatModel`() { - val (m, _) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) flush() @@ -36,13 +39,14 @@ class MessageListTest : SessionManagerTestBase() { emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) flush() - val p = m.chat.part("msg1", "prt1") + val p = m.chat.content("msg1", "prt1") assertNotNull(p) - assertEquals("hello world", p!!.text.toString()) + assertTrue(p is Text) + assertEquals("hello world", (p as Text).content.toString()) } fun `test MessageRemoved removes from ChatModel`() { - val (m, _) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt index 857b5608416..bc285118735 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt @@ -12,7 +12,7 @@ class SessionCreationTest : SessionManagerTestBase() { 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 }) + assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) } fun `test prompt reuses existing session`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt index a57e0908cd1..39b23b9e002 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt @@ -80,9 +80,9 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { // ------ Event collection ------ - /** Attach a listener that collects events and asserts EDT. */ - protected fun collect(m: SessionManager): MutableList { - val events = mutableListOf() + /** Attach a listener that collects lifecycle events and asserts EDT. */ + protected fun collect(m: SessionManager): MutableList { + val events = mutableListOf() val disposable = Disposer.newDisposable("listener") Disposer.register(parent, disposable) m.addListener(disposable) { event -> @@ -92,6 +92,17 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { return events } + /** Attach a listener that collects model events (messages, parts, phase). */ + protected fun collectModel(m: SessionManager): MutableList { + val events = mutableListOf() + val disposable = Disposer.newDisposable("model-listener") + Disposer.register(parent, disposable) + m.chat.addListener(disposable) { event -> + events.add(event) + } + return events + } + // ------ EDT + coroutine helpers ------ /** Let coroutines settle, then drain all pending EDT events. */ @@ -111,13 +122,14 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { rpc.events.emit(event) } - /** Create a model, attach listener, send initial prompt, and flush. */ - protected fun prompted(): Pair> { + /** Create a model, attach both listeners, send initial prompt, and flush. */ + protected fun prompted(): Triple, MutableList> { val m = model() val events = collect(m) + val model = collectModel(m) edt { m.prompt("go") } flush() - return m to events + return Triple(m, events, model) } // ------ DTO factories ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt new file mode 100644 index 00000000000..1a30d9dad9f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -0,0 +1,397 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.session.model.message.Compaction +import ai.kilocode.client.session.model.message.Reasoning +import ai.kilocode.client.session.model.message.Text +import ai.kilocode.client.session.model.message.Tool +import ai.kilocode.client.session.model.message.ToolExecState +import ai.kilocode.client.session.model.permission.Permission +import ai.kilocode.client.session.model.permission.PermissionFileDiff +import ai.kilocode.client.session.model.permission.PermissionMeta +import ai.kilocode.client.session.model.permission.PermissionRequestState +import ai.kilocode.client.session.model.question.Question +import ai.kilocode.client.session.model.question.QuestionItem +import ai.kilocode.client.session.model.question.QuestionOption +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.UsefulTestCase + +class SessionModelTest : UsefulTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var events: MutableList + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + events = mutableListOf() + model.addListener(parent) { events.add(it) } + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + fun `test initial app and workspace state`() { + assertEquals(KiloAppStatusDto.DISCONNECTED, model.app.status) + assertEquals(KiloWorkspaceStatusDto.PENDING, model.workspace.status) + assertEquals(SessionPhase.Idle, model.phase) + } + + fun `test addMessage stores entry and fires MessageAdded`() { + model.addMessage(msg("m1", "user")) + + val item = model.message("m1") + assertNotNull(item) + assertEquals(1, events.size) + val event = events[0] as SessionModelEvent.MessageAdded + assertEquals("m1", event.info.info.id) + } + + fun `test addMessage duplicate is ignored`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.addMessage(msg("m1", "user")) + + assertEquals(1, model.messages().size) + assertTrue(events.isEmpty()) + } + + fun `test removeMessage removes entry and fires MessageRemoved`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.removeMessage("m1") + + assertNull(model.message("m1")) + assertEquals(1, events.size) + assertEquals("m1", (events[0] as SessionModelEvent.MessageRemoved).id) + } + + fun `test removeMessage unknown id is noop`() { + model.removeMessage("unknown") + assertTrue(events.isEmpty()) + } + + fun `test updateContent text creates Text content and fires ContentAdded`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Text) + assertEquals("hello", (p as Text).content.toString()) + val event = events.single() as SessionModelEvent.ContentAdded + assertEquals("m1", event.messageId) + } + + fun `test updateContent text replaces content and fires ContentUpdated`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "old")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "new")) + + assertEquals("new", (model.message("m1")!!.parts["p1"] as Text).content.toString()) + assertTrue(events.single() is SessionModelEvent.ContentUpdated) + } + + fun `test updateContent reasoning creates Reasoning content`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "reasoning", text = "thinking")) + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Reasoning) + assertEquals("thinking", (p as Reasoning).content.toString()) + } + + fun `test updateContent tool creates Tool content and tracks state`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running", title = "ls")) + + val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals("bash", p.name) + assertEquals(ToolExecState.RUNNING, p.state) + assertEquals("ls", p.title) + } + + fun `test updateContent tool updates lifecycle`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "completed")) + + val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals(ToolExecState.COMPLETED, p.state) + assertTrue(events.single() is SessionModelEvent.ContentUpdated) + } + + fun `test updateContent compaction creates Compaction content`() { + model.addMessage(msg("m1", "assistant")) + + model.updateContent("m1", part("p1", "m1", "compaction")) + + assertTrue(model.message("m1")!!.parts["p1"] is Compaction) + } + + fun `test updateContent unknown type ignored`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "step-start")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test appendDelta appends to existing text content`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello ")) + events.clear() + + model.appendDelta("m1", "p1", "world") + + assertEquals("hello world", (model.message("m1")!!.parts["p1"] as Text).content.toString()) + val event = events.single() as SessionModelEvent.ContentDelta + assertEquals("p1", event.contentId) + assertEquals("world", event.delta) + } + + fun `test appendDelta appends to reasoning content`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "reasoning", text = "hmm ")) + events.clear() + + model.appendDelta("m1", "p1", "interesting") + + assertEquals("hmm interesting", (model.message("m1")!!.parts["p1"] as Reasoning).content.toString()) + } + + fun `test appendDelta creates text content if missing`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.appendDelta("m1", "p1", "first") + + val p = model.message("m1")!!.parts["p1"] + assertTrue(p is Text) + assertEquals("first", (p as Text).content.toString()) + assertEquals(2, events.size) + assertTrue(events[0] is SessionModelEvent.ContentAdded) + assertTrue(events[1] is SessionModelEvent.ContentDelta) + } + + fun `test appendDelta on tool content is noop`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running")) + events.clear() + + model.appendDelta("m1", "p1", "text") + + assertTrue(events.isEmpty()) + } + + fun `test setPhase stores phase and fires PhaseChanged`() { + val phase = SessionPhase.Working(StatusState.Thinking("thinking")) + model.setPhase(phase) + + assertEquals(phase, model.phase) + assertEquals(phase, (events.single() as SessionModelEvent.PhaseChanged).phase) + } + + fun `test setPhase to Error stores error data`() { + model.setPhase(SessionPhase.Error("something broke", "timeout")) + + val phase = model.phase as SessionPhase.Error + assertEquals("something broke", phase.message) + assertEquals("timeout", phase.kind) + } + + fun `test setPhase to Prompting with question`() { + val q = question("q1") + model.setPhase(SessionPhase.Prompting(PromptState.Asking("q1", q))) + + val phase = model.phase as SessionPhase.Prompting + assertTrue(phase.prompt is PromptState.Asking) + } + + fun `test setPhase to Prompting with permission`() { + val p = permission("p1") + model.setPhase(SessionPhase.Prompting(PromptState.Permitting("p1", p))) + + val phase = model.phase as SessionPhase.Prompting + assertTrue(phase.prompt is PromptState.Permitting) + } + + fun `test question tool ref is stored in Prompting phase`() { + val q = Question( + id = "q1", + items = listOf(QuestionItem("Pick one", "Choice", listOf(QuestionOption("A", "Option A")), false, true)), + tool = ToolCallRef("msg1", "call1"), + ) + + model.setPhase(SessionPhase.Prompting(PromptState.Asking("q1", q))) + + val prompt = (model.phase as SessionPhase.Prompting).prompt as PromptState.Asking + val ref = prompt.question.tool + assertNotNull(ref) + assertEquals("msg1", ref!!.messageId) + assertEquals("call1", ref.callId) + } + + fun `test permission fields are preserved in Prompting phase`() { + val p = Permission( + id = "p1", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = listOf("src/**"), + meta = PermissionMeta( + rules = listOf("src/**"), + diff = "patch", + filePath = "src/A.kt", + fileDiff = PermissionFileDiff("src/A.kt", additions = 2, deletions = 1), + raw = mapOf("kind" to "edit"), + ), + message = "Allow edit?", + tool = ToolCallRef("msg1", "call1"), + state = PermissionRequestState.RESPONDING, + ) + + model.setPhase(SessionPhase.Prompting(PromptState.Permitting("p1", p))) + + val prompt = (model.phase as SessionPhase.Prompting).prompt as PromptState.Permitting + assertEquals(listOf("*.kt"), prompt.permission.patterns) + assertEquals(listOf("src/**"), prompt.permission.always) + assertEquals("src/A.kt", prompt.permission.meta.filePath) + assertEquals(PermissionRequestState.RESPONDING, prompt.permission.state) + assertEquals("msg1", prompt.permission.tool!!.messageId) + } + + fun `test loadHistory populates typed contents and fires HistoryLoaded`() { + model.addMessage(msg("old", "user")) + events.clear() + + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "hello") + val tool = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "tool", tool = "bash", state = "completed", title = "ls") + + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, tool)))) + + assertNull(model.message("old")) + val entry = model.message("m1")!! + assertTrue(entry.parts["p1"] is Text) + assertEquals("hello", (entry.parts["p1"] as Text).content.toString()) + assertTrue(entry.parts["p2"] is Tool) + assertEquals(ToolExecState.COMPLETED, (entry.parts["p2"] as Tool).state) + assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + } + + fun `test loadHistory skips unknown content types`() { + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") + val snapshot = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "snapshot") + + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, snapshot)))) + + val entry = model.message("m1")!! + assertTrue(entry.parts.containsKey("p1")) + assertFalse(entry.parts.containsKey("p2")) + } + + fun `test clear resets messages and phase`() { + model.addMessage(msg("m1", "user")) + model.setPhase(SessionPhase.Working(StatusState.Working("busy"))) + model.app = KiloAppStateDto(KiloAppStatusDto.READY) + model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + events.clear() + + model.clear() + + assertTrue(model.isEmpty()) + assertEquals(SessionPhase.Idle, model.phase) + assertTrue(events.single() is SessionModelEvent.Cleared) + } + + fun `test listener auto removed on dispose`() { + val child = Disposer.newDisposable("child") + Disposer.register(parent, child) + + val extra = mutableListOf() + model.addListener(child) { extra.add(it) } + + model.addMessage(msg("m1", "user")) + assertEquals(1, extra.size) + + Disposer.dispose(child) + extra.clear() + + model.addMessage(msg("m2", "user")) + assertTrue(extra.isEmpty()) + } + + private fun msg(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun part( + id: String, + mid: String, + type: String, + text: String? = null, + tool: String? = null, + state: String? = null, + title: String? = null, + ) = PartDto( + id = id, + sessionID = "ses", + messageID = mid, + type = type, + text = text, + tool = tool, + state = state, + title = title, + ) + + private fun question(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Which option?", + header = "Pick", + options = listOf(QuestionOption("A", "Option A"), QuestionOption("B", "Option B")), + multiple = false, + custom = true, + ), + ), + ) + + private fun permission(id: String) = Permission( + id = id, + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt index 832911f5bfe..ffbb4ff8dd7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -6,7 +6,7 @@ import ai.kilocode.rpc.dto.ChatEventDto class StatusComputationTest : SessionManagerTestBase() { fun `test status shows tool-specific text`() { - val (_, events) = prompted() + val (_, _, model) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() @@ -17,14 +17,17 @@ class StatusComputationTest : SessionManagerTestBase() { emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) flush() - val status = events.filterIsInstance() - .lastOrNull { it.text != null && it.text != KiloBundle.message("session.status.considering") } - assertNotNull(status) - assertEquals(KiloBundle.message("session.status.commands"), status!!.text) + val phase = model.filterIsInstance() + .mapNotNull { it.phase as? SessionPhase.Working } + .lastOrNull { it.status is StatusState.Working } + + assertNotNull(phase) + val text = (phase!!.status as StatusState.Working).text + assertEquals(KiloBundle.message("session.status.commands"), text) } - fun `test PartUpdated after TurnClose does not fire StatusChanged`() { - val (_, events) = prompted() + fun `test PartUpdated after TurnClose does not fire PhaseChanged`() { + val (_, _, model) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) flush() @@ -33,12 +36,12 @@ class StatusComputationTest : SessionManagerTestBase() { emit(ChatEventDto.TurnClose("ses_test", "completed")) flush() - val before = events.filterIsInstance().size + val before = model.filterIsInstance().size emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) flush() - val after = events.filterIsInstance().size + val after = model.filterIsInstance().size assertEquals(before, after) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt index 965a20c40f1..8d21837f0ec 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -1,53 +1,50 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto class TurnLifecycleTest : SessionManagerTestBase() { - fun `test TurnOpen fires BusyChanged true`() { - val (_, events) = prompted() + fun `test TurnOpen fires PhaseChanged to Working`() { + val (_, _, model) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() - assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) - assertTrue(events.any { it is SessionEvent.StatusChanged && it.text == KiloBundle.message("session.status.considering") }) + val phase = model.filterIsInstance().lastOrNull()?.phase + assertTrue(phase is SessionPhase.Working) } - fun `test TurnClose fires BusyChanged false and clears status`() { - val (_, events) = prompted() + fun `test TurnClose fires PhaseChanged to Idle`() { + val (_, _, model) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() emit(ChatEventDto.TurnClose("ses_test", "completed")) flush() - val last = events.filterIsInstance().last() - assertFalse(last.busy) - val status = events.filterIsInstance().last() - assertNull(status.text) + val phase = model.filterIsInstance().last().phase + assertEquals(SessionPhase.Idle, phase) } - fun `test Error fires Error event with message`() { - val (_, events) = prompted() + fun `test Error fires PhaseChanged to Error`() { + val (_, _, model) = prompted() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) flush() - val err = events.filterIsInstance().firstOrNull() - assertNotNull(err) - assertEquals("Bad Request", err!!.message) + val phase = model.filterIsInstance().last().phase + assertTrue(phase is SessionPhase.Error) + assertEquals("Bad Request", (phase as SessionPhase.Error).message) } fun `test Error with null message falls back to type`() { - val (_, events) = prompted() + val (_, _, model) = prompted() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) flush() - val err = events.filterIsInstance().first() - assertEquals("timeout", err.message) + val phase = model.filterIsInstance().last().phase as SessionPhase.Error + assertEquals("timeout", phase.message) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt index cb45256231e..6a0a8cf0a63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt @@ -9,7 +9,7 @@ class ViewSwitchingTest : SessionManagerTestBase() { edt { m.prompt("hello") } flush() - assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) } fun `test ViewChanged not fired twice`() { @@ -21,6 +21,6 @@ class ViewSwitchingTest : SessionManagerTestBase() { edt { m.prompt("second") } flush() - assertEquals(1, events.count { it is SessionEvent.ViewChanged && it.show }) + assertEquals(1, events.count { it is SessionManagerEvent.ViewChanged && it.show }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt index f142abb1ba2..a47f779d47d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -15,7 +15,7 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { 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 }) + assertTrue(events.any { it is SessionManagerEvent.WorkspaceReady }) } fun `test workspace ready sets default agent and model`() { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 06ead59e46d..c04d6fa9e2d 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -132,8 +132,94 @@ sealed class ChatEventDto { val sessionID: String, val messageID: String, ) : ChatEventDto() + + @Serializable + @SerialName("permission.asked") + data class PermissionAsked( + val sessionID: String, + val request: PermissionRequestDto, + ) : ChatEventDto() + + @Serializable + @SerialName("permission.replied") + data class PermissionReplied( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("question.asked") + data class QuestionAsked( + val sessionID: String, + val request: QuestionRequestDto, + ) : ChatEventDto() + + @Serializable + @SerialName("question.replied") + data class QuestionReplied( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("question.rejected") + data class QuestionRejected( + val sessionID: String, + val requestID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.status") + data class SessionStatusChanged( + val sessionID: String, + val status: SessionStatusDto, + ) : ChatEventDto() } +// --- Permission DTOs --- + +@Serializable +data class PermissionRequestDto( + val id: String, + val sessionID: String, + val permission: String, + val patterns: List, + val metadata: Map = emptyMap(), + val always: List = emptyList(), + val tool: ToolRefDto? = null, +) + +@Serializable +data class ToolRefDto( + val messageID: String, + val callID: String, +) + +// --- Question DTOs --- + +@Serializable +data class QuestionRequestDto( + val id: String, + val sessionID: String, + val questions: List, + val tool: ToolRefDto? = null, +) + +@Serializable +data class QuestionInfoDto( + val question: String, + val header: String, + val options: List = emptyList(), + val multiple: Boolean = false, + val custom: Boolean = true, +) + +@Serializable +data class QuestionOptionDto( + val label: String, + val description: String, +) + // --- Config Update --- @Serializable From 83c00da43b8757f5b91fcfd9a958d65182e06e9b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 15:36:20 -0400 Subject: [PATCH 08/27] refactor(jetbrains): consolidate frontend message content models Co-locate message content types and tool refs so the frontend session model lives under one package. Build Message directly from MessageDto to remove the redundant MessageInfo wrapper. --- .../client/session/model/PromptState.kt | 4 +-- .../client/session/model/SessionManager.kt | 11 +++--- .../client/session/model/SessionModel.kt | 25 +++++++------- .../client/session/model/SessionModelEvent.kt | 4 +-- .../client/session/model/ToolCallRef.kt | 6 ---- .../Content.kt => content/Message.kt} | 16 ++++++++- .../{permission => content}/Permission.kt | 4 +-- .../model/{question => content}/Question.kt | 4 +-- .../client/session/model/message/Message.kt | 34 ------------------- .../client/session/ui/MessageListUi.kt | 14 ++++---- .../session/model/HistoryLoadingTest.kt | 2 +- .../client/session/model/MessageListTest.kt | 2 +- .../client/session/model/SessionModelTest.kt | 25 +++++++------- 13 files changed, 61 insertions(+), 90 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{message/Content.kt => content/Message.kt} (67%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{permission => content}/Permission.kt (89%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{question => content}/Question.kt (82%) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt index d82f794af5f..94cce22f2d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.permission.Permission -import ai.kilocode.client.session.model.question.Question +import ai.kilocode.client.session.model.content.Permission +import ai.kilocode.client.session.model.content.Question sealed class PromptState { abstract val id: String diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt index e9b078ece77..ebb18df2580 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt @@ -4,11 +4,12 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.permission.Permission -import ai.kilocode.client.session.model.permission.PermissionMeta -import ai.kilocode.client.session.model.question.Question -import ai.kilocode.client.session.model.question.QuestionItem -import ai.kilocode.client.session.model.question.QuestionOption +import ai.kilocode.client.session.model.content.Permission +import ai.kilocode.client.session.model.content.PermissionMeta +import ai.kilocode.client.session.model.content.Question +import ai.kilocode.client.session.model.content.QuestionItem +import ai.kilocode.client.session.model.content.QuestionOption +import ai.kilocode.client.session.model.content.ToolCallRef import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index fe2294e0a6b..3cb28764e37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -1,13 +1,12 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.message.Compaction -import ai.kilocode.client.session.model.message.Content -import ai.kilocode.client.session.model.message.Message -import ai.kilocode.client.session.model.message.Reasoning -import ai.kilocode.client.session.model.message.Text -import ai.kilocode.client.session.model.message.Tool -import ai.kilocode.client.session.model.message.ToolExecState -import ai.kilocode.client.session.model.message.toInfo +import ai.kilocode.client.session.model.content.Compaction +import ai.kilocode.client.session.model.content.Content +import ai.kilocode.client.session.model.content.Message +import ai.kilocode.client.session.model.content.Reasoning +import ai.kilocode.client.session.model.content.Text +import ai.kilocode.client.session.model.content.Tool +import ai.kilocode.client.session.model.content.ToolExecState import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -57,10 +56,10 @@ class SessionModel { fun isEmpty(): Boolean = entries.isEmpty() - fun addMessage(info: MessageDto): Message? { - if (entries.containsKey(info.id)) return null - val msg = Message(info.toInfo()) - entries[info.id] = msg + fun addMessage(dto: MessageDto): Message? { + if (entries.containsKey(dto.id)) return null + val msg = Message(dto) + entries[dto.id] = msg fire(SessionModelEvent.MessageAdded(msg)) return msg } @@ -110,7 +109,7 @@ class SessionModel { entries.clear() phase = SessionPhase.Idle for (msg in history) { - val item = Message(msg.info.toInfo()) + val item = Message(msg.info) for (part in msg.parts) { val content = fromDto(part, part.text) if (content != null) item.parts[content.id] = content diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index f7a5e041355..5a6977b74bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.message.Content -import ai.kilocode.client.session.model.message.Message +import ai.kilocode.client.session.model.content.Content +import ai.kilocode.client.session.model.content.Message /** * Change events fired by [SessionModel]. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt deleted file mode 100644 index 8f5227ddee1..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/ToolCallRef.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ai.kilocode.client.session.model - -data class ToolCallRef( - val messageId: String, - val callId: String, -) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt similarity index 67% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt index 57962eb6991..abea8017008 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Content.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt @@ -1,4 +1,13 @@ -package ai.kilocode.client.session.model.message +package ai.kilocode.client.session.model.content + +import ai.kilocode.rpc.dto.MessageDto + +/** A single message with its typed contents. */ +class Message( + val info: MessageDto, +) { + val parts = LinkedHashMap() +} /** Typed content within a message. */ sealed class Content(val id: String) @@ -23,3 +32,8 @@ class Tool(id: String, val name: String) : Content(id) { class Compaction(id: String) : Content(id) enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR } + +data class ToolCallRef( + val messageId: String, + val callId: String, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt similarity index 89% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt index 0ffbdf6513b..dda56342b60 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/permission/Permission.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model.permission - -import ai.kilocode.client.session.model.ToolCallRef +package ai.kilocode.client.session.model.content enum class PermissionRequestState { PENDING, RESPONDING, RESOLVED, ERROR } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt similarity index 82% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt index d5ea7541b58..720d6727c04 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/question/Question.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model.question - -import ai.kilocode.client.session.model.ToolCallRef +package ai.kilocode.client.session.model.content enum class QuestionRequestState { PENDING, RESPONDING, ERROR } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt deleted file mode 100644 index 386fae89c52..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/message/Message.kt +++ /dev/null @@ -1,34 +0,0 @@ -package ai.kilocode.client.session.model.message - -import ai.kilocode.rpc.dto.MessageDto - -data class MessageInfo( - val id: String, - val role: String, - val created: Double, - val completed: Double? = null, - val agent: String? = null, - val providerId: String? = null, - val modelId: String? = null, - val parentId: String? = null, - val cost: Double? = null, -) - -/** A single message with its typed contents. */ -class Message( - val info: MessageInfo, -) { - val parts = LinkedHashMap() -} - -fun MessageDto.toInfo() = MessageInfo( - id = id, - role = role, - created = time.created, - completed = time.completed, - agent = agent, - providerId = providerID, - modelId = modelID, - parentId = parentID, - cost = cost, -) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index f43fd766456..b04d74c768c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -4,13 +4,13 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionPhase import ai.kilocode.client.session.model.StatusState -import ai.kilocode.client.session.model.message.Compaction -import ai.kilocode.client.session.model.message.Content -import ai.kilocode.client.session.model.message.Message -import ai.kilocode.client.session.model.message.Reasoning -import ai.kilocode.client.session.model.message.Text -import ai.kilocode.client.session.model.message.Tool -import ai.kilocode.client.session.model.message.ToolExecState +import ai.kilocode.client.session.model.content.Compaction +import ai.kilocode.client.session.model.content.Content +import ai.kilocode.client.session.model.content.Message +import ai.kilocode.client.session.model.content.Reasoning +import ai.kilocode.client.session.model.content.Text +import ai.kilocode.client.session.model.content.Tool +import ai.kilocode.client.session.model.content.ToolExecState import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.JBColor diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index 8be12d9bad2..a44d4184030 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.message.Text +import ai.kilocode.client.session.model.content.Text import ai.kilocode.rpc.dto.MessageWithPartsDto class HistoryLoadingTest : SessionManagerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 438129bd904..2441fb120ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.message.Text +import ai.kilocode.client.session.model.content.Text import ai.kilocode.rpc.dto.ChatEventDto class MessageListTest : SessionManagerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 1a30d9dad9f..463d08c095d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -1,17 +1,18 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.message.Compaction -import ai.kilocode.client.session.model.message.Reasoning -import ai.kilocode.client.session.model.message.Text -import ai.kilocode.client.session.model.message.Tool -import ai.kilocode.client.session.model.message.ToolExecState -import ai.kilocode.client.session.model.permission.Permission -import ai.kilocode.client.session.model.permission.PermissionFileDiff -import ai.kilocode.client.session.model.permission.PermissionMeta -import ai.kilocode.client.session.model.permission.PermissionRequestState -import ai.kilocode.client.session.model.question.Question -import ai.kilocode.client.session.model.question.QuestionItem -import ai.kilocode.client.session.model.question.QuestionOption +import ai.kilocode.client.session.model.content.Compaction +import ai.kilocode.client.session.model.content.Reasoning +import ai.kilocode.client.session.model.content.Text +import ai.kilocode.client.session.model.content.Tool +import ai.kilocode.client.session.model.content.ToolExecState +import ai.kilocode.client.session.model.content.Permission +import ai.kilocode.client.session.model.content.PermissionFileDiff +import ai.kilocode.client.session.model.content.PermissionMeta +import ai.kilocode.client.session.model.content.PermissionRequestState +import ai.kilocode.client.session.model.content.Question +import ai.kilocode.client.session.model.content.QuestionItem +import ai.kilocode.client.session.model.content.QuestionOption +import ai.kilocode.client.session.model.content.ToolCallRef import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto From afdab7c912b9107bc67ea0ef0ec2d407b43ee1da Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 15:46:50 -0400 Subject: [PATCH 09/27] refactor(jetbrains): flatten frontend session state model Replace the nested phase, prompt, and status types with a single SessionState so the UI reads one explicit source of truth. Update manager transitions and tests to use direct busy, prompt, and error states. --- .../ai/kilocode/client/session/SessionUi.kt | 14 ++- .../client/session/model/PromptState.kt | 18 ---- .../client/session/model/SessionManager.kt | 68 +++++++------ .../client/session/model/SessionModel.kt | 12 +-- .../client/session/model/SessionModelEvent.kt | 2 +- .../client/session/model/SessionPhase.kt | 26 ----- .../client/session/model/SessionState.kt | 21 ++++ .../client/session/model/StatusState.kt | 7 -- .../client/session/ui/MessageListUi.kt | 20 ++-- .../session/model/ListenerLifecycleTest.kt | 4 +- .../session/model/PromptLifecycleTest.kt | 96 +++++++++++++++++++ .../client/session/model/SessionModelTest.kt | 67 +++++++------ .../session/model/StatusComputationTest.kt | 16 ++-- .../client/session/model/TurnLifecycleTest.kt | 24 ++--- 14 files changed, 230 insertions(+), 165 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index c0ab0ea8cea..d9334bc49f7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -6,7 +6,7 @@ import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionManager import ai.kilocode.client.session.model.SessionManagerEvent import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionPhase +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.MessageListUi import ai.kilocode.client.session.ui.PromptPanel @@ -87,11 +87,11 @@ class SessionUi( } } - // Model events — phase drives the prompt busy state + // Model events — state drives the prompt busy state model.chat.addListener(this) { event -> when (event) { - is SessionModelEvent.PhaseChanged -> { - val busy = event.phase is SessionPhase.Working || event.phase is SessionPhase.Prompting + is SessionModelEvent.StateChanged -> { + val busy = event.state.isBusy() prompt.setBusy(busy) scrollToBottom() } @@ -118,3 +118,9 @@ class SessionUi( override fun dispose() {} } + +private fun SessionState.isBusy(): Boolean = when (this) { + is SessionState.Idle -> false + is SessionState.Error -> false + else -> true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt deleted file mode 100644 index 94cce22f2d2..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.session.model.content.Permission -import ai.kilocode.client.session.model.content.Question - -sealed class PromptState { - abstract val id: String - - data class Asking( - override val id: String, - val question: Question, - ) : PromptState() - - data class Permitting( - override val id: String, - val permission: Permission, - ) : PromptState() -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt index ebb18df2580..0d90922efbf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch * event subscription happens before the prompt is sent, eliminating races. * * Owns [SessionModel] — the single source of truth for chat content and - * phase. UIs observe model changes via [SessionModelEvent] on [chat]. + * state. UIs observe model changes via [SessionModelEvent] on [chat]. * Lifecycle events (app/workspace state, view switching) are published * via [SessionManagerEvent] to registered listeners. */ @@ -84,7 +84,7 @@ class SessionManager( LOG.warn("prompt failed", e) edt { val msg = e.message ?: KiloBundle.message("session.error.prompt") - chat.setPhase(SessionPhase.Error(msg)) + chat.setState(SessionState.Error(msg)) } } } @@ -211,8 +211,8 @@ class SessionManager( partType = event.part.type tool = event.part.tool chat.updateContent(event.part.messageID, event.part) - if (chat.phase is SessionPhase.Working) { - chat.setPhase(SessionPhase.Working(status())) + if (chat.state is SessionState.Busy) { + chat.setState(SessionState.Busy(status())) } } @@ -225,20 +225,20 @@ class SessionManager( is ChatEventDto.TurnOpen -> { partType = null tool = null - chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) + chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } is ChatEventDto.TurnClose -> { partType = null tool = null - chat.setPhase(SessionPhase.Idle) + chat.setState(SessionState.Idle) } is ChatEventDto.Error -> { partType = null tool = null val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - chat.setPhase(SessionPhase.Error(msg, event.error?.type)) + chat.setState(SessionState.Error(msg, event.error?.type)) } is ChatEventDto.MessageRemoved -> { @@ -246,45 +246,45 @@ class SessionManager( } is ChatEventDto.PermissionAsked -> { - chat.setPhase(SessionPhase.Prompting(toPermissionPrompt(event.request))) + chat.setState(SessionState.AwaitingPermission(toPermission(event.request))) } is ChatEventDto.PermissionReplied -> { - if (chat.phase is SessionPhase.Prompting) { - chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) + if (chat.state is SessionState.AwaitingPermission) { + chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } is ChatEventDto.QuestionAsked -> { - chat.setPhase(SessionPhase.Prompting(toQuestionPrompt(event.request))) + chat.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) } is ChatEventDto.QuestionReplied -> { - if (chat.phase is SessionPhase.Prompting) { - chat.setPhase(SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering")))) + if (chat.state is SessionState.AwaitingQuestion) { + chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } is ChatEventDto.QuestionRejected -> { - if (chat.phase is SessionPhase.Prompting) { - chat.setPhase(SessionPhase.Idle) + if (chat.state is SessionState.AwaitingQuestion) { + chat.setState(SessionState.Idle) } } is ChatEventDto.SessionStatusChanged -> { - val phase = when (event.status.type) { - "idle" -> SessionPhase.Idle + val state = when (event.status.type) { + "idle" -> SessionState.Idle "busy" -> { - val current = chat.phase - if (current is SessionPhase.Idle || current is SessionPhase.Error) - SessionPhase.Working(StatusState.Thinking(KiloBundle.message("session.status.considering"))) + val current = chat.state + if (current is SessionState.Idle || current is SessionState.Error) + SessionState.Busy(KiloBundle.message("session.status.considering")) else return // already in a more specific phase } - "retry" -> SessionPhase.Retry(0, event.status.message ?: "", 0) - "offline" -> SessionPhase.Offline("", event.status.message ?: "") + "retry" -> SessionState.Retry(event.status.message ?: "", 0, 0) + "offline" -> SessionState.Offline(event.status.message ?: "", "") else -> return } - chat.setPhase(phase) + chat.setState(state) } } } @@ -296,10 +296,10 @@ class SessionManager( } } - private fun status(): StatusState = when (partType) { - "reasoning" -> StatusState.Thinking(KiloBundle.message("session.status.thinking")) - "text" -> StatusState.Working(KiloBundle.message("session.status.writing")) - "tool" -> StatusState.Working(when (tool) { + private fun status(): String = when (partType) { + "reasoning" -> KiloBundle.message("session.status.thinking") + "text" -> KiloBundle.message("session.status.writing") + "tool" -> when (tool) { "task" -> KiloBundle.message("session.status.delegating") "todowrite", "todoread" -> KiloBundle.message("session.status.planning") "read" -> KiloBundle.message("session.status.gathering") @@ -308,8 +308,8 @@ class SessionManager( "edit", "write" -> KiloBundle.message("session.status.editing") "bash" -> KiloBundle.message("session.status.commands") else -> KiloBundle.message("session.status.considering") - }) - else -> StatusState.Thinking(KiloBundle.message("session.status.considering")) + } + else -> KiloBundle.message("session.status.considering") } private fun fire(event: SessionManagerEvent) { @@ -331,9 +331,9 @@ class SessionManager( } } -private fun toPermissionPrompt(dto: PermissionRequestDto): PromptState.Permitting { +private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } - val perm = Permission( + return Permission( id = dto.id, sessionId = dto.sessionID, name = dto.permission, @@ -342,10 +342,9 @@ private fun toPermissionPrompt(dto: PermissionRequestDto): PromptState.Permittin meta = PermissionMeta(raw = dto.metadata), tool = ref, ) - return PromptState.Permitting(dto.id, perm) } -private fun toQuestionPrompt(dto: QuestionRequestDto): PromptState.Asking { +private fun toQuestion(dto: QuestionRequestDto): Question { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } val items = dto.questions.map { QuestionItem( @@ -356,6 +355,5 @@ private fun toQuestionPrompt(dto: QuestionRequestDto): PromptState.Asking { custom = it.custom, ) } - val q = Question(id = dto.id, items = items, tool = ref) - return PromptState.Asking(dto.id, q) + return Question(id = dto.id, items = items, tool = ref) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 3cb28764e37..bdfab4c20e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -38,7 +38,7 @@ class SessionModel { var ready: Boolean = false var showMessages: Boolean = false - var phase: SessionPhase = SessionPhase.Idle + var state: SessionState = SessionState.Idle private set private val listeners = mutableListOf() @@ -100,14 +100,14 @@ class SessionModel { fire(SessionModelEvent.ContentDelta(messageId, contentId, delta)) } - fun setPhase(phase: SessionPhase) { - this.phase = phase - fire(SessionModelEvent.PhaseChanged(phase)) + fun setState(state: SessionState) { + this.state = state + fire(SessionModelEvent.StateChanged(state)) } fun loadHistory(history: List) { entries.clear() - phase = SessionPhase.Idle + state = SessionState.Idle for (msg in history) { val item = Message(msg.info) for (part in msg.parts) { @@ -121,7 +121,7 @@ class SessionModel { fun clear() { entries.clear() - phase = SessionPhase.Idle + state = SessionState.Idle fire(SessionModelEvent.Cleared) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 5a6977b74bc..405060c55d7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -15,7 +15,7 @@ sealed class SessionModelEvent { data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() - data class PhaseChanged(val phase: SessionPhase) : SessionModelEvent() + data class StateChanged(val state: SessionState) : SessionModelEvent() data object HistoryLoaded : SessionModelEvent() data object Cleared : SessionModelEvent() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt deleted file mode 100644 index 16b946c0244..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.kilocode.client.session.model - -/** - * Single source of truth for what a session is doing right now. - * - * Variants are mutually exclusive — the UI switches on a single value. - */ -sealed class SessionPhase { - /** Nothing happening. Ready for user input. */ - data object Idle : SessionPhase() - - /** LLM is actively processing. */ - data class Working(val status: StatusState) : SessionPhase() - - /** Waiting for user to answer a question or approve a permission. */ - data class Prompting(val prompt: PromptState) : SessionPhase() - - /** Request failed, retrying with backoff. */ - data class Retry(val attempt: Int, val message: String, val next: Long) : SessionPhase() - - /** Cannot reach the provider. */ - data class Offline(val requestId: String, val message: String) : SessionPhase() - - /** Turn ended with an error. User can still send a new prompt. */ - data class Error(val message: String, val kind: String? = null) : SessionPhase() -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt new file mode 100644 index 00000000000..ecc1c29046a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -0,0 +1,21 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.session.model.content.Permission +import ai.kilocode.client.session.model.content.Question + +/** Single source of truth for what a session is doing right now. */ +sealed class SessionState { + data object Idle : SessionState() + + data class Busy(val text: String) : SessionState() + + data class AwaitingQuestion(val question: Question) : SessionState() + + data class AwaitingPermission(val permission: Permission) : SessionState() + + data class Retry(val message: String, val attempt: Int, val next: Long) : SessionState() + + data class Offline(val message: String, val requestId: String) : SessionState() + + data class Error(val message: String, val kind: String? = null) : SessionState() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt deleted file mode 100644 index 08d3445ae80..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ai.kilocode.client.session.model - -/** Describes the LLM activity when `SessionPhase.Working`. */ -sealed class StatusState { - data class Thinking(val text: String) : StatusState() - data class Working(val text: String) : StatusState() -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index b04d74c768c..be5eef9c9df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -2,8 +2,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionPhase -import ai.kilocode.client.session.model.StatusState +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.content.Compaction import ai.kilocode.client.session.model.content.Content import ai.kilocode.client.session.model.content.Message @@ -70,7 +69,7 @@ class MessageListUi( is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) - is SessionModelEvent.PhaseChanged -> onPhase(event.phase) + is SessionModelEvent.StateChanged -> onState(event.state) is SessionModelEvent.HistoryLoaded -> onHistory() is SessionModelEvent.Cleared -> onCleared() } @@ -106,18 +105,15 @@ class MessageListUi( refresh() } - private fun onPhase(phase: SessionPhase) { - when (phase) { - is SessionPhase.Working -> { - label.text = when (val s = phase.status) { - is StatusState.Thinking -> s.text - is StatusState.Working -> s.text - } + private fun onState(state: SessionState) { + when (state) { + is SessionState.Busy -> { + label.text = state.text spinner.isVisible = true } - is SessionPhase.Error -> { + is SessionState.Error -> { spinner.isVisible = false - val err = JBLabel(phase.message).apply { + val err = JBLabel(state.message).apply { foreground = JBColor.RED font = JBUI.Fonts.label() border = JBUI.Borders.empty(4, 0) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index 297350fcf27..659dee6e13e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -46,12 +46,12 @@ class ListenerLifecycleTest : SessionManagerTestBase() { assertEquals(events1.map { it::class }, events2.map { it::class }) } - fun `test session status busy fires PhaseChanged to Working`() { + fun `test session status busy fires StateChanged to Busy`() { val (_, _, model) = prompted() emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) flush() - assertTrue(model.any { it is SessionModelEvent.PhaseChanged && (it.phase is SessionPhase.Working) }) + assertTrue(model.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt new file mode 100644 index 00000000000..c9e39fda5e1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt @@ -0,0 +1,96 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.ToolRefDto + +class PromptLifecycleTest : SessionManagerTestBase() { + + fun `test PermissionAsked moves state to AwaitingPermission`() { + val (_, _, model) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + flush() + + val state = model.filterIsInstance().last().state + assertTrue(state is SessionState.AwaitingPermission) + assertEquals("perm1", (state as SessionState.AwaitingPermission).permission.id) + assertEquals("msg1", state.permission.tool!!.messageId) + } + + fun `test PermissionReplied resumes Busy state`() { + val (_, _, model) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + flush() + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) + flush() + + val state = model.filterIsInstance().last().state + assertTrue(state is SessionState.Busy) + } + + fun `test QuestionAsked moves state to AwaitingQuestion`() { + val (_, _, model) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + flush() + + val state = model.filterIsInstance().last().state + assertTrue(state is SessionState.AwaitingQuestion) + assertEquals("q1", (state as SessionState.AwaitingQuestion).question.id) + assertEquals("call1", state.question.tool!!.callId) + } + + fun `test QuestionReplied resumes Busy state`() { + val (_, _, model) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + flush() + emit(ChatEventDto.QuestionReplied("ses_test", "q1")) + flush() + + val state = model.filterIsInstance().last().state + assertTrue(state is SessionState.Busy) + } + + fun `test QuestionRejected moves state to Idle`() { + val (_, _, model) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + flush() + emit(ChatEventDto.QuestionRejected("ses_test", "q1")) + flush() + + val state = model.filterIsInstance().last().state + assertEquals(SessionState.Idle, state) + } + + private fun permission(id: String) = PermissionRequestDto( + id = id, + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + metadata = mapOf("kind" to "edit"), + tool = ToolRefDto("msg1", "call1"), + ) + + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 463d08c095d..22567bd2139 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -50,7 +50,7 @@ class SessionModelTest : UsefulTestCase() { fun `test initial app and workspace state`() { assertEquals(KiloAppStatusDto.DISCONNECTED, model.app.status) assertEquals(KiloWorkspaceStatusDto.PENDING, model.workspace.status) - assertEquals(SessionPhase.Idle, model.phase) + assertEquals(SessionState.Idle, model.state) } fun `test addMessage stores entry and fires MessageAdded`() { @@ -211,55 +211,54 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.isEmpty()) } - fun `test setPhase stores phase and fires PhaseChanged`() { - val phase = SessionPhase.Working(StatusState.Thinking("thinking")) - model.setPhase(phase) + fun `test setState stores state and fires StateChanged`() { + val state = SessionState.Busy("thinking") + model.setState(state) - assertEquals(phase, model.phase) - assertEquals(phase, (events.single() as SessionModelEvent.PhaseChanged).phase) + assertEquals(state, model.state) + assertEquals(state, (events.single() as SessionModelEvent.StateChanged).state) } - fun `test setPhase to Error stores error data`() { - model.setPhase(SessionPhase.Error("something broke", "timeout")) + fun `test setState to Error stores error data`() { + model.setState(SessionState.Error("something broke", "timeout")) - val phase = model.phase as SessionPhase.Error - assertEquals("something broke", phase.message) - assertEquals("timeout", phase.kind) + val state = model.state as SessionState.Error + assertEquals("something broke", state.message) + assertEquals("timeout", state.kind) } - fun `test setPhase to Prompting with question`() { + fun `test setState to AwaitingQuestion stores question`() { val q = question("q1") - model.setPhase(SessionPhase.Prompting(PromptState.Asking("q1", q))) + model.setState(SessionState.AwaitingQuestion(q)) - val phase = model.phase as SessionPhase.Prompting - assertTrue(phase.prompt is PromptState.Asking) + val state = model.state as SessionState.AwaitingQuestion + assertEquals("q1", state.question.id) } - fun `test setPhase to Prompting with permission`() { + fun `test setState to AwaitingPermission stores permission`() { val p = permission("p1") - model.setPhase(SessionPhase.Prompting(PromptState.Permitting("p1", p))) + model.setState(SessionState.AwaitingPermission(p)) - val phase = model.phase as SessionPhase.Prompting - assertTrue(phase.prompt is PromptState.Permitting) + val state = model.state as SessionState.AwaitingPermission + assertEquals("p1", state.permission.id) } - fun `test question tool ref is stored in Prompting phase`() { + fun `test question tool ref is stored in awaiting question state`() { val q = Question( id = "q1", items = listOf(QuestionItem("Pick one", "Choice", listOf(QuestionOption("A", "Option A")), false, true)), tool = ToolCallRef("msg1", "call1"), ) - model.setPhase(SessionPhase.Prompting(PromptState.Asking("q1", q))) + model.setState(SessionState.AwaitingQuestion(q)) - val prompt = (model.phase as SessionPhase.Prompting).prompt as PromptState.Asking - val ref = prompt.question.tool + val ref = (model.state as SessionState.AwaitingQuestion).question.tool assertNotNull(ref) assertEquals("msg1", ref!!.messageId) assertEquals("call1", ref.callId) } - fun `test permission fields are preserved in Prompting phase`() { + fun `test permission fields are preserved in awaiting permission state`() { val p = Permission( id = "p1", sessionId = "ses", @@ -278,14 +277,14 @@ class SessionModelTest : UsefulTestCase() { state = PermissionRequestState.RESPONDING, ) - model.setPhase(SessionPhase.Prompting(PromptState.Permitting("p1", p))) + model.setState(SessionState.AwaitingPermission(p)) - val prompt = (model.phase as SessionPhase.Prompting).prompt as PromptState.Permitting - assertEquals(listOf("*.kt"), prompt.permission.patterns) - assertEquals(listOf("src/**"), prompt.permission.always) - assertEquals("src/A.kt", prompt.permission.meta.filePath) - assertEquals(PermissionRequestState.RESPONDING, prompt.permission.state) - assertEquals("msg1", prompt.permission.tool!!.messageId) + val state = model.state as SessionState.AwaitingPermission + assertEquals(listOf("*.kt"), state.permission.patterns) + assertEquals(listOf("src/**"), state.permission.always) + assertEquals("src/A.kt", state.permission.meta.filePath) + assertEquals(PermissionRequestState.RESPONDING, state.permission.state) + assertEquals("msg1", state.permission.tool!!.messageId) } fun `test loadHistory populates typed contents and fires HistoryLoaded`() { @@ -317,9 +316,9 @@ class SessionModelTest : UsefulTestCase() { assertFalse(entry.parts.containsKey("p2")) } - fun `test clear resets messages and phase`() { + fun `test clear resets messages and state`() { model.addMessage(msg("m1", "user")) - model.setPhase(SessionPhase.Working(StatusState.Working("busy"))) + model.setState(SessionState.Busy("busy")) model.app = KiloAppStateDto(KiloAppStatusDto.READY) model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) events.clear() @@ -327,7 +326,7 @@ class SessionModelTest : UsefulTestCase() { model.clear() assertTrue(model.isEmpty()) - assertEquals(SessionPhase.Idle, model.phase) + assertEquals(SessionState.Idle, model.state) assertTrue(events.single() is SessionModelEvent.Cleared) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt index ffbb4ff8dd7..cb588c238de 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -17,16 +17,16 @@ class StatusComputationTest : SessionManagerTestBase() { emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) flush() - val phase = model.filterIsInstance() - .mapNotNull { it.phase as? SessionPhase.Working } - .lastOrNull { it.status is StatusState.Working } + val state = model.filterIsInstance() + .mapNotNull { it.state as? SessionState.Busy } + .lastOrNull() - assertNotNull(phase) - val text = (phase!!.status as StatusState.Working).text + assertNotNull(state) + val text = state!!.text assertEquals(KiloBundle.message("session.status.commands"), text) } - fun `test PartUpdated after TurnClose does not fire PhaseChanged`() { + fun `test PartUpdated after TurnClose does not fire StateChanged`() { val (_, _, model) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) @@ -36,12 +36,12 @@ class StatusComputationTest : SessionManagerTestBase() { emit(ChatEventDto.TurnClose("ses_test", "completed")) flush() - val before = model.filterIsInstance().size + val before = model.filterIsInstance().size emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) flush() - val after = model.filterIsInstance().size + val after = model.filterIsInstance().size assertEquals(before, after) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt index 8d21837f0ec..418a5827aa7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -5,17 +5,17 @@ import ai.kilocode.rpc.dto.MessageErrorDto class TurnLifecycleTest : SessionManagerTestBase() { - fun `test TurnOpen fires PhaseChanged to Working`() { + fun `test TurnOpen fires StateChanged to Busy`() { val (_, _, model) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() - val phase = model.filterIsInstance().lastOrNull()?.phase - assertTrue(phase is SessionPhase.Working) + val state = model.filterIsInstance().lastOrNull()?.state + assertTrue(state is SessionState.Busy) } - fun `test TurnClose fires PhaseChanged to Idle`() { + fun `test TurnClose fires StateChanged to Idle`() { val (_, _, model) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) @@ -23,19 +23,19 @@ class TurnLifecycleTest : SessionManagerTestBase() { emit(ChatEventDto.TurnClose("ses_test", "completed")) flush() - val phase = model.filterIsInstance().last().phase - assertEquals(SessionPhase.Idle, phase) + val state = model.filterIsInstance().last().state + assertEquals(SessionState.Idle, state) } - fun `test Error fires PhaseChanged to Error`() { + fun `test Error fires StateChanged to Error`() { val (_, _, model) = prompted() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) flush() - val phase = model.filterIsInstance().last().phase - assertTrue(phase is SessionPhase.Error) - assertEquals("Bad Request", (phase as SessionPhase.Error).message) + val state = model.filterIsInstance().last().state + assertTrue(state is SessionState.Error) + assertEquals("Bad Request", (state as SessionState.Error).message) } fun `test Error with null message falls back to type`() { @@ -44,7 +44,7 @@ class TurnLifecycleTest : SessionManagerTestBase() { emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) flush() - val phase = model.filterIsInstance().last().phase as SessionPhase.Error - assertEquals("timeout", phase.message) + val state = model.filterIsInstance().last().state as SessionState.Error + assertEquals("timeout", state.message) } } From 3930a17b21c79b7afe7cf0e44775bae8c66957bd Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 15:56:29 -0400 Subject: [PATCH 10/27] refactor(jetbrains): reorganize frontend session controller models Move the session controller types into the session package and collapse message, question, and permission types into the main model package. This keeps the frontend session API flatter and updates tests and UI to match the new layout. --- .../kilocode/client/app/KiloSessionService.kt | 2 +- ...SessionManager.kt => SessionController.kt} | 43 +++++++++++-------- .../client/session/SessionControllerEvent.kt | 30 +++++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 14 +++--- .../session/model/{content => }/Message.kt | 2 +- .../session/model/{content => }/Permission.kt | 2 +- .../session/model/{content => }/Question.kt | 2 +- .../session/model/SessionManagerEvent.kt | 27 ------------ .../client/session/model/SessionModel.kt | 9 +--- .../client/session/model/SessionModelEvent.kt | 3 -- .../client/session/model/SessionState.kt | 3 -- .../client/session/ui/MessageListUi.kt | 14 +++--- .../kilocode/client/session/ui/StatusPanel.kt | 18 ++++---- .../client/session/model/AppWatchingTest.kt | 3 +- .../session/model/ConfigSelectionTest.kt | 4 +- .../session/model/HistoryLoadingTest.kt | 4 +- .../session/model/ListenerLifecycleTest.kt | 7 +-- .../client/session/model/MessageListTest.kt | 1 - .../session/model/SessionCreationTest.kt | 4 +- .../session/model/SessionManagerTestBase.kt | 14 +++--- .../client/session/model/SessionModelTest.kt | 13 ------ .../client/session/model/ViewSwitchingTest.kt | 6 ++- .../session/model/WorkspaceWatchingTest.kt | 4 +- 23 files changed, 110 insertions(+), 119 deletions(-) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{model/SessionManager.kt => SessionController.kt} (89%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{content => }/Message.kt (94%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{content => }/Permission.kt (94%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/{content => }/Question.kt (91%) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 01d221a1494..6a0bfcebdef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.model.SessionManager] owns the + * session IDs. [ai.kilocode.client.session.SessionController] owns the * active session concept. */ @Service(Service.Level.PROJECT) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt similarity index 89% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 0d90922efbf..587399ece18 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -1,15 +1,20 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.content.Permission -import ai.kilocode.client.session.model.content.PermissionMeta -import ai.kilocode.client.session.model.content.Question -import ai.kilocode.client.session.model.content.QuestionItem -import ai.kilocode.client.session.model.content.QuestionOption -import ai.kilocode.client.session.model.content.ToolCallRef +import ai.kilocode.client.session.model.AgentItem +import ai.kilocode.client.session.model.ModelItem +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -35,9 +40,9 @@ import kotlinx.coroutines.launch * Owns [SessionModel] — the single source of truth for chat content and * state. UIs observe model changes via [SessionModelEvent] on [chat]. * Lifecycle events (app/workspace state, view switching) are published - * via [SessionManagerEvent] to registered listeners. + * via [SessionControllerEvent] to registered listeners. */ -class SessionManager( +class SessionController( parent: Disposable, id: String?, private val sessions: KiloSessionService, @@ -47,7 +52,7 @@ class SessionManager( ) : Disposable { companion object { - private val LOG = Logger.getInstance(SessionManager::class.java) + private val LOG = Logger.getInstance(SessionController::class.java) } init { @@ -56,7 +61,7 @@ class SessionManager( val chat = SessionModel() - private val listeners = mutableListOf() + private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory @@ -64,7 +69,7 @@ class SessionManager( private var tool: String? = null private var eventJob: Job? = null - fun addListener(parent: Disposable, listener: SessionManagerListener) { + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } @@ -110,7 +115,7 @@ class SessionManager( LOG.warn("selectAgent failed", e) } } - fire(SessionManagerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) } fun selectModel(provider: String, id: String) { @@ -122,7 +127,7 @@ class SessionManager( LOG.warn("selectModel failed", e) } } - fire(SessionManagerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) } init { @@ -138,7 +143,7 @@ class SessionManager( edt { chat.app = state chat.version = app.version - fire(SessionManagerEvent.AppChanged) + fire(SessionControllerEvent.AppChanged) } } } @@ -147,7 +152,7 @@ class SessionManager( workspace.state.collect { state -> edt { chat.workspace = state - fire(SessionManagerEvent.WorkspaceChanged) + fire(SessionControllerEvent.WorkspaceChanged) if (state.status == KiloWorkspaceStatusDto.READY) { chat.agents = state.agents?.agents?.map { @@ -168,7 +173,7 @@ class SessionManager( if (chat.model == null) chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value chat.ready = true - fire(SessionManagerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) } } } @@ -292,7 +297,7 @@ class SessionManager( private fun showMessages() { if (!chat.showMessages) { chat.showMessages = true - fire(SessionManagerEvent.ViewChanged(true)) + fire(SessionControllerEvent.ViewChanged(true)) } } @@ -312,7 +317,7 @@ class SessionManager( else -> KiloBundle.message("session.status.considering") } - private fun fire(event: SessionManagerEvent) { + private fun fire(event: SessionControllerEvent) { val application = ApplicationManager.getApplication() if (application.isDispatchThread) { for (l in listeners) l.onEvent(event) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt new file mode 100644 index 00000000000..f7e0519fcf8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent + +/** + * Lifecycle events fired by [SessionController] on the EDT. + * + * These cover app/workspace state changes and view switching — things + * outside the [SessionModel] domain. For model mutations (messages, + * parts, state), listen to [SessionModelEvent] on [SessionModel] directly. + */ +sealed class SessionControllerEvent { + + // App + workspace lifecycle (every state transition) + data object AppChanged : SessionControllerEvent() + data object WorkspaceChanged : SessionControllerEvent() + + // Workspace ready (pickers populated) + data object WorkspaceReady : SessionControllerEvent() + data class ViewChanged(val show: Boolean) : SessionControllerEvent() +} + +/** + * Listener for [SessionControllerEvent]s fired by [SessionController]. + * All callbacks are guaranteed to run on the EDT. + */ +fun interface SessionControllerListener { + fun onEvent(event: SessionControllerEvent) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index d9334bc49f7..d90ac1b5a0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,8 +3,6 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.model.SessionManager -import ai.kilocode.client.session.model.SessionManagerEvent import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.LabelPicker @@ -20,7 +18,7 @@ import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.JPanel -/** Main chat panel — reacts to [SessionManager] events. */ +/** Main chat panel — reacts to [SessionController] events. */ class SessionUi( project: Project, workspace: Workspace, @@ -34,7 +32,7 @@ class SessionUi( private const val MESSAGES = "messages" } - private val model = SessionManager(this, null, sessions, workspace, app, cs) + private val model = SessionController(this, null, sessions, workspace, app, cs) private val status = StatusPanel(this, model) private val messages = MessageListUi(this, model.chat) @@ -71,7 +69,7 @@ class SessionUi( // Lifecycle events from the manager (app/workspace state, view switching) model.addListener(this) { event -> when (event) { - is SessionManagerEvent.WorkspaceReady -> { + is SessionControllerEvent.WorkspaceReady -> { val c = model.chat prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent) val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } @@ -80,10 +78,10 @@ class SessionUi( prompt.setReady(c.ready) } - is SessionManagerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) + is SessionControllerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) - is SessionManagerEvent.AppChanged, - is SessionManagerEvent.WorkspaceChanged -> {} + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged -> {} } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index abea8017008..e4a9f08f2a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.model.content +package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.MessageDto diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt index dda56342b60..d6034eee7ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Permission.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Permission.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.model.content +package ai.kilocode.client.session.model enum class PermissionRequestState { PENDING, RESPONDING, RESOLVED, ERROR } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt similarity index 91% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt index 720d6727c04..70ea656fdbc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/content/Question.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Question.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.model.content +package ai.kilocode.client.session.model enum class QuestionRequestState { PENDING, RESPONDING, ERROR } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt deleted file mode 100644 index b58f1512f6e..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionManagerEvent.kt +++ /dev/null @@ -1,27 +0,0 @@ -package ai.kilocode.client.session.model - -/** - * Lifecycle events fired by [SessionManager] on the EDT. - * - * These cover app/workspace state changes and view switching — things - * outside the [SessionModel] domain. For model mutations (messages, - * parts, phase), listen to [SessionModelEvent] on [SessionModel] directly. - */ -sealed class SessionManagerEvent { - - // App + workspace lifecycle (every state transition) - data object AppChanged : SessionManagerEvent() - data object WorkspaceChanged : SessionManagerEvent() - - // Workspace ready (pickers populated) - data object WorkspaceReady : SessionManagerEvent() - data class ViewChanged(val show: Boolean) : SessionManagerEvent() -} - -/** - * Listener for [SessionManagerEvent]s fired by [SessionManager]. - * All callbacks are guaranteed to run on the EDT. - */ -fun interface SessionManagerListener { - fun onEvent(event: SessionManagerEvent) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index bdfab4c20e1..34958765bb3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -1,12 +1,5 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Compaction -import ai.kilocode.client.session.model.content.Content -import ai.kilocode.client.session.model.content.Message -import ai.kilocode.client.session.model.content.Reasoning -import ai.kilocode.client.session.model.content.Text -import ai.kilocode.client.session.model.content.Tool -import ai.kilocode.client.session.model.content.ToolExecState import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -20,7 +13,7 @@ import com.intellij.openapi.util.Disposer /** * Pure session model — single source of truth for chat content and runtime state. * - * **EDT-only access** — no synchronization. [SessionManager] guarantees all + * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all * reads and writes happen on the EDT. */ class SessionModel { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 405060c55d7..098b975343b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -1,8 +1,5 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Content -import ai.kilocode.client.session.model.content.Message - /** * Change events fired by [SessionModel]. * diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index ecc1c29046a..6226dde8a55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -1,8 +1,5 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Permission -import ai.kilocode.client.session.model.content.Question - /** Single source of truth for what a session is doing right now. */ sealed class SessionState { data object Idle : SessionState() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index be5eef9c9df..3fee90beeb6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -3,13 +3,13 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.model.content.Compaction -import ai.kilocode.client.session.model.content.Content -import ai.kilocode.client.session.model.content.Message -import ai.kilocode.client.session.model.content.Reasoning -import ai.kilocode.client.session.model.content.Text -import ai.kilocode.client.session.model.content.Tool -import ai.kilocode.client.session.model.content.ToolExecState +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.JBColor diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index 5c8f5518df1..7311dae785f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -1,9 +1,9 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.model.SessionManager -import ai.kilocode.client.session.model.SessionManagerEvent -import ai.kilocode.client.session.model.SessionManagerListener import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.SessionControllerListener import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -29,7 +29,7 @@ import javax.swing.SwingConstants /** * Welcome panel showing app + workspace initialization progress. * - * Pure view — listens to [SessionManager] events and reads + * Pure view — listens to [SessionController] events and reads * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. * No coroutines, no service references. * @@ -39,8 +39,8 @@ import javax.swing.SwingConstants */ class StatusPanel( parent: Disposable, - private val model: SessionManager, -) : JPanel(GridBagLayout()), SessionManagerListener, Disposable { + private val model: SessionController, +) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { init { Disposer.register(parent, this) @@ -113,15 +113,15 @@ class StatusPanel( model.addListener(this, this) } - override fun onEvent(event: SessionManagerEvent) { + override fun onEvent(event: SessionControllerEvent) { when (event) { - is SessionManagerEvent.AppChanged -> { + is SessionControllerEvent.AppChanged -> { renderApp(model.chat.app) revalidate() repaint() } - is SessionManagerEvent.WorkspaceChanged -> { + is SessionControllerEvent.WorkspaceChanged -> { renderWorkspace(model.chat.workspace) revalidate() repaint() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index 08ecaf334e4..6491edeec59 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -13,7 +14,7 @@ class AppWatchingTest : SessionManagerTestBase() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) flush() - assertTrue(events.any { it is SessionManagerEvent.AppChanged }) + assertTrue(events.any { it is SessionControllerEvent.AppChanged }) assertEquals(KiloAppStatusDto.READY, m.chat.app.status) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index 8e3567c0d63..6033f77cc9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent + class ConfigSelectionTest : SessionManagerTestBase() { fun `test selectModel updates ChatModel and calls updateConfig`() { @@ -36,6 +38,6 @@ class ConfigSelectionTest : SessionManagerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(events.any { it is SessionManagerEvent.WorkspaceReady }) + assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index a44d4184030..2972ecf6dc2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Text +import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.MessageWithPartsDto class HistoryLoadingTest : SessionManagerTestBase() { @@ -28,7 +28,7 @@ class HistoryLoadingTest : SessionManagerTestBase() { val events = collect(model) flush() - assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) assertTrue(model.chat.showMessages) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index 659dee6e13e..c2d67596fc5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer @@ -11,7 +12,7 @@ class ListenerLifecycleTest : SessionManagerTestBase() { val disposable = Disposer.newDisposable("listener-parent") Disposer.register(parent, disposable) - val events = mutableListOf() + val events = mutableListOf() m.addListener(disposable) { events.add(it) } edt { m.prompt("before") } @@ -28,8 +29,8 @@ class ListenerLifecycleTest : SessionManagerTestBase() { fun `test all listeners notified`() { val m = model() - val events1 = mutableListOf() - val events2 = mutableListOf() + val events1 = mutableListOf() + val events2 = mutableListOf() val d1 = Disposer.newDisposable("l1") val d2 = Disposer.newDisposable("l2") Disposer.register(parent, d1) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 2441fb120ba..0f506d6a876 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -1,6 +1,5 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Text import ai.kilocode.rpc.dto.ChatEventDto class MessageListTest : SessionManagerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt index bc285118735..77a73dd03cc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent + class SessionCreationTest : SessionManagerTestBase() { fun `test prompt creates session on first call`() { @@ -12,7 +14,7 @@ class SessionCreationTest : SessionManagerTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) } fun `test prompt reuses existing session`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt index 39b23b9e002..9975c0e0c86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.model import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -30,7 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionManager] tests. + * Base class for [SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. @@ -76,13 +78,13 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { // ------ Model creation ------ protected fun model(id: String? = null) = - SessionManager(parent, id, sessions, workspace, app, scope) + SessionController(parent, id, sessions, workspace, app, scope) // ------ Event collection ------ /** Attach a listener that collects lifecycle events and asserts EDT. */ - protected fun collect(m: SessionManager): MutableList { - val events = mutableListOf() + protected fun collect(m: SessionController): MutableList { + val events = mutableListOf() val disposable = Disposer.newDisposable("listener") Disposer.register(parent, disposable) m.addListener(disposable) { event -> @@ -93,7 +95,7 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { } /** Attach a listener that collects model events (messages, parts, phase). */ - protected fun collectModel(m: SessionManager): MutableList { + protected fun collectModel(m: SessionController): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) @@ -123,7 +125,7 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { } /** Create a model, attach both listeners, send initial prompt, and flush. */ - protected fun prompted(): Triple, MutableList> { + protected fun prompted(): Triple, MutableList> { val m = model() val events = collect(m) val model = collectModel(m) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 22567bd2139..4bf0766a061 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -1,18 +1,5 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.session.model.content.Compaction -import ai.kilocode.client.session.model.content.Reasoning -import ai.kilocode.client.session.model.content.Text -import ai.kilocode.client.session.model.content.Tool -import ai.kilocode.client.session.model.content.ToolExecState -import ai.kilocode.client.session.model.content.Permission -import ai.kilocode.client.session.model.content.PermissionFileDiff -import ai.kilocode.client.session.model.content.PermissionMeta -import ai.kilocode.client.session.model.content.PermissionRequestState -import ai.kilocode.client.session.model.content.Question -import ai.kilocode.client.session.model.content.QuestionItem -import ai.kilocode.client.session.model.content.QuestionOption -import ai.kilocode.client.session.model.content.ToolCallRef import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt index 6a0a8cf0a63..3bea95ef733 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent + class ViewSwitchingTest : SessionManagerTestBase() { fun `test first prompt shows messages view`() { @@ -9,7 +11,7 @@ class ViewSwitchingTest : SessionManagerTestBase() { edt { m.prompt("hello") } flush() - assertTrue(events.any { it is SessionManagerEvent.ViewChanged && it.show }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) } fun `test ViewChanged not fired twice`() { @@ -21,6 +23,6 @@ class ViewSwitchingTest : SessionManagerTestBase() { edt { m.prompt("second") } flush() - assertEquals(1, events.count { it is SessionManagerEvent.ViewChanged && it.show }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged && it.show }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt index a47f779d47d..2bcb6640550 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.SessionControllerEvent + class WorkspaceWatchingTest : SessionManagerTestBase() { fun `test workspace ready populates agents and models`() { @@ -15,7 +17,7 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { assertEquals(1, m.chat.models.size) assertEquals("gpt-5", m.chat.models[0].id) assertTrue(m.chat.ready) - assertTrue(events.any { it is SessionManagerEvent.WorkspaceReady }) + assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) } fun `test workspace ready sets default agent and model`() { From a57369131982579382d470c79e953adf4a3392ab Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 16:48:56 -0400 Subject: [PATCH 11/27] refactor(jetbrains): rename session controller model access Rename the SessionController session model property from chat to model so the controller API reads consistently. Update the frontend UI and tests to use the new accessor. --- .../client/session/SessionController.kt | 72 +++++++++---------- .../ai/kilocode/client/session/SessionUi.kt | 20 +++--- .../kilocode/client/session/ui/StatusPanel.kt | 8 +-- .../client/session/model/AppWatchingTest.kt | 2 +- .../session/model/ConfigSelectionTest.kt | 4 +- .../session/model/HistoryLoadingTest.kt | 6 +- .../client/session/model/MessageListTest.kt | 10 +-- .../session/model/SessionManagerTestBase.kt | 2 +- .../session/model/WorkspaceWatchingTest.kt | 14 ++-- 9 files changed, 69 insertions(+), 69 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 587399ece18..8b6418ba86f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.launch * event subscription happens before the prompt is sent, eliminating races. * * Owns [SessionModel] — the single source of truth for chat content and - * state. UIs observe model changes via [SessionModelEvent] on [chat]. + * state. UIs observe model changes via [SessionModelEvent] on [model]. * Lifecycle events (app/workspace state, view switching) are published * via [SessionControllerEvent] to registered listeners. */ @@ -59,7 +59,7 @@ class SessionController( Disposer.register(parent, this) } - val chat = SessionModel() + val model = SessionModel() private val listeners = mutableListOf() private var sessionId: String? = id @@ -89,7 +89,7 @@ class SessionController( LOG.warn("prompt failed", e) edt { val msg = e.message ?: KiloBundle.message("session.error.prompt") - chat.setState(SessionState.Error(msg)) + model.setState(SessionState.Error(msg)) } } } @@ -107,7 +107,7 @@ class SessionController( } fun selectAgent(name: String) { - chat.agent = name + model.agent = name cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -119,7 +119,7 @@ class SessionController( } fun selectModel(provider: String, id: String) { - chat.model = "$provider/$id" + model.model = "$provider/$id" cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) @@ -141,8 +141,8 @@ class SessionController( app.state.collect { state -> if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync() edt { - chat.app = state - chat.version = app.version + model.app = state + model.version = app.version fire(SessionControllerEvent.AppChanged) } } @@ -151,15 +151,15 @@ class SessionController( cs.launch { workspace.state.collect { state -> edt { - chat.workspace = state + model.workspace = state fire(SessionControllerEvent.WorkspaceChanged) if (state.status == KiloWorkspaceStatusDto.READY) { - chat.agents = state.agents?.agents?.map { + model.agents = state.agents?.agents?.map { AgentItem(it.name, it.displayName ?: it.name) } ?: emptyList() - chat.models = state.providers?.let { providers -> + model.models = state.providers?.let { providers -> providers.providers .filter { it.id in providers.connected } .flatMap { provider -> @@ -169,10 +169,10 @@ class SessionController( } } ?: emptyList() - if (chat.agent == null) chat.agent = state.agents?.default - if (chat.model == null) chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value + if (this@SessionController.model.agent == null) this@SessionController.model.agent = state.agents?.default + if (this@SessionController.model.model == null) this@SessionController.model.model = state.providers?.defaults?.entries?.firstOrNull()?.value - chat.ready = true + this@SessionController.model.ready = true fire(SessionControllerEvent.WorkspaceReady) } } @@ -186,8 +186,8 @@ class SessionController( try { val history = sessions.messages(id, directory) edt { - chat.loadHistory(history) - if (!chat.isEmpty()) showMessages() + this@SessionController.model.loadHistory(history) + if (!model.isEmpty()) showMessages() } } catch (e: Exception) { LOG.warn("loadHistory failed", e) @@ -208,71 +208,71 @@ class SessionController( private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.MessageUpdated -> { - chat.addMessage(event.info) ?: return + model.addMessage(event.info) ?: return showMessages() } is ChatEventDto.PartUpdated -> { partType = event.part.type tool = event.part.tool - chat.updateContent(event.part.messageID, event.part) - if (chat.state is SessionState.Busy) { - chat.setState(SessionState.Busy(status())) + model.updateContent(event.part.messageID, event.part) + if (model.state is SessionState.Busy) { + model.setState(SessionState.Busy(status())) } } is ChatEventDto.PartDelta -> { if (event.field == "text") { - chat.appendDelta(event.messageID, event.partID, event.delta) + model.appendDelta(event.messageID, event.partID, event.delta) } } is ChatEventDto.TurnOpen -> { partType = null tool = null - chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } is ChatEventDto.TurnClose -> { partType = null tool = null - chat.setState(SessionState.Idle) + model.setState(SessionState.Idle) } is ChatEventDto.Error -> { partType = null tool = null val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - chat.setState(SessionState.Error(msg, event.error?.type)) + model.setState(SessionState.Error(msg, event.error?.type)) } is ChatEventDto.MessageRemoved -> { - chat.removeMessage(event.messageID) + model.removeMessage(event.messageID) } is ChatEventDto.PermissionAsked -> { - chat.setState(SessionState.AwaitingPermission(toPermission(event.request))) + model.setState(SessionState.AwaitingPermission(toPermission(event.request))) } is ChatEventDto.PermissionReplied -> { - if (chat.state is SessionState.AwaitingPermission) { - chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + if (model.state is SessionState.AwaitingPermission) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } is ChatEventDto.QuestionAsked -> { - chat.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) } is ChatEventDto.QuestionReplied -> { - if (chat.state is SessionState.AwaitingQuestion) { - chat.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + if (model.state is SessionState.AwaitingQuestion) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } is ChatEventDto.QuestionRejected -> { - if (chat.state is SessionState.AwaitingQuestion) { - chat.setState(SessionState.Idle) + if (model.state is SessionState.AwaitingQuestion) { + model.setState(SessionState.Idle) } } @@ -280,7 +280,7 @@ class SessionController( val state = when (event.status.type) { "idle" -> SessionState.Idle "busy" -> { - val current = chat.state + val current = model.state if (current is SessionState.Idle || current is SessionState.Error) SessionState.Busy(KiloBundle.message("session.status.considering")) else return // already in a more specific phase @@ -289,14 +289,14 @@ class SessionController( "offline" -> SessionState.Offline(event.status.message ?: "", "") else -> return } - chat.setState(state) + model.setState(state) } } } private fun showMessages() { - if (!chat.showMessages) { - chat.showMessages = true + if (!model.showMessages) { + model.showMessages = true fire(SessionControllerEvent.ViewChanged(true)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index d90ac1b5a0a..71b92aad68f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -32,9 +32,9 @@ class SessionUi( private const val MESSAGES = "messages" } - private val model = SessionController(this, null, sessions, workspace, app, cs) - private val status = StatusPanel(this, model) - private val messages = MessageListUi(this, model.chat) + private val controller = SessionController(this, null, sessions, workspace, app, cs) + private val status = StatusPanel(this, controller) + private val messages = MessageListUi(this, controller.model) private val cards = CardLayout() private val center = JPanel(cards) @@ -48,7 +48,7 @@ class SessionUi( private val prompt = PromptPanel( project = project, onSend = { text -> send(text) }, - onAbort = { model.abort() }, + onAbort = { controller.abort() }, ) init { @@ -59,18 +59,18 @@ class SessionUi( add(center, BorderLayout.CENTER) add(prompt, BorderLayout.SOUTH) - prompt.mode.onSelect = { item -> model.selectAgent(item.id) } + prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } prompt.model.onSelect = picker@{ item -> val group = item.group if (group == null) return@picker - model.selectModel(group, item.id) + controller.selectModel(group, item.id) } // Lifecycle events from the manager (app/workspace state, view switching) - model.addListener(this) { event -> + controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { - val c = model.chat + val c = controller.model prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent) val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } @@ -86,7 +86,7 @@ class SessionUi( } // Model events — state drives the prompt busy state - model.chat.addListener(this) { event -> + controller.model.addListener(this) { event -> when (event) { is SessionModelEvent.StateChanged -> { val busy = event.state.isBusy() @@ -105,7 +105,7 @@ class SessionUi( private fun send(text: String) { if (text.isBlank()) return - model.prompt(text) + controller.prompt(text) prompt.clear() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index 7311dae785f..40d8041f07c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -116,13 +116,13 @@ class StatusPanel( override fun onEvent(event: SessionControllerEvent) { when (event) { is SessionControllerEvent.AppChanged -> { - renderApp(model.chat.app) + renderApp(model.model.app) revalidate() repaint() } is SessionControllerEvent.WorkspaceChanged -> { - renderWorkspace(model.chat.workspace) + renderWorkspace(model.model.workspace) revalidate() repaint() } @@ -187,7 +187,7 @@ class StatusPanel( } private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = model.chat.app.status == KiloAppStatusDto.READY + val appReady = model.model.app.status == KiloAppStatusDto.READY val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING wsSection.isVisible = visible if (!visible) return @@ -241,7 +241,7 @@ class StatusPanel( KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") KiloAppStatusDto.READY -> { - val ver = model.chat.version + val ver = model.model.version if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) else KiloBundle.message("toolwindow.status.connected") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index 6491edeec59..babf582bb4d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -15,6 +15,6 @@ class AppWatchingTest : SessionManagerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.AppChanged }) - assertEquals(KiloAppStatusDto.READY, m.chat.app.status) + assertEquals(KiloAppStatusDto.READY, m.model.app.status) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index 6033f77cc9f..d4627bb2eb2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -12,7 +12,7 @@ class ConfigSelectionTest : SessionManagerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertEquals("kilo/gpt-5", m.chat.model) + assertEquals("kilo/gpt-5", m.model.model) assertEquals(1, rpc.configs.size) assertEquals("kilo/gpt-5", rpc.configs[0].second.model) } @@ -25,7 +25,7 @@ class ConfigSelectionTest : SessionManagerTestBase() { edt { m.selectAgent("plan") } flush() - assertEquals("plan", m.chat.agent) + assertEquals("plan", m.model.agent) assertEquals(1, rpc.configs.size) assertEquals("plan", rpc.configs[0].second.agent) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index 2972ecf6dc2..9ef5c4cda46 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -15,8 +15,8 @@ class HistoryLoadingTest : SessionManagerTestBase() { flush() assertTrue(events.any { it is SessionModelEvent.HistoryLoaded }) - assertNotNull(model.chat.message("msg1")) - val content = model.chat.content("msg1", "prt1") + assertNotNull(model.model.message("msg1")) + val content = model.model.content("msg1", "prt1") assertTrue(content is Text) assertEquals("hello", (content as Text).content.toString()) } @@ -29,6 +29,6 @@ class HistoryLoadingTest : SessionManagerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) - assertTrue(model.chat.showMessages) + assertTrue(model.model.showMessages) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 0f506d6a876..90abcaebca7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -11,7 +11,7 @@ class MessageListTest : SessionManagerTestBase() { flush() assertTrue(model.any { it is SessionModelEvent.MessageAdded }) - assertNotNull(m.chat.message("msg1")) + assertNotNull(m.model.message("msg1")) } fun `test PartUpdated text updates ChatModel`() { @@ -24,7 +24,7 @@ class MessageListTest : SessionManagerTestBase() { flush() assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) - val p = m.chat.content("msg1", "prt1") + val p = m.model.content("msg1", "prt1") assertTrue(p is Text) } @@ -38,7 +38,7 @@ class MessageListTest : SessionManagerTestBase() { emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) flush() - val p = m.chat.content("msg1", "prt1") + val p = m.model.content("msg1", "prt1") assertNotNull(p) assertTrue(p is Text) assertEquals("hello world", (p as Text).content.toString()) @@ -49,10 +49,10 @@ class MessageListTest : SessionManagerTestBase() { emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) flush() - assertNotNull(m.chat.message("msg1")) + assertNotNull(m.model.message("msg1")) emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) flush() - assertNull(m.chat.message("msg1")) + assertNull(m.model.message("msg1")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt index 9975c0e0c86..0cf16108555 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt @@ -99,7 +99,7 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) - m.chat.addListener(disposable) { event -> + m.model.addListener(disposable) { event -> events.add(event) } return events diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt index 2bcb6640550..635a5f7842c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -12,11 +12,11 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { projectRpc.state.value = workspaceReady() flush() - 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) + assertEquals(1, m.model.agents.size) + assertEquals("code", m.model.agents[0].name) + assertEquals(1, m.model.models.size) + assertEquals("gpt-5", m.model.models[0].id) + assertTrue(m.model.ready) assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) } @@ -28,7 +28,7 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { projectRpc.state.value = workspaceReady() flush() - assertEquals("code", m.chat.agent) - assertEquals("gpt-5", m.chat.model) + assertEquals("code", m.model.agent) + assertEquals("gpt-5", m.model.model) } } From c6b0b159a8550d9bc6b0b1dcbfe37337298ea724 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 18:18:36 -0400 Subject: [PATCH 12/27] refactor(jetbrains): add transcript-style toString and snapshot session tests Extend permission/question dump with file, state, and metadata fields matching the UI-visible order. Replace direct state-cast assertions in permission/question tests with assertModel/assertController snapshots. Rename SessionManagerTestBase to SessionControllerTestBase. --- .../client/session/SessionController.kt | 115 ++++++++++++++---- .../ai/kilocode/client/session/SessionUi.kt | 4 +- .../client/session/model/SessionModel.kt | 101 ++++++++++++++- .../client/session/model/AppWatchingTest.kt | 9 +- .../session/model/ConfigSelectionTest.kt | 16 ++- .../session/model/HistoryLoadingTest.kt | 31 +++-- .../session/model/ListenerLifecycleTest.kt | 2 +- .../client/session/model/MessageListTest.kt | 18 ++- .../session/model/PromptLifecycleTest.kt | 75 ++++++++---- ...stBase.kt => SessionControllerTestBase.kt} | 20 ++- .../session/model/SessionCreationTest.kt | 8 +- .../client/session/model/SessionModelTest.kt | 74 ++++++++--- .../session/model/StatusComputationTest.kt | 19 +-- .../client/session/model/TurnLifecycleTest.kt | 43 ++++--- .../client/session/model/ViewSwitchingTest.kt | 8 +- .../session/model/WorkspaceWatchingTest.kt | 13 +- 16 files changed, 451 insertions(+), 105 deletions(-) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/{SessionManagerTestBase.kt => SessionControllerTestBase.kt} (88%) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 8b6418ba86f..059866547b8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -11,6 +11,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.PermissionRequestState import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption @@ -69,6 +70,8 @@ class SessionController( private var tool: String? = null private var eventJob: Job? = null + val ready: Boolean get() = model.isReady() + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } @@ -140,42 +143,43 @@ class SessionController( cs.launch { app.state.collect { state -> if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync() - edt { + fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version - fire(SessionControllerEvent.AppChanged) } } } cs.launch { workspace.state.collect { state -> - edt { + fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state - fire(SessionControllerEvent.WorkspaceChanged) - if (state.status == KiloWorkspaceStatusDto.READY) { - model.agents = state.agents?.agents?.map { - AgentItem(it.name, it.displayName ?: it.name) - } ?: emptyList() + if (state.status != KiloWorkspaceStatusDto.READY) return@fire - model.models = state.providers?.let { providers -> - providers.providers - .filter { it.id in providers.connected } - .flatMap { provider -> - provider.models.map { (id, info) -> - ModelItem(id, info.name, provider.id) - } + model.agents = state.agents?.agents?.map { + AgentItem(it.name, it.displayName ?: it.name) + } ?: emptyList() + + model.models = state.providers?.let { providers -> + providers.providers + .filter { it.id in providers.connected } + .flatMap { provider -> + provider.models.map { (id, info) -> + ModelItem(id, info.name, provider.id) } - } ?: emptyList() + } + } ?: emptyList() - if (this@SessionController.model.agent == null) this@SessionController.model.agent = state.agents?.default - if (this@SessionController.model.model == null) this@SessionController.model.model = state.providers?.defaults?.entries?.firstOrNull()?.value - - this@SessionController.model.ready = true - fire(SessionControllerEvent.WorkspaceReady) + if (this@SessionController.model.agent == null) this@SessionController.model.agent = state.agents?.default + if (this@SessionController.model.model == null) { + this@SessionController.model.model = state.providers?.defaults?.entries?.firstOrNull()?.let { "${it.key}/${it.value}" } } } + + if (state.status == KiloWorkspaceStatusDto.READY) { + fire(SessionControllerEvent.WorkspaceReady) + } } } } @@ -317,13 +321,17 @@ class SessionController( else -> KiloBundle.message("session.status.considering") } - private fun fire(event: SessionControllerEvent) { + private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { val application = ApplicationManager.getApplication() if (application.isDispatchThread) { + before?.invoke() for (l in listeners) l.onEvent(event) return } - application.invokeLater { for (l in listeners) l.onEvent(event) } + application.invokeLater { + before?.invoke() + for (l in listeners) l.onEvent(event) + } } private fun edt(block: () -> Unit) { @@ -334,18 +342,68 @@ class SessionController( eventJob?.cancel() cs.cancel() } + + override fun toString(): String { + val out = mutableListOf() + val body = model.toString().trim() + if (body.isNotEmpty()) out.add(body) + if (out.isNotEmpty()) out.add("") + out.add(statusLine()) + return out.joinToString("\n") + } + + private fun statusLine(): String { + val out = mutableListOf() + model.agent?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + model.model?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + + if (!ready) { + out.add("[app: ${model.app.status}]") + out.add("[workspace: ${model.workspace.status}]") + return out.joinToString(" ") + } + + when (val state = model.state) { + is SessionState.Idle -> out.add("[idle]") + is SessionState.Busy -> { + out.add("[busy]") + out.add("[${state.text.toDumpText()}]") + } + is SessionState.AwaitingQuestion -> out.add("[awaiting-question]") + is SessionState.AwaitingPermission -> out.add("[awaiting-permission]") + is SessionState.Retry -> { + out.add("[retry]") + state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + } + is SessionState.Offline -> { + out.add("[offline]") + state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") } + } + is SessionState.Error -> { + out.add("[error]") + out.add("[${state.message}]") + } + } + + return out.joinToString(" ") + } } private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } + val file = dto.metadata["file"] ?: dto.metadata["path"] + val state = dto.metadata["state"]?.let { raw -> + PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) } + } ?: PermissionRequestState.PENDING return Permission( id = dto.id, sessionId = dto.sessionID, name = dto.permission, patterns = dto.patterns, always = dto.always, - meta = PermissionMeta(raw = dto.metadata), + meta = PermissionMeta(filePath = file, raw = dto.metadata), tool = ref, + state = state, ) } @@ -362,3 +420,12 @@ private fun toQuestion(dto: QuestionRequestDto): Question { } return Question(id = dto.id, items = items, tool = ref) } + +private fun String.toDumpText(): String { + val text = lowercase() + .replace("\u2026", "") + .replace("…", "") + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + return text +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 71b92aad68f..155d47796ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -75,13 +75,13 @@ class SessionUi( val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) - prompt.setReady(c.ready) + prompt.setReady(c.isReady()) } is SessionControllerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> {} + is SessionControllerEvent.WorkspaceChanged -> prompt.setReady(controller.model.isReady()) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 34958765bb3..fdda1eacfba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -28,7 +28,6 @@ class SessionModel { var models: List = emptyList() var agent: String? = null var model: String? = null - var ready: Boolean = false var showMessages: Boolean = false var state: SessionState = SessionState.Idle @@ -49,6 +48,8 @@ class SessionModel { fun isEmpty(): Boolean = entries.isEmpty() + fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY + fun addMessage(dto: MessageDto): Message? { if (entries.containsKey(dto.id)) return null val msg = Message(dto) @@ -160,6 +161,29 @@ class SessionModel { private fun fire(event: SessionModelEvent) { for (l in listeners) l.onEvent(event) } + + override fun toString(): String { + val out = mutableListOf() + + for (msg in messages()) { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderMessage(msg)) + } + + when (val state = this.state) { + is SessionState.AwaitingQuestion -> { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderQuestion(state.question)) + } + is SessionState.AwaitingPermission -> { + if (out.isNotEmpty()) out.add("---") + out.addAll(renderPermission(state.permission)) + } + else -> {} + } + + return out.joinToString("\n") + } } private fun parseToolState(raw: String?): ToolExecState = when (raw) { @@ -173,3 +197,78 @@ private fun parseToolState(raw: String?): ToolExecState = when (raw) { data class AgentItem(val name: String, val display: String) data class ModelItem(val id: String, val display: String, val provider: String) + +private fun renderMessage(msg: Message): List { + val out = mutableListOf() + out.add("${msg.info.role}#${msg.info.id}") + for (part in msg.parts.values) { + when (part) { + is Text -> { + out.add("text#${part.id}:") + out.addAll(renderText(part.content)) + } + is Reasoning -> { + out.add("reasoning#${part.id}:") + out.addAll(renderText(part.content)) + } + is Tool -> out.add(renderTool(part)) + is Compaction -> out.add("compaction#${part.id}") + } + } + return out +} + +private fun renderQuestion(question: Question): List { + val out = mutableListOf() + out.add("question#${question.id}") + out.add("tool: ${renderToolRef(question.tool)}") + for (item in question.items) { + out.add("header: ${item.header}") + out.add("prompt: ${item.question}") + for (opt in item.options) { + out.add("option: ${opt.label} - ${opt.description}") + } + out.add("multiple: ${item.multiple}") + out.add("custom: ${item.custom}") + } + return out +} + +private fun renderPermission(permission: Permission): List { + val out = mutableListOf() + out.add("permission#${permission.id}") + out.add("tool: ${renderToolRef(permission.tool)}") + out.add("name: ${permission.name}") + out.add("patterns: ${permission.patterns.joinToString(", ").ifEmpty { "" }}") + out.add("always: ${permission.always.joinToString(", ").ifEmpty { "" }}") + out.add("file: ${renderFile(permission.meta)}") + out.add("state: ${permission.state.name}") + val meta = permission.meta.raw.entries + .filter { it.key !in setOf("file", "path", "state") } + .sortedBy { it.key } + .joinToString(", ") { "${it.key}=${it.value}" } + .ifEmpty { "" } + out.add("metadata: $meta") + return out +} + +private fun renderToolRef(ref: ToolCallRef?): String = ref?.let { "${it.messageId}/${it.callId}" } ?: "" + +private fun renderFile(meta: PermissionMeta): String { + meta.filePath?.takeIf { it.isNotBlank() }?.let { return it } + meta.raw["file"]?.takeIf { it.isNotBlank() }?.let { return it } + meta.raw["path"]?.takeIf { it.isNotBlank() }?.let { return it } + return "" +} + +private fun renderTool(tool: Tool): String { + val state = tool.state.name + val title = tool.title?.takeIf { it.isNotBlank() }?.let { " $it" } ?: "" + return "tool#${tool.id} ${tool.name} [$state]$title" +} + +private fun renderText(text: CharSequence): List { + val raw = text.toString() + if (raw.isEmpty()) return listOf(" ") + return raw.split("\n").map { " $it" } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index babf582bb4d..04e14857c25 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -4,7 +4,7 @@ import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto -class AppWatchingTest : SessionManagerTestBase() { +class AppWatchingTest : SessionControllerTestBase() { fun `test app state change fires AppChanged`() { val m = model() @@ -16,5 +16,12 @@ class AppWatchingTest : SessionManagerTestBase() { assertTrue(events.any { it is SessionControllerEvent.AppChanged }) assertEquals(KiloAppStatusDto.READY, m.model.app.status) + assertController( + """ + [app: READY] [workspace: PENDING] + """, + m, + show = false, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index d4627bb2eb2..5b4397e3241 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.client.session.SessionControllerEvent -class ConfigSelectionTest : SessionManagerTestBase() { +class ConfigSelectionTest : SessionControllerTestBase() { fun `test selectModel updates ChatModel and calls updateConfig`() { val m = model() @@ -15,6 +15,13 @@ class ConfigSelectionTest : SessionManagerTestBase() { assertEquals("kilo/gpt-5", m.model.model) assertEquals(1, rpc.configs.size) assertEquals("kilo/gpt-5", rpc.configs[0].second.model) + assertController( + """ + [kilo/gpt-5] [app: DISCONNECTED] [workspace: PENDING] + """, + m, + show = false, + ) } fun `test selectAgent updates ChatModel and calls updateConfig`() { @@ -28,6 +35,13 @@ class ConfigSelectionTest : SessionManagerTestBase() { assertEquals("plan", m.model.agent) assertEquals(1, rpc.configs.size) assertEquals("plan", rpc.configs[0].second.agent) + assertController( + """ + [plan] [app: DISCONNECTED] [workspace: PENDING] + """, + m, + show = false, + ) } fun `test selectModel fires WorkspaceReady event`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt index 9ef5c4cda46..7ada34f1d33 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -3,32 +3,43 @@ package ai.kilocode.client.session.model import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.MessageWithPartsDto -class HistoryLoadingTest : SessionManagerTestBase() { +class HistoryLoadingTest : SessionControllerTestBase() { fun `test existing session loads history on init`() { val m = msg("msg1", "ses_test", "user") val part = part("prt1", "ses_test", "msg1", "text", text = "hello") rpc.history.add(MessageWithPartsDto(m, listOf(part))) - val model = model("ses_test") - val events = collectModel(model) + val c = model("ses_test") + val events = collectModel(c) flush() assertTrue(events.any { it is SessionModelEvent.HistoryLoaded }) - assertNotNull(model.model.message("msg1")) - val content = model.model.content("msg1", "prt1") - assertTrue(content is Text) - assertEquals("hello", (content as Text).content.toString()) + assertModel( + """ + user#msg1 + text#prt1: + hello + """, + c, + ) } 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) + val c = model("ses_test") + val events = collect(c) flush() assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) - assertTrue(model.model.showMessages) + assertController( + """ + user#msg1 + + [app: DISCONNECTED] [workspace: PENDING] + """, + c, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index c2d67596fc5..7dc70a06ce7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -5,7 +5,7 @@ import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer -class ListenerLifecycleTest : SessionManagerTestBase() { +class ListenerLifecycleTest : SessionControllerTestBase() { fun `test listener removed on parent dispose`() { val m = model() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 90abcaebca7..799ea089844 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.ChatEventDto -class MessageListTest : SessionManagerTestBase() { +class MessageListTest : SessionControllerTestBase() { fun `test MessageUpdated adds message to ChatModel`() { val (m, _, model) = prompted() @@ -26,6 +26,14 @@ class MessageListTest : SessionManagerTestBase() { assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) val p = m.model.content("msg1", "prt1") assertTrue(p is Text) + assertModel( + """ + assistant#msg1 + text#prt1: + hello + """, + m, + ) } fun `test PartDelta appends text to ChatModel`() { @@ -42,6 +50,14 @@ class MessageListTest : SessionManagerTestBase() { assertNotNull(p) assertTrue(p is Text) assertEquals("hello world", (p as Text).content.toString()) + assertModel( + """ + assistant#msg1 + text#prt1: + hello world + """, + m, + ) } fun `test MessageRemoved removes from ChatModel`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt index c9e39fda5e1..9d72fb7289f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt @@ -7,66 +7,99 @@ import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto -class PromptLifecycleTest : SessionManagerTestBase() { +class PromptLifecycleTest : SessionControllerTestBase() { fun `test PermissionAsked moves state to AwaitingPermission`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) flush() - val state = model.filterIsInstance().last().state - assertTrue(state is SessionState.AwaitingPermission) - assertEquals("perm1", (state as SessionState.AwaitingPermission).permission.id) - assertEquals("msg1", state.permission.tool!!.messageId) + assertController( + """ + permission#perm1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: + file: src/A.kt + state: RESPONDING + metadata: kind=edit + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, + ) } fun `test PermissionReplied resumes Busy state`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) flush() emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) flush() - val state = model.filterIsInstance().last().state - assertTrue(state is SessionState.Busy) + assertController( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) } fun `test QuestionAsked moves state to AwaitingQuestion`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) flush() - val state = model.filterIsInstance().last().state - assertTrue(state is SessionState.AwaitingQuestion) - assertEquals("q1", (state as SessionState.AwaitingQuestion).question.id) - assertEquals("call1", state.question.tool!!.callId) + assertController( + """ + question#q1 + tool: msg1/call1 + header: Choice + prompt: Pick one + option: A - Option A + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, + ) } fun `test QuestionReplied resumes Busy state`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) flush() emit(ChatEventDto.QuestionReplied("ses_test", "q1")) flush() - val state = model.filterIsInstance().last().state - assertTrue(state is SessionState.Busy) + assertController( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) } fun `test QuestionRejected moves state to Idle`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) flush() emit(ChatEventDto.QuestionRejected("ses_test", "q1")) flush() - val state = model.filterIsInstance().last().state - assertEquals(SessionState.Idle, state) + assertController( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) } private fun permission(id: String) = PermissionRequestDto( @@ -75,7 +108,7 @@ class PromptLifecycleTest : SessionManagerTestBase() { permission = "edit", patterns = listOf("*.kt"), always = emptyList(), - metadata = mapOf("kind" to "edit"), + metadata = mapOf("kind" to "edit", "file" to "src/A.kt", "state" to "RESPONDING"), tool = ToolRefDto("msg1", "call1"), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt similarity index 88% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt index 0cf16108555..51887973da1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt @@ -12,6 +12,8 @@ import ai.kilocode.client.app.Workspace import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.MessageDto @@ -37,7 +39,7 @@ import kotlinx.coroutines.runBlocking * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. */ -abstract class SessionManagerTestBase : BasePlatformTestCase() { +abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi @@ -126,14 +128,30 @@ abstract class SessionManagerTestBase : BasePlatformTestCase() { /** Create a model, attach both listeners, send initial prompt, and flush. */ protected fun prompted(): Triple, MutableList> { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() val m = model() val events = collect(m) val model = collectModel(m) + flush() edt { m.prompt("go") } flush() return Triple(m, events, model) } + protected fun assertModel(expected: String, model: SessionModel) { + assertEquals(expected.trimIndent().trim(), model.toString().trim()) + } + + protected fun assertModel(expected: String, c: SessionController) { + assertModel(expected, c.model) + } + + protected fun assertController(expected: String, c: SessionController, show: Boolean = true) { + assertEquals(expected.trimIndent().trim(), c.toString().trim()) + assertEquals(show, c.model.showMessages) + } + // ------ DTO factories ------ protected fun msg(id: String, sid: String, role: String) = MessageDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt index 77a73dd03cc..749fcb8b5d8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.client.session.SessionControllerEvent -class SessionCreationTest : SessionManagerTestBase() { +class SessionCreationTest : SessionControllerTestBase() { fun `test prompt creates session on first call`() { val m = model() @@ -15,6 +15,12 @@ class SessionCreationTest : SessionManagerTestBase() { assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) + assertController( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) } fun `test prompt reuses existing session`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 4bf0766a061..a3ee5aa6dac 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -37,9 +37,18 @@ class SessionModelTest : UsefulTestCase() { fun `test initial app and workspace state`() { assertEquals(KiloAppStatusDto.DISCONNECTED, model.app.status) assertEquals(KiloWorkspaceStatusDto.PENDING, model.workspace.status) + assertFalse(model.isReady()) assertEquals(SessionState.Idle, model.state) } + fun `test isReady requires app and workspace readiness`() { + model.app = KiloAppStateDto(KiloAppStatusDto.READY) + assertFalse(model.isReady()) + + model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + assertTrue(model.isReady()) + } + fun `test addMessage stores entry and fires MessageAdded`() { model.addMessage(msg("m1", "user")) @@ -218,16 +227,36 @@ class SessionModelTest : UsefulTestCase() { val q = question("q1") model.setState(SessionState.AwaitingQuestion(q)) - val state = model.state as SessionState.AwaitingQuestion - assertEquals("q1", state.question.id) + assertModel( + """ + question#q1 + tool: + header: Pick + prompt: Which option? + option: A - Option A + option: B - Option B + multiple: false + custom: true + """, + ) } fun `test setState to AwaitingPermission stores permission`() { val p = permission("p1") model.setState(SessionState.AwaitingPermission(p)) - val state = model.state as SessionState.AwaitingPermission - assertEquals("p1", state.permission.id) + assertModel( + """ + permission#p1 + tool: + name: edit + patterns: *.kt + always: + file: + state: PENDING + metadata: + """, + ) } fun `test question tool ref is stored in awaiting question state`() { @@ -239,10 +268,17 @@ class SessionModelTest : UsefulTestCase() { model.setState(SessionState.AwaitingQuestion(q)) - val ref = (model.state as SessionState.AwaitingQuestion).question.tool - assertNotNull(ref) - assertEquals("msg1", ref!!.messageId) - assertEquals("call1", ref.callId) + assertModel( + """ + question#q1 + tool: msg1/call1 + header: Choice + prompt: Pick one + option: A - Option A + multiple: false + custom: true + """, + ) } fun `test permission fields are preserved in awaiting permission state`() { @@ -266,12 +302,18 @@ class SessionModelTest : UsefulTestCase() { model.setState(SessionState.AwaitingPermission(p)) - val state = model.state as SessionState.AwaitingPermission - assertEquals(listOf("*.kt"), state.permission.patterns) - assertEquals(listOf("src/**"), state.permission.always) - assertEquals("src/A.kt", state.permission.meta.filePath) - assertEquals(PermissionRequestState.RESPONDING, state.permission.state) - assertEquals("msg1", state.permission.tool!!.messageId) + assertModel( + """ + permission#p1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: src/** + file: src/A.kt + state: RESPONDING + metadata: kind=edit + """, + ) } fun `test loadHistory populates typed contents and fires HistoryLoaded`() { @@ -381,4 +423,8 @@ class SessionModelTest : UsefulTestCase() { always = emptyList(), meta = PermissionMeta(), ) + + private fun assertModel(expected: String) { + assertEquals(expected.trimIndent().trim(), model.toString().trim()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt index cb588c238de..0190c5a6c9e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -1,12 +1,11 @@ package ai.kilocode.client.session.model -import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.ChatEventDto -class StatusComputationTest : SessionManagerTestBase() { +class StatusComputationTest : SessionControllerTestBase() { fun `test status shows tool-specific text`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() @@ -17,13 +16,15 @@ class StatusComputationTest : SessionManagerTestBase() { emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) flush() - val state = model.filterIsInstance() - .mapNotNull { it.state as? SessionState.Busy } - .lastOrNull() + assertController( + """ + assistant#msg1 + tool#prt1 bash [PENDING] - assertNotNull(state) - val text = state!!.text - assertEquals(KiloBundle.message("session.status.commands"), text) + [code] [kilo/gpt-5] [busy] [running commands] + """, + m, + ) } fun `test PartUpdated after TurnClose does not fire StateChanged`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt index 418a5827aa7..a66fc7d8fe3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -3,48 +3,63 @@ package ai.kilocode.client.session.model import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto -class TurnLifecycleTest : SessionManagerTestBase() { +class TurnLifecycleTest : SessionControllerTestBase() { fun `test TurnOpen fires StateChanged to Busy`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() - val state = model.filterIsInstance().lastOrNull()?.state - assertTrue(state is SessionState.Busy) + assertController( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) } fun `test TurnClose fires StateChanged to Idle`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) flush() emit(ChatEventDto.TurnClose("ses_test", "completed")) flush() - val state = model.filterIsInstance().last().state - assertEquals(SessionState.Idle, state) + assertController( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) } fun `test Error fires StateChanged to Error`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) flush() - val state = model.filterIsInstance().last().state - assertTrue(state is SessionState.Error) - assertEquals("Bad Request", (state as SessionState.Error).message) + assertController( + """ + [code] [kilo/gpt-5] [error] [Bad Request] + """, + m, + ) } fun `test Error with null message falls back to type`() { - val (_, _, model) = prompted() + val (m, _, _) = prompted() emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) flush() - val state = model.filterIsInstance().last().state as SessionState.Error - assertEquals("timeout", state.message) + assertController( + """ + [code] [kilo/gpt-5] [error] [timeout] + """, + m, + ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt index 3bea95ef733..9354c0d7ce8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.client.session.SessionControllerEvent -class ViewSwitchingTest : SessionManagerTestBase() { +class ViewSwitchingTest : SessionControllerTestBase() { fun `test first prompt shows messages view`() { val m = model() @@ -12,6 +12,12 @@ class ViewSwitchingTest : SessionManagerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) + assertController( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) } fun `test ViewChanged not fired twice`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt index 635a5f7842c..c103b228610 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session.model import ai.kilocode.client.session.SessionControllerEvent -class WorkspaceWatchingTest : SessionManagerTestBase() { +class WorkspaceWatchingTest : SessionControllerTestBase() { fun `test workspace ready populates agents and models`() { val m = model() @@ -16,8 +16,15 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { assertEquals("code", m.model.agents[0].name) assertEquals(1, m.model.models.size) assertEquals("gpt-5", m.model.models[0].id) - assertTrue(m.model.ready) + assertFalse(m.model.isReady()) assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) + assertController( + """ + [code] [kilo/gpt-5] [app: DISCONNECTED] [workspace: READY] + """, + m, + show = false, + ) } fun `test workspace ready sets default agent and model`() { @@ -29,6 +36,6 @@ class WorkspaceWatchingTest : SessionManagerTestBase() { flush() assertEquals("code", m.model.agent) - assertEquals("gpt-5", m.model.model) + assertEquals("kilo/gpt-5", m.model.model) } } From a85328eb8143b8551edfa1cfe2d6abcbffd104fc Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 18:44:15 -0400 Subject: [PATCH 13/27] refactor(jetbrains): add flush param to emit and remove redundant test assertions --- .../client/session/model/AppWatchingTest.kt | 1 - .../session/model/ConfigSelectionTest.kt | 2 -- .../session/model/ListenerLifecycleTest.kt | 3 +-- .../client/session/model/MessageListTest.kt | 27 +++++-------------- .../session/model/PromptLifecycleTest.kt | 24 ++++++----------- .../model/SessionControllerTestBase.kt | 5 ++-- .../session/model/StatusComputationTest.kt | 21 +++++---------- .../client/session/model/TurnLifecycleTest.kt | 15 ++++------- 8 files changed, 31 insertions(+), 67 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt index 04e14857c25..3e0ac8985dd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -15,7 +15,6 @@ class AppWatchingTest : SessionControllerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.AppChanged }) - assertEquals(KiloAppStatusDto.READY, m.model.app.status) assertController( """ [app: READY] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt index 5b4397e3241..2ea673c9bbd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -12,7 +12,6 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertEquals("kilo/gpt-5", m.model.model) assertEquals(1, rpc.configs.size) assertEquals("kilo/gpt-5", rpc.configs[0].second.model) assertController( @@ -32,7 +31,6 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectAgent("plan") } flush() - assertEquals("plan", m.model.agent) assertEquals(1, rpc.configs.size) assertEquals("plan", rpc.configs[0].second.agent) assertController( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt index 7dc70a06ce7..9f4cd9016a3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -50,8 +50,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { fun `test session status busy fires StateChanged to Busy`() { val (_, _, model) = prompted() - emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) - flush() + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null)), flush = true) assertTrue(model.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt index 799ea089844..39828c2ad0b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -7,8 +7,7 @@ class MessageListTest : SessionControllerTestBase() { fun `test MessageUpdated adds message to ChatModel`() { val (m, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) assertTrue(model.any { it is SessionModelEvent.MessageAdded }) assertNotNull(m.model.message("msg1")) @@ -17,15 +16,11 @@ class MessageListTest : SessionControllerTestBase() { fun `test PartUpdated text updates ChatModel`() { val (m, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) - flush() + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")), flush = true) assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) - val p = m.model.content("msg1", "prt1") - assertTrue(p is Text) assertModel( """ assistant#msg1 @@ -39,17 +34,11 @@ class MessageListTest : SessionControllerTestBase() { fun `test PartDelta appends text to ChatModel`() { val (m, _, _) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello ")) - emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) - flush() + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"), flush = true) - val p = m.model.content("msg1", "prt1") - assertNotNull(p) - assertTrue(p is Text) - assertEquals("hello world", (p as Text).content.toString()) assertModel( """ assistant#msg1 @@ -63,12 +52,10 @@ class MessageListTest : SessionControllerTestBase() { fun `test MessageRemoved removes from ChatModel`() { val (m, _, _) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user")), flush = true) assertNotNull(m.model.message("msg1")) - emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) - flush() + emit(ChatEventDto.MessageRemoved("ses_test", "msg1"), flush = true) assertNull(m.model.message("msg1")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt index 9d72fb7289f..7110ee59491 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt @@ -12,8 +12,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() - emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) - flush() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = true) assertController( """ @@ -35,10 +34,8 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test PermissionReplied resumes Busy state`() { val (m, _, _) = prompted() - emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) - flush() - emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) - flush() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = true) + emit(ChatEventDto.PermissionReplied("ses_test", "perm1"), flush = true) assertController( """ @@ -51,8 +48,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionAsked moves state to AwaitingQuestion`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) - flush() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) assertController( """ @@ -73,10 +69,8 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionReplied resumes Busy state`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) - flush() - emit(ChatEventDto.QuestionReplied("ses_test", "q1")) - flush() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) + emit(ChatEventDto.QuestionReplied("ses_test", "q1"), flush = true) assertController( """ @@ -89,10 +83,8 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionRejected moves state to Idle`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) - flush() - emit(ChatEventDto.QuestionRejected("ses_test", "q1")) - flush() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) + emit(ChatEventDto.QuestionRejected("ses_test", "q1"), flush = true) assertController( """ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt index 51887973da1..e94233463b1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt @@ -122,8 +122,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } /** Emit a chat event into the fake RPC flow. */ - protected fun emit(event: ChatEventDto) = runBlocking { - rpc.events.emit(event) + protected fun emit(event: ChatEventDto, flush: Boolean = false) { + runBlocking { rpc.events.emit(event) } + if (flush) flush() } /** Create a model, attach both listeners, send initial prompt, and flush. */ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt index 0190c5a6c9e..6a2ae2d08cd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -7,14 +7,11 @@ class StatusComputationTest : SessionControllerTestBase() { fun `test status shows tool-specific text`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test")) - flush() + emit(ChatEventDto.TurnOpen("ses_test"), flush = true) - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) - flush() + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash")), flush = true) assertController( """ @@ -30,17 +27,13 @@ class StatusComputationTest : SessionControllerTestBase() { fun `test PartUpdated after TurnClose does not fire StateChanged`() { val (_, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - flush() - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - emit(ChatEventDto.TurnClose("ses_test", "completed")) - flush() + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) + emit(ChatEventDto.TurnOpen("ses_test"), flush = true) + emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = true) val before = model.filterIsInstance().size - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) - flush() + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late")), flush = true) val after = model.filterIsInstance().size assertEquals(before, after) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt index a66fc7d8fe3..93a57dc53ef 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -8,8 +8,7 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test TurnOpen fires StateChanged to Busy`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test")) - flush() + emit(ChatEventDto.TurnOpen("ses_test"), flush = true) assertController( """ @@ -22,10 +21,8 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test TurnClose fires StateChanged to Idle`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test")) - flush() - emit(ChatEventDto.TurnClose("ses_test", "completed")) - flush() + emit(ChatEventDto.TurnOpen("ses_test"), flush = true) + emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = true) assertController( """ @@ -38,8 +35,7 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test Error fires StateChanged to Error`() { val (m, _, _) = prompted() - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) - flush() + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request")), flush = true) assertController( """ @@ -52,8 +48,7 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test Error with null message falls back to type`() { val (m, _, _) = prompted() - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) - flush() + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null)), flush = true) assertController( """ From 27fa1e2d7fcff4631a5aeaf10b855ae8e5bd447c Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 18:48:03 -0400 Subject: [PATCH 14/27] refactor(jetbrains): move SessionController tests from session/model to session package --- .kilo/plans/1776454044955-clever-circuit.md | 225 +++++++++ .kilo/plans/1776459614129-curious-squid.md | 451 ++++++++++++++++++ .../session/{model => }/AppWatchingTest.kt | 3 +- .../{model => }/ConfigSelectionTest.kt | 4 +- .../session/{model => }/HistoryLoadingTest.kt | 4 +- .../{model => }/ListenerLifecycleTest.kt | 5 +- .../session/{model => }/MessageListTest.kt | 3 +- .../{model => }/PromptLifecycleTest.kt | 2 +- .../{model => }/SessionControllerTestBase.kt | 6 +- .../{model => }/SessionCreationTest.kt | 4 +- .../{model => }/StatusComputationTest.kt | 3 +- .../session/{model => }/TurnLifecycleTest.kt | 2 +- .../session/{model => }/ViewSwitchingTest.kt | 4 +- .../{model => }/WorkspaceWatchingTest.kt | 4 +- 14 files changed, 695 insertions(+), 25 deletions(-) create mode 100644 .kilo/plans/1776454044955-clever-circuit.md create mode 100644 .kilo/plans/1776459614129-curious-squid.md rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/AppWatchingTest.kt (85%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/ConfigSelectionTest.kt (92%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/HistoryLoadingTest.kt (92%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/ListenerLifecycleTest.kt (92%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/MessageListTest.kt (95%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/PromptLifecycleTest.kt (98%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/SessionControllerTestBase.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/SessionCreationTest.kt (92%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/StatusComputationTest.kt (93%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/TurnLifecycleTest.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/ViewSwitchingTest.kt (88%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{model => }/WorkspaceWatchingTest.kt (91%) diff --git a/.kilo/plans/1776454044955-clever-circuit.md b/.kilo/plans/1776454044955-clever-circuit.md new file mode 100644 index 00000000000..65efeffd143 --- /dev/null +++ b/.kilo/plans/1776454044955-clever-circuit.md @@ -0,0 +1,225 @@ +# JetBrains Frontend Session State Cleanup + +## Goal + +Replace the current `SessionPhase` + `PromptState` + `StatusState` stack with one explicit session state model that answers a single question cleanly: `what is this session doing right now?` + +## Why The Current Model Feels Messy + +### Current shape + +- `SessionModel.phase: SessionPhase` +- `SessionPhase.Working(val status: StatusState)` +- `SessionPhase.Prompting(val prompt: PromptState)` +- `StatusState` has only `Thinking(text)` and `Working(text)` +- `PromptState` has only `Asking(question)` and `Permitting(permission)` + +### Problems + +1. The real state is split across three types. + `SessionPhase` alone is not enough; callers often need the nested payload type too. + +2. `StatusState` does not carry independent behavior. + In practice the UI only reads `text`, and the `Thinking` vs `Working` distinction is not used meaningfully. + +3. `PromptState` is a wrapper around already meaningful domain models. + `Question` and `Permission` are already specific enough to be top-level session states. + +4. `SessionUi` already treats multiple variants as one concept. + Busy is derived with `phase is SessionPhase.Working || phase is SessionPhase.Prompting`, which is a sign the state model is indirect. + +5. Naming is working against readability. + `phase` sounds like an internal lifecycle step, while the user-facing need is a single `state`. + +## Recommended Target Model + +Rename the concept from `phase` to `state` and flatten it into one sealed class. + +```kotlin +sealed class SessionState { + data object Idle : SessionState() + data class Busy(val text: String) : SessionState() + data class AwaitingQuestion(val question: Question) : SessionState() + data class AwaitingPermission(val permission: Permission) : SessionState() + data class Retry(val message: String, val attempt: Int, val next: Long) : SessionState() + data class Offline(val message: String, val requestId: String) : SessionState() + data class Error(val message: String, val kind: String? = null) : SessionState() +} +``` + +### Design notes + +- `Busy(text)` replaces `SessionPhase.Working(StatusState.*)`. +- `AwaitingQuestion` replaces `SessionPhase.Prompting(PromptState.Asking(...))`. +- `AwaitingPermission` replaces `SessionPhase.Prompting(PromptState.Permitting(...))`. +- `Retry`, `Offline`, and `Error` stay top-level because they represent distinct user-visible conditions. +- `StatusState` and `PromptState` are removed entirely. + +## Intended Rules + +At any point, `SessionModel.state` must be exactly one of: + +1. `Idle` +2. `Busy` +3. `AwaitingQuestion` +4. `AwaitingPermission` +5. `Retry` +6. `Offline` +7. `Error` + +No nested state machine for normal rendering. The UI should only switch on `state`. + +## Transition Mapping + +Map chat events directly to the new state: + +| Event | New state | +| ------------------------------- | --------------------------------------------------------------- | +| `TurnOpen` | `Busy("Considering...")` | +| `PartUpdated(reasoning)` | `Busy("Thinking...")` | +| `PartUpdated(text)` | `Busy("Writing...")` | +| `PartUpdated(tool=...)` | `Busy(tool-specific text)` | +| `TurnClose` | `Idle` | +| `Error` | `Error(message, kind)` | +| `PermissionAsked` | `AwaitingPermission(permission)` | +| `PermissionReplied` | `Busy("Considering...")` | +| `QuestionAsked` | `AwaitingQuestion(question)` | +| `QuestionReplied` | `Busy("Considering...")` | +| `QuestionRejected` | `Idle` | +| `SessionStatusChanged(idle)` | `Idle` | +| `SessionStatusChanged(busy)` | `Busy("Considering...")` only when recovering from coarse state | +| `SessionStatusChanged(retry)` | `Retry(...)` | +| `SessionStatusChanged(offline)` | `Offline(...)` | + +## Implementation Plan + +### 1. Replace the state types + +Files: + +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt` +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt` +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt` + +Changes: + +- Introduce `SessionState` as the single sealed class. +- Remove `PromptState`. +- Remove `StatusState`. +- Either rename `SessionPhase.kt` to `SessionState.kt` or replace its contents with the new model and then rename references. + +### 2. Rename the model field and event + +Files: + +- `SessionModel.kt` +- `SessionModelEvent.kt` + +Changes: + +- Rename `phase` to `state`. +- Rename `setPhase()` to `setState()`. +- Rename `PhaseChanged` to `StateChanged`. +- Reset `state` to `Idle` in `loadHistory()` and `clear()`. + +Result: + +- `SessionModel` exposes exactly one current state field. + +### 3. Flatten session manager transitions + +File: + +- `SessionManager.kt` + +Changes: + +- Replace every `chat.setPhase(...)` call with `chat.setState(...)`. +- Remove `toPermissionPrompt()` and `toQuestionPrompt()` returning wrapper types. + Instead return `Permission` and `Question` directly or inline the construction. +- Replace `status(): StatusState` with `status(): String`. +- Convert all event branches to produce a direct `SessionState` variant. + +Important simplification: + +- There should be no code path that first decides `state = Prompting` and then asks the UI to inspect another type. +- There should be no code path that wraps a text label inside a second state class. + +### 4. Update UI consumers to switch on one type + +Files: + +- `SessionUi.kt` +- `MessageListUi.kt` +- any prompt or status panels that react to `PhaseChanged` + +Changes: + +- Switch listeners from `PhaseChanged` to `StateChanged`. +- Derive busy directly from `state`: + `Busy`, `AwaitingQuestion`, `AwaitingPermission`, `Retry`, and `Offline` are busy-like. +- Render message/footer text from `SessionState.Busy.text`. +- Render errors from `SessionState.Error.message`. + +Recommendation: + +- Add a small helper near the UI if needed: + `private fun SessionState.isBusy(): Boolean` + so the busy rule is centralized and readable. + +### 5. Update tests to assert direct states + +Files: + +- `SessionModelTest.kt` +- `TurnLifecycleTest.kt` +- `StatusComputationTest.kt` +- `ListenerLifecycleTest.kt` +- any prompt-related tests + +Changes: + +- Rename assertions from `PhaseChanged` to `StateChanged`. +- Replace `SessionPhase.Working(StatusState.Working(...))` style expectations with `SessionState.Busy(...)`. +- Replace `SessionPhase.Prompting(PromptState...)` expectations with `SessionState.AwaitingQuestion(...)` or `SessionState.AwaitingPermission(...)`. + +Add or update coverage for: + +- `TurnOpen -> Busy` +- `TurnClose -> Idle` +- `Error -> Error` +- `PermissionAsked -> AwaitingPermission` +- `PermissionReplied -> Busy` +- `QuestionAsked -> AwaitingQuestion` +- `QuestionRejected -> Idle` +- tool-specific `PartUpdated -> Busy(text)` +- `SessionStatusChanged(retry/offline)` producing explicit states + +### 6. Verify dead branches and naming fallout + +Search for and remove any remaining references to: + +- `SessionPhase` +- `PromptState` +- `StatusState` +- `phase` +- `PhaseChanged` + +Then verify there is only one current-state concept left in the frontend session model. + +## Recommended Implementation Order + +1. Add `SessionState` and update `SessionModel`/`SessionModelEvent`. +2. Update `SessionManager` transitions and status helper. +3. Update UI listeners and rendering. +4. Update tests. +5. Run frontend tests. + +## Expected Outcome + +After the refactor, the session model should read like this: + +- `chat.state` tells the full story. +- UI switches on one sealed class. +- No extra prompt/status wrapper types are needed. +- State transitions become straightforward to trace from chat events. diff --git a/.kilo/plans/1776459614129-curious-squid.md b/.kilo/plans/1776459614129-curious-squid.md new file mode 100644 index 00000000000..8c58a06075c --- /dev/null +++ b/.kilo/plans/1776459614129-curious-squid.md @@ -0,0 +1,451 @@ +# JetBrains Session Test Plan + +## Goal + +Change the JetBrains frontend session tests from many small event-only assertions toward readable string snapshots built into the production types themselves, with a transcript-first `SessionController.toString()` and a compact status line that reflects readiness and runtime state. Permission and question data should be rendered in the same order the real UI presents them: after the message transcript, at the bottom of the visible content, just above the final status line. + +## Scope + +1. Rename `SessionManagerTestBase` to `SessionControllerTestBase`. +2. Remove stored `SessionModel.ready` and replace it with a computed readiness method based on app/workspace status. +3. Ensure app/workspace state changes applied to `SessionModel` happen on the EDT immediately before their controller event is fired. +4. Add readable `toString()` on `SessionModel` and `SessionController`. +5. Add assertion helpers so tests compare multiline expected strings and separately verify whether messages are visible. +6. Convert permission/question assertions to use the `toString()` output instead of field-by-field state extraction. +7. Convert or add tests to use the new string representation. + +## Current State + +- The existing base class is `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt`. +- It is already controller-oriented: it creates `SessionController`, wires fake RPC services, exposes `prompted()`, `flush()`, and DTO factories. +- 11 frontend tests extend this base. +- `SessionModelTest` is separate and tests the pure model directly. +- There is no existing snapshot or golden assertion helper in `packages/kilo-jetbrains`. +- The nearest local convention is inline triple-quoted strings with `trimIndent()` and plain `assertEquals()`. +- `SessionModel` currently stores: + - `app` + - `workspace` + - `ready` + - `showMessages` + - message content and runtime state +- `SessionController` currently mutates `model.app` and `model.workspace` inside `edt { ... }`, then fires `AppChanged` / `WorkspaceChanged`. +- `SessionController` currently sets `model.ready = true` when workspace status becomes `READY`. +- Permission/question tests still contain field-by-field assertions like: + - cast `model.state` to `AwaitingPermission` or `AwaitingQuestion` + - assert `patterns`, `always`, `tool`, `filePath`, and other fields individually + +## Recommended Implementation + +### 1. Rename The Base + +Rename: + +- File: `SessionManagerTestBase.kt` -> `SessionControllerTestBase.kt` +- Class: `SessionManagerTestBase` -> `SessionControllerTestBase` + +Update all 11 subclasses to extend the new name. + +Note: + +- I will assume the intended final spelling is `SessionControllerTestBase` even though the request contains typos. +- I would not move packages or directories in this pass. The tests currently live under `session/model`, but the requested change only requires renaming the base class and updating usage. + +### 2. Make Readiness Computed, Not Stored + +Remove `SessionModel.ready`. + +Add a method on `SessionModel` that computes readiness from the app and workspace state, for example: + +- `fun isReady(): Boolean` + +Planned logic: + +- app is ready when `app.status == KiloAppStatusDto.READY` +- workspace is ready when `workspace.status == KiloWorkspaceStatusDto.READY` +- overall readiness is true only when both are ready + +This removes duplicated state and avoids drift between `ready` and the two underlying statuses. + +### 3. Update App/Workspace Model State On EDT Right Before Firing + +For app/workspace changes, mutate the model only on the EDT, then fire the matching controller event. + +Requested approach: + +- extend `fire()` with an optional closure that runs immediately before listeners are notified +- when already on the EDT, run the closure first and then notify listeners +- when not on the EDT, schedule both together so the mutation and event delivery still happen on the EDT in order + +Planned shape: + +- `private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null)` + +Use it for app/workspace updates like this conceptually: + +- `fire(SessionControllerEvent.AppChanged) { model.app = state; model.version = app.version }` +- `fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state; ... }` + +This gives two guarantees: + +- UI reads the updated model from the EDT +- listeners observe the event only after the model was updated on that same EDT turn + +### 4. Add Readable `toString()` Implementations + +The formatter should live in production types, not only in test helpers. + +Planned shape: + +- `SessionModel.toString()` +- `SessionController.toString()` + +Recommended responsibilities: + +- `SessionModel.toString()`: + - render message content and current interaction state + - include reasoning, tools, text, compaction, question, and permission data + - place question/permission blocks after the message transcript, matching the UI order + - omit app/workspace readiness noise +- `SessionController.toString()`: + - render the full user-facing transcript plus a final one-line status summary + - if the controller is not ready, summarize app/workspace status instead of repeating runtime status details + +### 5. Separate Dump Structure From Visibility Assertion + +Do not include visibility inside the dump string body. + +Instead: + +- the dump string should exclude message visibility metadata +- assertion helpers should separately verify whether messages are visible + +Planned helper shape: + +- `protected fun assertController(expected: String, c: SessionController, show: Boolean = true)` +- `protected fun assertModel(expected: String, m: SessionModel)` +- helper compares normalized string output +- helper then checks `c.model.showMessages == show` for controller assertions + +This keeps the dump readable and avoids noisy lines like `View: Messages` in every assertion. + +### 6. New String Format + +The controller dump should look like a real transcript first. + +Top section: + +- all messages in order +- no `Messages` header +- include reasoning, tool calls, text, and compaction entries + +Then, at the bottom of the transcript content: + +- if a question exists in current state, include it after the messages as its own block +- if a permission request exists in current state, include it after the messages as its own block +- this is where the real UI exposes those pending interactions +- separate top-level transcript blocks with `---` + +Bottom section: + +- one final status line + +If ready: + +- `[code] [kilo/gpt-5] [busy] [running commands]` + +If not ready: + +- do not include message visibility or runtime status text there +- include agent/model first when already known +- then summarize app/workspace readiness, for example: + - `[code] [kilo/gpt-5] [app: READY] [workspace: PENDING]` + - if agent/model are still unknown: `[app: READY] [workspace: PENDING]` + +### 7. Proposed Text Shape + +Ready + busy example: + +```text +user#msg1 +text#p1: + go +--- +assistant#msg2 +reasoning#p2: + Need to inspect files first +tool#p3 bash [RUNNING] ls +text#p4: + I found the issue + +[code] [kilo/gpt-5] [busy] [running commands] +``` + +Question example: + +```text +user#msg1 +text#p1: + go +--- +question#q1 +tool: msg1/call1 +header: Choice +prompt: Pick one +option: A - Option A +multiple: false +custom: true + +[code] [kilo/gpt-5] [awaiting-question] +``` + +Permission example: + +```text +user#msg1 +text#p1: + go +--- +permission#perm1 +tool: msg1/call1 +name: edit +patterns: *.kt +always: src/** +file: src/A.kt +state: RESPONDING +metadata: kind=edit + +[code] [kilo/gpt-5] [awaiting-permission] +``` + +Not ready example: + +```text +[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] +``` + +If agent/model are not known yet: + +```text +[app: READY] [workspace: PENDING] +``` + +Or, if there is already transcript content but readiness is still incomplete: + +```text +user#msg1 +text#p1: + go + +[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] +``` + +### 8. Permission/Question Assertions Through `toString()` + +Replace direct field assertions like this: + +```kotlin +val state = model.state as SessionState.AwaitingPermission +assertEquals(listOf("*.kt"), state.permission.patterns) +assertEquals(listOf("src/**"), state.permission.always) +assertEquals("src/A.kt", state.permission.meta.filePath) +assertEquals(PermissionRequestState.RESPONDING, state.permission.state) +assertEquals("msg1", state.permission.tool!!.messageId) +``` + +with a string assertion that renders the same information in UI order, for example: + +```kotlin +assertModel( + """ + user#msg1 + text#p1: + go + --- + permission#p1 + tool: msg1/call1 + name: edit + patterns: *.kt + always: src/** + file: src/A.kt + state: RESPONDING + metadata: kind=edit + """, + model, +) +``` + +Do the same for question data. + +This turns low-level state assertions into readable UI-like snapshots while still covering the same fields. + +### 9. `SessionModel.toString()` Role + +`SessionModel.toString()` should stay useful, but it does not need to be the main integration dump anymore. + +Recommended behavior: + +- same transcript content formatting used by `SessionController.toString()` +- no readiness line based on app/workspace +- no visibility information +- question/permission blocks still appear after message content +- optionally a compact final state line derived only from `SessionState` if useful for model-only tests + +This keeps model tests focused on model content/state while controller tests cover readiness and visibility separately. + +### 10. Formatting Rules + +- Preserve message order and part order using the existing `LinkedHashMap` order. +- Separate top-level transcript blocks with `---`. +- Render multiline text with an indented block under the entry header. +- Use `` for empty optional values to keep diffs explicit. +- Keep status words normalized and lowercase in brackets, for example `busy`, `idle`, `awaiting-question`. +- Include useful permission/question details directly in the dump: + - permission: id, tool ref, name, patterns, always, file path if present, request state if present, metadata + - question: id, tool ref, header, prompt, options, multiple/custom flags +- Avoid raw DTO dumps, autogenerated data-class output, or transport-layer formatting because they are harder to scan and less stable. + +### 11. Add Snapshot-Style Tests Around Controller Behavior + +Recommended first conversions or additions: + +- `PromptLifecycleTest` + - permission asked -> assert controller string shows transcript + permission block + final status line + - question asked -> assert controller string shows transcript + question block + final status line + - question rejected -> assert controller string returns to transcript + idle status line +- `TurnLifecycleTest` + - turn open -> assert controller string shows busy status line + - error -> assert controller string shows error state in the final line or error section if needed +- `StatusComputationTest` + - tool part update with `bash` -> assert final line becomes `[code] [kilo/gpt-5] [busy] [running commands]` +- `MessageListTest` + - assistant message with text / reasoning / tool parts -> assert transcript block formatting +- `HistoryLoadingTest` + - loaded history appears in the transcript exactly as the UI would render it +- `AppWatchingTest` / `WorkspaceWatchingTest` + - assert not-ready line shape such as `[app: READY] [workspace: PENDING]` +- `SessionModelTest` + - convert permission/question unit assertions to `assertModel(...)` snapshots when the goal is to verify visible content, not raw object storage +- visibility checks + - assertion helper verifies `showMessages` separately instead of embedding it in the dump string + +The existing fine-grained assertions do not all need to disappear in one pass. The safe approach is: + +- keep narrow unit tests in `SessionModelTest` where object identity or event behavior is the real subject +- add or convert scenario tests to assert the full readable dump +- keep visibility as a separate assertion +- prefer `toString()` assertions for permission/question content that is meant to mirror the UI + +### 12. Verification + +After implementation, run the relevant JetBrains frontend tests, ideally the session test package first, then broader frontend tests if needed. + +Primary verification target: + +- the `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/` suite + +## Concrete Edit List + +Expected touched files: + +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt` +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` +- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt` +- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt` + +## Risks And Mitigations + +- Risk: dump strings become too noisy. + - Mitigation: keep transcript first, visibility separate, and status compressed into one final line. +- Risk: readiness could become inconsistent if still stored separately. + - Mitigation: remove `ready` and derive readiness from app/workspace statuses only. +- Risk: UI could read app/workspace state off-EDT. + - Mitigation: apply app/workspace model mutations inside the EDT fire path before notifying listeners. +- Risk: tests become brittle if wording changes. + - Mitigation: keep labels stable, lowercase normalized state words, and compact bracket syntax. +- Risk: permission/question assertions lose coverage of important fields. + - Mitigation: include the important fields directly in the `toString()` format so the snapshot still covers them. + +## Recommended Final Shape + +1. Rename the base to `SessionControllerTestBase`. +2. Remove stored `SessionModel.ready` and add computed `isReady()`. +3. Update `SessionController.fire()` to support a `before` closure executed on the EDT before listener notification. +4. Move app/workspace model mutation into that EDT fire path. +5. Add transcript-style `toString()` implementations. +6. Place permission/question blocks after the messages, matching the real UI order. +7. Replace field-by-field permission/question assertions with `toString()` snapshot assertions. +8. Keep visibility checks out of the dump and assert them separately. +9. Convert controller tests to use the new transcript + one-line-status format. + +## Text Preview For The User + +Busy tool run: + +```text +user#msg1 +text#p1: + go +--- +assistant#msg2 +reasoning#p2: + Need to inspect files first +tool#p3 bash [RUNNING] ls +text#p4: + I found the issue + +[code] [kilo/gpt-5] [busy] [running commands] +``` + +Awaiting question: + +```text +user#msg1 +text#p1: + go +--- +question#q1 +tool: msg1/call1 +header: Choice +prompt: Pick one +option: A - Option A +multiple: false +custom: true + +[code] [kilo/gpt-5] [awaiting-question] +``` + +Awaiting permission: + +```text +user#msg1 +text#p1: + go +--- +permission#perm1 +tool: msg1/call1 +name: edit +patterns: *.kt +always: src/** +file: src/A.kt +state: RESPONDING +metadata: kind=edit + +[code] [kilo/gpt-5] [awaiting-permission] +``` + +Not ready: + +```text +[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] +``` diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt similarity index 85% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt index 3e0ac8985dd..b594f5f5353 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt @@ -1,6 +1,5 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session -import ai.kilocode.client.session.SessionControllerEvent import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt similarity index 92% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt index 2ea673c9bbd..198febbf940 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.session.SessionControllerEvent +package ai.kilocode.client.session class ConfigSelectionTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt similarity index 92% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt index 7ada34f1d33..2eb6b6ae3ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt @@ -1,6 +1,6 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session -import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.MessageWithPartsDto class HistoryLoadingTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt similarity index 92% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 9f4cd9016a3..0edf74b1bfc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -1,6 +1,7 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session -import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt similarity index 95% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index 39828c2ad0b..231c4a363ec 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -1,5 +1,6 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session +import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto class MessageListTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt index 7110ee59491..7d3116e0847 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PermissionRequestDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index e94233463b1..b191419bdee 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -1,9 +1,9 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt similarity index 92% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt index 749fcb8b5d8..a3a48bc9ba6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.session.SessionControllerEvent +package ai.kilocode.client.session class SessionCreationTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt similarity index 93% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt index 6a2ae2d08cd..0c63827f461 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -1,5 +1,6 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session +import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto class StatusComputationTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt index 93a57dc53ef..f68624427dd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.model +package ai.kilocode.client.session import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt similarity index 88% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt index 9354c0d7ce8..0504df11062 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.session.SessionControllerEvent +package ai.kilocode.client.session class ViewSwitchingTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt similarity index 91% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt index c103b228610..88358b2cef7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt @@ -1,6 +1,4 @@ -package ai.kilocode.client.session.model - -import ai.kilocode.client.session.SessionControllerEvent +package ai.kilocode.client.session class WorkspaceWatchingTest : SessionControllerTestBase() { From 2c7d46c22880490b643f784fb45e5bab695a6544 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 18:51:43 -0400 Subject: [PATCH 15/27] refactor(jetbrains): default emit flush to true, remove explicit flush=true at call sites --- .../client/session/AppWatchingTest.kt | 2 +- .../client/session/ConfigSelectionTest.kt | 4 +-- .../client/session/HistoryLoadingTest.kt | 2 +- .../client/session/ListenerLifecycleTest.kt | 2 +- .../client/session/MessageListTest.kt | 16 ++++++------ .../client/session/PromptLifecycleTest.kt | 26 +++++++++---------- .../session/SessionControllerTestBase.kt | 4 +-- .../client/session/SessionCreationTest.kt | 2 +- .../client/session/StatusComputationTest.kt | 16 ++++++------ .../client/session/TurnLifecycleTest.kt | 18 ++++++------- .../client/session/ViewSwitchingTest.kt | 2 +- .../client/session/WorkspaceWatchingTest.kt | 2 +- 12 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt index b594f5f5353..467ea7dd797 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt @@ -14,7 +14,7 @@ class AppWatchingTest : SessionControllerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.AppChanged }) - assertController( + assertSession( """ [app: READY] [workspace: PENDING] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt index 198febbf940..4b083cf3db3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt @@ -12,7 +12,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { assertEquals(1, rpc.configs.size) assertEquals("kilo/gpt-5", rpc.configs[0].second.model) - assertController( + assertSession( """ [kilo/gpt-5] [app: DISCONNECTED] [workspace: PENDING] """, @@ -31,7 +31,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { assertEquals(1, rpc.configs.size) assertEquals("plan", rpc.configs[0].second.agent) - assertController( + assertSession( """ [plan] [app: DISCONNECTED] [workspace: PENDING] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt index 2eb6b6ae3ba..b17e8cbb048 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt @@ -33,7 +33,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) - assertController( + assertSession( """ user#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 0edf74b1bfc..516a8126caa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -51,7 +51,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { fun `test session status busy fires StateChanged to Busy`() { val (_, _, model) = prompted() - emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null)), flush = true) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) assertTrue(model.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index 231c4a363ec..a774718494e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -8,7 +8,7 @@ class MessageListTest : SessionControllerTestBase() { fun `test MessageUpdated adds message to ChatModel`() { val (m, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) assertTrue(model.any { it is SessionModelEvent.MessageAdded }) assertNotNull(m.model.message("msg1")) @@ -17,9 +17,9 @@ class MessageListTest : SessionControllerTestBase() { fun `test PartUpdated text updates ChatModel`() { val (m, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")), flush = true) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) assertModel( @@ -35,10 +35,10 @@ class MessageListTest : SessionControllerTestBase() { fun `test PartDelta appends text to ChatModel`() { val (m, _, _) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello ")) - emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"), flush = true) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) assertModel( """ @@ -53,10 +53,10 @@ class MessageListTest : SessionControllerTestBase() { fun `test MessageRemoved removes from ChatModel`() { val (m, _, _) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user")), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) assertNotNull(m.model.message("msg1")) - emit(ChatEventDto.MessageRemoved("ses_test", "msg1"), flush = true) + emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) assertNull(m.model.message("msg1")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt index 7d3116e0847..871b879b56f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt @@ -12,9 +12,9 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() - emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = true) + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) - assertController( + assertSession( """ permission#perm1 tool: msg1/call1 @@ -34,10 +34,10 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test PermissionReplied resumes Busy state`() { val (m, _, _) = prompted() - emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = true) - emit(ChatEventDto.PermissionReplied("ses_test", "perm1"), flush = true) + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionReplied("ses_test", "perm1")) - assertController( + assertSession( """ [code] [kilo/gpt-5] [busy] [considering next steps] """, @@ -48,9 +48,9 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionAsked moves state to AwaitingQuestion`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) - assertController( + assertSession( """ question#q1 tool: msg1/call1 @@ -69,10 +69,10 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionReplied resumes Busy state`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) - emit(ChatEventDto.QuestionReplied("ses_test", "q1"), flush = true) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionReplied("ses_test", "q1")) - assertController( + assertSession( """ [code] [kilo/gpt-5] [busy] [considering next steps] """, @@ -83,10 +83,10 @@ class PromptLifecycleTest : SessionControllerTestBase() { fun `test QuestionRejected moves state to Idle`() { val (m, _, _) = prompted() - emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = true) - emit(ChatEventDto.QuestionRejected("ses_test", "q1"), flush = true) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionRejected("ses_test", "q1")) - assertController( + assertSession( """ [code] [kilo/gpt-5] [idle] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index b191419bdee..566f6b14f1e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -122,7 +122,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } /** Emit a chat event into the fake RPC flow. */ - protected fun emit(event: ChatEventDto, flush: Boolean = false) { + protected fun emit(event: ChatEventDto, flush: Boolean = true) { runBlocking { rpc.events.emit(event) } if (flush) flush() } @@ -148,7 +148,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { assertModel(expected, c.model) } - protected fun assertController(expected: String, c: SessionController, show: Boolean = true) { + protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) { assertEquals(expected.trimIndent().trim(), c.toString().trim()) assertEquals(show, c.model.showMessages) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt index a3a48bc9ba6..031b6415b38 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt @@ -13,7 +13,7 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) - assertController( + assertSession( """ [app: DISCONNECTED] [workspace: PENDING] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt index 0c63827f461..5c57d245c31 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -8,13 +8,13 @@ class StatusComputationTest : SessionControllerTestBase() { fun `test status shows tool-specific text`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test"), flush = true) + emit(ChatEventDto.TurnOpen("ses_test")) - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash")), flush = true) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) - assertController( + assertSession( """ assistant#msg1 tool#prt1 bash [PENDING] @@ -28,13 +28,13 @@ class StatusComputationTest : SessionControllerTestBase() { fun `test PartUpdated after TurnClose does not fire StateChanged`() { val (_, _, model) = prompted() - emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = true) - emit(ChatEventDto.TurnOpen("ses_test"), flush = true) - emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = true) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) val before = model.filterIsInstance().size - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late")), flush = true) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) val after = model.filterIsInstance().size assertEquals(before, after) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt index f68624427dd..07d0371d3fa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt @@ -8,9 +8,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test TurnOpen fires StateChanged to Busy`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test"), flush = true) + emit(ChatEventDto.TurnOpen("ses_test")) - assertController( + assertSession( """ [code] [kilo/gpt-5] [busy] [considering next steps] """, @@ -21,10 +21,10 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test TurnClose fires StateChanged to Idle`() { val (m, _, _) = prompted() - emit(ChatEventDto.TurnOpen("ses_test"), flush = true) - emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = true) + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) - assertController( + assertSession( """ [code] [kilo/gpt-5] [idle] """, @@ -35,9 +35,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test Error fires StateChanged to Error`() { val (m, _, _) = prompted() - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request")), flush = true) + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) - assertController( + assertSession( """ [code] [kilo/gpt-5] [error] [Bad Request] """, @@ -48,9 +48,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test Error with null message falls back to type`() { val (m, _, _) = prompted() - emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null)), flush = true) + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) - assertController( + assertSession( """ [code] [kilo/gpt-5] [error] [timeout] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt index 0504df11062..0058958efbf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt @@ -10,7 +10,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { flush() assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) - assertController( + assertSession( """ [app: DISCONNECTED] [workspace: PENDING] """, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt index 88358b2cef7..feaa51092a5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt @@ -16,7 +16,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) - assertController( + assertSession( """ [code] [kilo/gpt-5] [app: DISCONNECTED] [workspace: READY] """, From 7039760560a0d90035cded06d319ee95127759fe Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 19:05:00 -0400 Subject: [PATCH 16/27] refactor(jetbrains): fix outdated chat/ChatModel naming across session package --- .../ai/kilocode/client/KiloToolWindowFactory.kt | 6 +++--- .../kilocode/client/session/SessionController.kt | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../client/session/model/SessionModel.kt | 2 +- .../kilocode/client/session/ui/MessageListUi.kt | 2 +- .../ai/kilocode/client/session/ui/StatusPanel.kt | 12 ++++++------ .../kilocode/client/session/AppWatchingTest.kt | 2 +- .../client/session/ConfigSelectionTest.kt | 10 +++++----- .../client/session/HistoryLoadingTest.kt | 6 +++--- .../client/session/ListenerLifecycleTest.kt | 8 ++++---- .../kilocode/client/session/MessageListTest.kt | 16 ++++++++-------- .../client/session/SessionControllerTestBase.kt | 14 +++++++------- .../client/session/SessionCreationTest.kt | 6 +++--- .../client/session/StatusComputationTest.kt | 6 +++--- .../kilocode/client/session/ViewSwitchingTest.kt | 4 ++-- .../client/session/WorkspaceWatchingTest.kt | 4 ++-- 16 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index f6bbf13166e..060588265c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -62,10 +62,10 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { cs: CoroutineScope, ) { try { - val chat = SessionUi(project, workspace, sessions, app, cs) + val ui = SessionUi(project, workspace, sessions, app, cs) val content = ContentFactory.getInstance() - .createContent(chat, "", false) - content.setDisposer(chat) + .createContent(ui, "", false) + content.setDisposer(ui) toolWindow.contentManager.addContent(content) ActionManager.getInstance().getAction("Kilo.Settings")?.let { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 059866547b8..453873c38ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.launch * If null, lazily creates a session on the first [prompt] call. This ensures * event subscription happens before the prompt is sent, eliminating races. * - * Owns [SessionModel] — the single source of truth for chat content and + * Owns [SessionModel] — the single source of truth for session content and * state. UIs observe model changes via [SessionModelEvent] on [model]. * Lifecycle events (app/workspace state, view switching) are published * via [SessionControllerEvent] to registered listeners. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 155d47796ed..9393b5fb2af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -18,7 +18,7 @@ import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.JPanel -/** Main chat panel — reacts to [SessionController] events. */ +/** Main session panel — reacts to [SessionController] events. */ class SessionUi( project: Project, workspace: Workspace, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index fdda1eacfba..88e5305734c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -11,7 +11,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer /** - * Pure session model — single source of truth for chat content and runtime state. + * Pure session model — single source of truth for session content and runtime state. * * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all * reads and writes happen on the EDT. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index 3fee90beeb6..121833f6e7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -24,7 +24,7 @@ import javax.swing.JTextArea import javax.swing.border.MatteBorder /** - * Scrollable panel displaying chat messages aligned to the top, + * Scrollable panel displaying session messages aligned to the top, * with an optional animated status indicator at the bottom. * * Passive view — all rendering is driven by [SessionModelEvent]s diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index 40d8041f07c..a27b5471129 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -39,7 +39,7 @@ import javax.swing.SwingConstants */ class StatusPanel( parent: Disposable, - private val model: SessionController, + private val controller: SessionController, ) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { init { @@ -110,19 +110,19 @@ class StatusPanel( add(body, GridBagConstraints()) resetAll() - model.addListener(this, this) + controller.addListener(this, this) } override fun onEvent(event: SessionControllerEvent) { when (event) { is SessionControllerEvent.AppChanged -> { - renderApp(model.model.app) + renderApp(controller.model.app) revalidate() repaint() } is SessionControllerEvent.WorkspaceChanged -> { - renderWorkspace(model.model.workspace) + renderWorkspace(controller.model.workspace) revalidate() repaint() } @@ -187,7 +187,7 @@ class StatusPanel( } private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = model.model.app.status == KiloAppStatusDto.READY + val appReady = controller.model.app.status == KiloAppStatusDto.READY val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING wsSection.isVisible = visible if (!visible) return @@ -241,7 +241,7 @@ class StatusPanel( KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") KiloAppStatusDto.READY -> { - val ver = model.model.version + val ver = controller.model.version if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) else KiloBundle.message("toolwindow.status.connected") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt index 467ea7dd797..1dfeeed188e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt @@ -6,7 +6,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto class AppWatchingTest : SessionControllerTestBase() { fun `test app state change fires AppChanged`() { - val m = model() + val m = controller() val events = collect(m) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt index 4b083cf3db3..3bb1df26956 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt @@ -2,8 +2,8 @@ package ai.kilocode.client.session class ConfigSelectionTest : SessionControllerTestBase() { - fun `test selectModel updates ChatModel and calls updateConfig`() { - val m = model() + fun `test selectModel updates SessionModel and calls updateConfig`() { + val m = controller() collect(m) flush() @@ -21,8 +21,8 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } - fun `test selectAgent updates ChatModel and calls updateConfig`() { - val m = model() + fun `test selectAgent updates SessionModel and calls updateConfig`() { + val m = controller() collect(m) flush() @@ -41,7 +41,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { } fun `test selectModel fires WorkspaceReady event`() { - val m = model() + val m = controller() val events = collect(m) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt index b17e8cbb048..9990912d9de 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt @@ -10,8 +10,8 @@ class HistoryLoadingTest : SessionControllerTestBase() { val part = part("prt1", "ses_test", "msg1", "text", text = "hello") rpc.history.add(MessageWithPartsDto(m, listOf(part))) - val c = model("ses_test") - val events = collectModel(c) + val c = controller("ses_test") + val events = collectModelEvents(c) flush() assertTrue(events.any { it is SessionModelEvent.HistoryLoaded }) @@ -28,7 +28,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { fun `test non-empty history shows messages view`() { rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) - val c = model("ses_test") + val c = controller("ses_test") val events = collect(c) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 516a8126caa..0adb1e9cd4d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -9,7 +9,7 @@ import com.intellij.openapi.util.Disposer class ListenerLifecycleTest : SessionControllerTestBase() { fun `test listener removed on parent dispose`() { - val m = model() + val m = controller() val disposable = Disposer.newDisposable("listener-parent") Disposer.register(parent, disposable) @@ -29,7 +29,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { } fun `test all listeners notified`() { - val m = model() + val m = controller() val events1 = mutableListOf() val events2 = mutableListOf() val d1 = Disposer.newDisposable("l1") @@ -49,10 +49,10 @@ class ListenerLifecycleTest : SessionControllerTestBase() { } fun `test session status busy fires StateChanged to Busy`() { - val (_, _, model) = prompted() + val (_, _, modelEvents) = prompted() emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) - assertTrue(model.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) + assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index a774718494e..e2e0899681b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -5,23 +5,23 @@ import ai.kilocode.rpc.dto.ChatEventDto class MessageListTest : SessionControllerTestBase() { - fun `test MessageUpdated adds message to ChatModel`() { - val (m, _, model) = prompted() + fun `test MessageUpdated adds message to SessionModel`() { + val (m, _, modelEvents) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - assertTrue(model.any { it is SessionModelEvent.MessageAdded }) + assertTrue(modelEvents.any { it is SessionModelEvent.MessageAdded }) assertNotNull(m.model.message("msg1")) } - fun `test PartUpdated text updates ChatModel`() { - val (m, _, model) = prompted() + fun `test PartUpdated text updates SessionModel`() { + val (m, _, modelEvents) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) - assertTrue(model.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) + assertTrue(modelEvents.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) assertModel( """ assistant#msg1 @@ -32,7 +32,7 @@ class MessageListTest : SessionControllerTestBase() { ) } - fun `test PartDelta appends text to ChatModel`() { + fun `test PartDelta appends text to SessionModel`() { val (m, _, _) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) @@ -50,7 +50,7 @@ class MessageListTest : SessionControllerTestBase() { ) } - fun `test MessageRemoved removes from ChatModel`() { + fun `test MessageRemoved removes from SessionModel`() { val (m, _, _) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 566f6b14f1e..c8aa23fb19e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -77,9 +77,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } } - // ------ Model creation ------ + // ------ Controller creation ------ - protected fun model(id: String? = null) = + protected fun controller(id: String? = null) = SessionController(parent, id, sessions, workspace, app, scope) // ------ Event collection ------ @@ -97,7 +97,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } /** Attach a listener that collects model events (messages, parts, phase). */ - protected fun collectModel(m: SessionController): MutableList { + protected fun collectModelEvents(m: SessionController): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) @@ -127,17 +127,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { if (flush) flush() } - /** Create a model, attach both listeners, send initial prompt, and flush. */ + /** Create a controller, attach both listeners, send initial prompt, and flush. */ protected fun prompted(): Triple, MutableList> { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() - val m = model() + val m = controller() val events = collect(m) - val model = collectModel(m) + val modelEvents = collectModelEvents(m) flush() edt { m.prompt("go") } flush() - return Triple(m, events, model) + return Triple(m, events, modelEvents) } protected fun assertModel(expected: String, model: SessionModel) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt index 031b6415b38..f0e9912e96a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session class SessionCreationTest : SessionControllerTestBase() { fun `test prompt creates session on first call`() { - val m = model() + val m = controller() val events = collect(m) edt { m.prompt("hello") } @@ -22,7 +22,7 @@ class SessionCreationTest : SessionControllerTestBase() { } fun `test prompt reuses existing session`() { - val m = model() + val m = controller() edt { m.prompt("first") } flush() @@ -35,7 +35,7 @@ class SessionCreationTest : SessionControllerTestBase() { } fun `test prompt with existing ID skips creation`() { - val m = model("existing") + val m = controller("existing") collect(m) flush() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt index 5c57d245c31..17c6d6f6a11 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -26,17 +26,17 @@ class StatusComputationTest : SessionControllerTestBase() { } fun `test PartUpdated after TurnClose does not fire StateChanged`() { - val (_, _, model) = prompted() + val (_, _, modelEvents) = prompted() emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) emit(ChatEventDto.TurnOpen("ses_test")) emit(ChatEventDto.TurnClose("ses_test", "completed")) - val before = model.filterIsInstance().size + val before = modelEvents.filterIsInstance().size emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) - val after = model.filterIsInstance().size + val after = modelEvents.filterIsInstance().size assertEquals(before, after) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt index 0058958efbf..7a61359b847 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session class ViewSwitchingTest : SessionControllerTestBase() { fun `test first prompt shows messages view`() { - val m = model() + val m = controller() val events = collect(m) edt { m.prompt("hello") } @@ -19,7 +19,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { } fun `test ViewChanged not fired twice`() { - val m = model() + val m = controller() val events = collect(m) edt { m.prompt("first") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt index feaa51092a5..58da9ed5a24 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt @@ -3,7 +3,7 @@ package ai.kilocode.client.session class WorkspaceWatchingTest : SessionControllerTestBase() { fun `test workspace ready populates agents and models`() { - val m = model() + val m = controller() val events = collect(m) flush() @@ -26,7 +26,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { } fun `test workspace ready sets default agent and model`() { - val m = model() + val m = controller() collect(m) flush() From dc49c96d1ab923fdfcb7829fd24dffe2f198c939 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 19:35:51 -0400 Subject: [PATCH 17/27] refactor(jetbrains): implement toString on events and replace assertions with string dumps --- .../client/session/SessionControllerEvent.kt | 4 +++- .../client/session/model/SessionModelEvent.kt | 24 ++++++++++++++----- .../client/session/AppWatchingTest.kt | 3 ++- .../client/session/ConfigSelectionTest.kt | 3 ++- .../client/session/HistoryLoadingTest.kt | 6 ++--- .../client/session/ListenerLifecycleTest.kt | 23 ++++++++++++------ .../client/session/MessageListTest.kt | 7 ++++-- .../session/SessionControllerTestBase.kt | 8 +++++++ .../client/session/SessionCreationTest.kt | 4 +++- .../client/session/StatusComputationTest.kt | 11 +++++---- .../client/session/ViewSwitchingTest.kt | 8 +++++-- .../client/session/WorkspaceWatchingTest.kt | 6 ++++- 12 files changed, 76 insertions(+), 31 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt index f7e0519fcf8..6026aed5e28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt @@ -18,7 +18,9 @@ sealed class SessionControllerEvent { // Workspace ready (pickers populated) data object WorkspaceReady : SessionControllerEvent() - data class ViewChanged(val show: Boolean) : SessionControllerEvent() + data class ViewChanged(val show: Boolean) : SessionControllerEvent() { + override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide" + } } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 098b975343b..c135cc176ae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -7,12 +7,24 @@ package ai.kilocode.client.session.model * reading back from the model except for [HistoryLoaded]. */ sealed class SessionModelEvent { - data class MessageAdded(val info: Message) : SessionModelEvent() - data class MessageRemoved(val id: String) : SessionModelEvent() - data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() - data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() - data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() - data class StateChanged(val state: SessionState) : SessionModelEvent() + data class MessageAdded(val info: Message) : SessionModelEvent() { + override fun toString() = "MessageAdded ${info.info.id}" + } + data class MessageRemoved(val id: String) : SessionModelEvent() { + override fun toString() = "MessageRemoved $id" + } + data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() { + override fun toString() = "ContentAdded $messageId/${content.id}" + } + data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() { + override fun toString() = "ContentUpdated $messageId/${content.id}" + } + data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() { + override fun toString() = "ContentDelta $messageId/$contentId" + } + data class StateChanged(val state: SessionState) : SessionModelEvent() { + override fun toString() = "StateChanged ${state::class.simpleName}" + } data object HistoryLoaded : SessionModelEvent() data object Cleared : SessionModelEvent() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt index 1dfeeed188e..157642dc922 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt @@ -9,11 +9,12 @@ class AppWatchingTest : SessionControllerTestBase() { val m = controller() val events = collect(m) flush() + events.clear() appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) flush() - assertTrue(events.any { it is SessionControllerEvent.AppChanged }) + assertControllerEvents("AppChanged", events) assertSession( """ [app: READY] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt index 3bb1df26956..eb53354c488 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt @@ -44,10 +44,11 @@ class ConfigSelectionTest : SessionControllerTestBase() { val m = controller() val events = collect(m) flush() + events.clear() edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) + assertControllerEvents("WorkspaceReady", events) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt index 9990912d9de..6a892a4a5f1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt @@ -11,10 +11,10 @@ class HistoryLoadingTest : SessionControllerTestBase() { rpc.history.add(MessageWithPartsDto(m, listOf(part))) val c = controller("ses_test") - val events = collectModelEvents(c) + val modelEvents = collectModelEvents(c) flush() - assertTrue(events.any { it is SessionModelEvent.HistoryLoaded }) + assertModelEvents("HistoryLoaded", modelEvents) assertModel( """ user#msg1 @@ -29,10 +29,8 @@ class HistoryLoadingTest : SessionControllerTestBase() { rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) val c = controller("ses_test") - val events = collect(c) flush() - assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) assertSession( """ user#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 0adb1e9cd4d..ac8907c3946 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -1,7 +1,6 @@ package ai.kilocode.client.session import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer @@ -18,14 +17,17 @@ class ListenerLifecycleTest : SessionControllerTestBase() { edt { m.prompt("before") } flush() - val before = events.size Disposer.dispose(disposable) edt { m.prompt("after") } flush() - assertEquals(before, events.size) + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events) } fun `test all listeners notified`() { @@ -43,9 +45,16 @@ class ListenerLifecycleTest : SessionControllerTestBase() { edt { m.prompt("go") } flush() - assertTrue(events1.isNotEmpty()) - assertTrue(events2.isNotEmpty()) - assertEquals(events1.map { it::class }, events2.map { it::class }) + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events1) + assertControllerEvents(""" + ViewChanged show + AppChanged + WorkspaceChanged + """, events2) } fun `test session status busy fires StateChanged to Busy`() { @@ -53,6 +62,6 @@ class ListenerLifecycleTest : SessionControllerTestBase() { emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null))) - assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged && (it.state is SessionState.Busy) }) + assertModelEvents("StateChanged Busy", modelEvents) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index e2e0899681b..f106384c8a5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -10,7 +10,7 @@ class MessageListTest : SessionControllerTestBase() { emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - assertTrue(modelEvents.any { it is SessionModelEvent.MessageAdded }) + assertModelEvents("MessageAdded msg1", modelEvents) assertNotNull(m.model.message("msg1")) } @@ -21,7 +21,10 @@ class MessageListTest : SessionControllerTestBase() { emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) - assertTrue(modelEvents.any { it is SessionModelEvent.ContentAdded && it.messageId == "msg1" }) + assertModelEvents(""" + MessageAdded msg1 + ContentAdded msg1/prt1 + """, modelEvents) assertModel( """ assistant#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index c8aa23fb19e..d4410271665 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -153,6 +153,14 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { assertEquals(show, c.model.showMessages) } + protected fun assertControllerEvents(expected: String, events: List) { + assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + } + + protected fun assertModelEvents(expected: String, events: List) { + assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + } + // ------ DTO factories ------ protected fun msg(id: String, sid: String, role: String) = MessageDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt index f0e9912e96a..ac7d2c9a944 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt @@ -5,6 +5,8 @@ class SessionCreationTest : SessionControllerTestBase() { fun `test prompt creates session on first call`() { val m = controller() val events = collect(m) + flush() + events.clear() edt { m.prompt("hello") } flush() @@ -12,7 +14,7 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) + assertControllerEvents("ViewChanged show", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt index 17c6d6f6a11..d82f05b2f2a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -31,12 +31,13 @@ class StatusComputationTest : SessionControllerTestBase() { emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) emit(ChatEventDto.TurnOpen("ses_test")) emit(ChatEventDto.TurnClose("ses_test", "completed")) - - val before = modelEvents.filterIsInstance().size - emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) - val after = modelEvents.filterIsInstance().size - assertEquals(before, after) + assertModelEvents(""" + MessageAdded msg1 + StateChanged Busy + StateChanged Idle + ContentAdded msg1/prt1 + """, modelEvents) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt index 7a61359b847..66f687eb884 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt @@ -5,11 +5,13 @@ class ViewSwitchingTest : SessionControllerTestBase() { fun `test first prompt shows messages view`() { val m = controller() val events = collect(m) + flush() + events.clear() edt { m.prompt("hello") } flush() - assertTrue(events.any { it is SessionControllerEvent.ViewChanged && it.show }) + assertControllerEvents("ViewChanged show", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] @@ -21,12 +23,14 @@ class ViewSwitchingTest : SessionControllerTestBase() { fun `test ViewChanged not fired twice`() { val m = controller() val events = collect(m) + flush() + events.clear() edt { m.prompt("first") } flush() edt { m.prompt("second") } flush() - assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged && it.show }) + assertControllerEvents("ViewChanged show", events) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt index 58da9ed5a24..92ba9f79785 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt @@ -6,6 +6,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { val m = controller() val events = collect(m) flush() + events.clear() projectRpc.state.value = workspaceReady() flush() @@ -15,7 +16,10 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals(1, m.model.models.size) assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) - assertTrue(events.any { it is SessionControllerEvent.WorkspaceReady }) + assertControllerEvents(""" + WorkspaceChanged + WorkspaceReady + """, events) assertSession( """ [code] [kilo/gpt-5] [app: DISCONNECTED] [workspace: READY] From 7b6a7317f4ee7320107bccf7400556a43bdd21ae Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 18 Apr 2026 16:08:46 -0400 Subject: [PATCH 18/27] =?UTF-8?q?feat(jetbrains):=20complete=20JetBrains?= =?UTF-8?q?=20chat=20stack=20=E2=80=94=20full=20event=20surface,=20permiss?= =?UTF-8?q?ion/question=20RPC,=20and=20session=20model=20upgrades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full active-session chat backend for the JetBrains plugin: - Canonicalize shared ChatEventDto @SerialName values to match CLI event names (part.updated → message.part.updated, turn.open → session.turn.open, etc.) - Add PartRemoved, SessionIdle, SessionCompacted, SessionDiffChanged, TodoUpdated events and extend SessionStatusDto with retry/offline fields (attempt, next, requestID) - Add replyPermission, savePermissionRules, replyQuestion, rejectQuestion, pendingPermissions, pendingQuestions to KiloSessionRpcApi and implement them in backend (HTTP calls) and frontend (service + controller methods) - Expand CHAT_EVENTS set from 7 to 18, add callID to PartDto, fix JsonNull tool-ref handling in KiloCliDataParser - Upgrade SessionModel: upsertMessage (true add-or-update), removeContent, Generic fallback content type instead of silently dropping unknown parts - Fix SessionController: upsert semantics, requestID matching for permission/question replies, TurnClose reason awareness, full retry/offline detail in status, session-ID guard, recoverPending after history load - 262 tests passing (91 frontend, 171 backend) --- .../backend/app/KiloBackendChatManager.kt | 78 ++++ .../kilocode/backend/cli/KiloCliDataParser.kt | 164 +++++++- .../backend/rpc/KiloSessionRpcApiImpl.kt | 38 ++ .../backend/cli/ChatDtoSerializationTest.kt | 201 ++++++++++ .../backend/cli/KiloCliDataParserTest.kt | 377 ++++++++++++++++++ .../kilocode/client/app/KiloSessionService.kt | 35 ++ .../client/session/SessionController.kt | 133 +++++- .../kilocode/client/session/model/Message.kt | 6 + .../client/session/model/SessionModel.kt | 35 +- .../client/session/model/SessionModelEvent.kt | 6 + .../client/session/ui/MessageListUi.kt | 16 + .../client/session/ListenerLifecycleTest.kt | 62 +++ .../client/session/MessageListTest.kt | 51 +++ .../client/session/PromptLifecycleTest.kt | 81 ++++ .../client/session/SessionRecoveryTest.kt | 107 +++++ .../client/session/TurnLifecycleTest.kt | 104 +++++ .../client/session/model/SessionModelTest.kt | 72 +++- .../client/testing/FakeSessionRpcApi.kt | 45 +++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 25 ++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 83 +++- .../kotlin/ai/kilocode/rpc/dto/SessionDto.kt | 3 + 21 files changed, 1681 insertions(+), 41 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 379661bb358..497008ca7fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -5,7 +5,12 @@ import ai.kilocode.backend.util.KiloLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -38,9 +43,20 @@ class KiloBackendChatManager( "message.removed", "message.part.updated", "message.part.delta", + "message.part.removed", "session.turn.open", "session.turn.close", "session.error", + "session.status", + "session.idle", + "session.compacted", + "session.diff", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "todo.updated", ) } @@ -174,8 +190,70 @@ class KiloBackendChatManager( } } + // ------ permission / question ------ + + fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) { + val body = KiloCliDataParser.buildPermissionReplyJson(reply) + post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission") + } + + fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) { + val body = KiloCliDataParser.buildPermissionAlwaysRulesJson(rules) + post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules") + } + + fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) { + val body = KiloCliDataParser.buildQuestionReplyJson(answers) + post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion") + } + + fun rejectQuestion(requestId: String, dir: String) { + post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion") + } + + fun pendingPermissions(dir: String): List { + val raw = get("/permission?directory=${encode(dir)}", "pendingPermissions") ?: return emptyList() + return KiloCliDataParser.parsePermissionRequests(raw) + } + + fun pendingQuestions(dir: String): List { + val raw = get("/question?directory=${encode(dir)}", "pendingQuestions") ?: return emptyList() + return KiloCliDataParser.parseQuestionRequests(raw) + } + // ------ utilities ------ + private fun post(path: String, body: String, op: String) { + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url$path") + .post(body.toRequestBody(JSON_TYPE)) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("$op failed: HTTP ${response.code}") + } + } + } + + private fun get(path: String, op: String): String? { + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url$path") + .get() + .build() + return http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("$op failed: HTTP ${response.code}") + null + } else { + response.body?.string() + } + } + } + private fun requireClient(): OkHttpClient = client ?: throw IllegalStateException("Chat manager not started") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 68977bd985c..73057f894fd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -2,24 +2,30 @@ package ai.kilocode.backend.cli import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.ToolRefDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull @@ -147,13 +153,58 @@ object KiloCliDataParser { ChatEventDto.QuestionRejected(sid, rid) } + "message.part.removed" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + val pid = props.str("partID") ?: return null + ChatEventDto.PartRemoved(sid, mid, pid) + } + "session.status" -> { val sid = props.str("sessionID") ?: return null val st = props["status"]?.jsonObject ?: return null - val dto = SessionStatusDto(st.str("type") ?: "idle", st.str("message")) + val dto = parseStatus(st) ChatEventDto.SessionStatusChanged(sid, dto) } + "session.idle" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.SessionIdle(sid) + } + + "session.compacted" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.SessionCompacted(sid) + } + + "session.diff" -> { + val sid = props.str("sessionID") ?: return null + val diffs = props["diff"]?.jsonArray?.mapNotNull { elem -> + val d = elem.jsonObject + val file = d.str("file") ?: return@mapNotNull null + DiffFileDto( + file = file, + additions = d.long("additions")?.toInt() ?: 0, + deletions = d.long("deletions")?.toInt() ?: 0, + patch = d.str("patch"), + ) + } ?: emptyList() + ChatEventDto.SessionDiffChanged(sid, diffs) + } + + "todo.updated" -> { + val sid = props.str("sessionID") ?: return null + val todos = props["todos"]?.jsonArray?.map { elem -> + val t = elem.jsonObject + TodoDto( + content = t.str("content") ?: "", + status = t.str("status") ?: "pending", + priority = t.str("priority") ?: "medium", + ) + } ?: emptyList() + ChatEventDto.TodoUpdated(sid, todos) + } + else -> null } } @@ -161,15 +212,14 @@ object KiloCliDataParser { /** * Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair. * Returns null if the required fields are missing. - * - * Uses regex extraction (no full JSON parse) for consistency with - * the existing high-throughput status event handling. */ fun parseSessionStatus(data: String): Pair? { - val id = extractField(data, "sessionID") ?: return null - val type = extractNested(data, "status", "type") ?: "idle" - val msg = extractNested(data, "status", "message") - return id to SessionStatusDto(type, msg) + val obj = tryParseObject(data) ?: return null + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: obj + val id = props.str("sessionID") ?: return null + val st = props["status"]?.jsonObject ?: return id to SessionStatusDto("idle") + return id to parseStatus(st) } // ================================================================ @@ -297,6 +347,7 @@ object KiloCliDataParser { type = obj.str("type") ?: "unknown", text = obj.str("text"), tool = obj.str("tool"), + callID = obj.str("callID"), state = state?.str("status"), title = state?.str("title"), ) @@ -319,11 +370,7 @@ object KiloCliDataParser { val meta = obj["metadata"]?.jsonObject?.let { m -> m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") } } ?: emptyMap() - val ref = obj["tool"]?.jsonObject?.let { t -> - val mid = t.str("messageID") ?: return@let null - val cid = t.str("callID") ?: return@let null - ToolRefDto(mid, cid) - } + val ref = toolRef(obj) return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref) } @@ -344,11 +391,7 @@ object KiloCliDataParser { custom = qo.str("custom") != "false", ) } ?: emptyList() - val ref = obj["tool"]?.jsonObject?.let { t -> - val mid = t.str("messageID") ?: return@let null - val cid = t.str("callID") ?: return@let null - ToolRefDto(mid, cid) - } + val ref = toolRef(obj) return QuestionRequestDto(id, sid, questions, ref) } @@ -377,6 +420,91 @@ object KiloCliDataParser { ) } + // ================================================================ + // Internal — status parsing + // ================================================================ + + /** Safely parse an optional tool reference, handling `null` JSON values. */ + private fun toolRef(obj: JsonObject): ToolRefDto? { + val elem = obj["tool"] ?: return null + if (elem is JsonNull) return null + val t = elem.jsonObject + val mid = t.str("messageID") ?: return null + val cid = t.str("callID") ?: return null + return ToolRefDto(mid, cid) + } + + internal fun parseStatus(st: JsonObject): SessionStatusDto { + val type = st.str("type") ?: "idle" + return SessionStatusDto( + type = type, + message = st.str("message"), + attempt = st.long("attempt")?.toInt(), + next = st.long("next"), + requestID = st.str("requestID"), + ) + } + + // ================================================================ + // HTTP response parsing — lists + // ================================================================ + + /** + * Parse a list of pending permission requests (`GET /permission`). + */ + fun parsePermissionRequests(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + parsePermissionRequest(elem.jsonObject) + } + } + + /** + * Parse a list of pending question requests (`GET /question`). + */ + fun parseQuestionRequests(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + parseQuestionRequest(elem.jsonObject) + } + } + + // ================================================================ + // JSON serialization (DTO → JSON for outgoing permission/question requests) + // ================================================================ + + /** + * Build the JSON body for `POST /permission/{requestID}/reply`. + */ + fun buildPermissionReplyJson(reply: PermissionReplyDto): String { + val sb = StringBuilder() + sb.append("""{"reply":${escape(reply.reply)}""") + val msg = reply.message + if (msg != null) sb.append(""","message":${escape(msg)}""") + sb.append("}") + return sb.toString() + } + + /** + * Build the JSON body for `POST /permission/{requestID}/always-rules`. + */ + fun buildPermissionAlwaysRulesJson(rules: PermissionAlwaysRulesDto): String { + val approved = rules.approvedAlways.joinToString(",") { escape(it) } + val denied = rules.deniedAlways.joinToString(",") { escape(it) } + return """{"approvedAlways":[$approved],"deniedAlways":[$denied]}""" + } + + /** + * Build the JSON body for `POST /question/{requestID}/reply`. + */ + fun buildQuestionReplyJson(reply: QuestionReplyDto): String { + val answers = reply.answers.joinToString(",") { inner -> + val items = inner.joinToString(",") { escape(it) } + "[$items]" + } + return """{"answers":[$answers]}""" + } + // ================================================================ // Internal — regex-based field extraction (for status events) // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 33927bc280c..406faed16fc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -10,7 +10,12 @@ 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.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -88,6 +93,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.MessageUpdated -> event.sessionID is ChatEventDto.PartUpdated -> event.sessionID is ChatEventDto.PartDelta -> event.sessionID + is ChatEventDto.PartRemoved -> event.sessionID is ChatEventDto.TurnOpen -> event.sessionID is ChatEventDto.TurnClose -> event.sessionID is ChatEventDto.Error -> event.sessionID @@ -98,10 +104,42 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.QuestionReplied -> event.sessionID is ChatEventDto.QuestionRejected -> event.sessionID is ChatEventDto.SessionStatusChanged -> event.sessionID + is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionCompacted -> event.sessionID + is ChatEventDto.SessionDiffChanged -> event.sessionID + is ChatEventDto.TodoUpdated -> event.sessionID } sid == id } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = chat.updateConfig(directory, config) + + // ------ permission / question resolution ------ + + override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + LOG.info("replyPermission: requestId=$requestId, reply=${reply.reply}") + chat.replyPermission(requestId, directory, reply) + } + + override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + LOG.info("savePermissionRules: requestId=$requestId") + chat.savePermissionRules(requestId, directory, rules) + } + + override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + LOG.info("replyQuestion: requestId=$requestId, answers=${answers.answers.size}") + chat.replyQuestion(requestId, directory, answers) + } + + override suspend fun rejectQuestion(requestId: String, directory: String) { + LOG.info("rejectQuestion: requestId=$requestId") + chat.rejectQuestion(requestId, directory) + } + + override suspend fun pendingPermissions(directory: String): List = + chat.pendingPermissions(directory) + + override suspend fun pendingQuestions(directory: String): List = + chat.pendingQuestions(directory) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt new file mode 100644 index 00000000000..5ba748e1aa0 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -0,0 +1,201 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Round-trip serialization tests for [ChatEventDto]. + * + * Verifies that the @SerialName values match the real CLI event type strings + * and that polymorphic serialization/deserialization works end-to-end. + */ +class ChatDtoSerializationTest { + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + } + + // ================================================================ + // Serial name round-trips — confirms @SerialName matches CLI types + // ================================================================ + + @Test + fun `MessageUpdated serializes with correct type discriminator`() { + val event: ChatEventDto = ChatEventDto.MessageUpdated( + sessionID = "ses_1", + info = msg("msg_1"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.updated""""), "Expected type=message.updated in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.MessageUpdated) + assertEquals("ses_1", (decoded as ChatEventDto.MessageUpdated).sessionID) + } + + @Test + fun `PartUpdated serializes with type message dot part dot updated`() { + val event: ChatEventDto = ChatEventDto.PartUpdated( + sessionID = "ses_1", + part = part("p1"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.updated""""), "Expected type=message.part.updated in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.PartUpdated) + } + + @Test + fun `PartDelta serializes with type message dot part dot delta`() { + val event: ChatEventDto = ChatEventDto.PartDelta("ses_1", "msg_1", "part_1", "text", "hello") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.delta""""), "Expected type=message.part.delta in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartDelta + assertEquals("hello", decoded.delta) + } + + @Test + fun `PartRemoved serializes with type message dot part dot removed`() { + val event: ChatEventDto = ChatEventDto.PartRemoved("ses_1", "msg_1", "part_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"message.part.removed""""), "Expected type=message.part.removed in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartRemoved + assertEquals("part_1", decoded.partID) + } + + @Test + fun `TurnOpen serializes with type session dot turn dot open`() { + val event: ChatEventDto = ChatEventDto.TurnOpen("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.turn.open""""), "Expected type=session.turn.open in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) + assertTrue(decoded is ChatEventDto.TurnOpen) + } + + @Test + fun `TurnClose serializes with type session dot turn dot close`() { + val event: ChatEventDto = ChatEventDto.TurnClose("ses_1", "completed") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.turn.close""""), "Expected type=session.turn.close in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.TurnClose + assertEquals("completed", decoded.reason) + } + + @Test + fun `Error serializes with type session dot error`() { + val event: ChatEventDto = ChatEventDto.Error("ses_1", MessageErrorDto("timeout", "Timed out")) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.error""""), "Expected type=session.error in: $encoded") + } + + @Test + fun `SessionStatusChanged serializes with full retry detail`() { + val event: ChatEventDto = ChatEventDto.SessionStatusChanged( + sessionID = "ses_1", + status = SessionStatusDto("retry", "Retrying", attempt = 3, next = 5000L), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.status""""), "Expected type=session.status in: $encoded") + assertTrue(encoded.contains(""""attempt":3"""), "Expected attempt in: $encoded") + assertTrue(encoded.contains(""""next":5000"""), "Expected next in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged + assertEquals(3, decoded.status.attempt) + assertEquals(5000L, decoded.status.next) + } + + @Test + fun `SessionStatusChanged serializes with offline requestID`() { + val event: ChatEventDto = ChatEventDto.SessionStatusChanged( + sessionID = "ses_1", + status = SessionStatusDto("offline", "No connection", requestID = "req_abc"), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""requestID":"req_abc""""), "Expected requestID in: $encoded") + + val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged + assertEquals("req_abc", decoded.status.requestID) + } + + @Test + fun `PermissionAsked serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.PermissionAsked( + sessionID = "ses_1", + request = PermissionRequestDto("p1", "ses_1", "edit", listOf("*.kt")), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"permission.asked"""")) + } + + @Test + fun `QuestionAsked serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.QuestionAsked( + sessionID = "ses_1", + request = QuestionRequestDto("q1", "ses_1", listOf(QuestionInfoDto("pick", "h"))), + ) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"question.asked"""")) + } + + @Test + fun `TodoUpdated serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.TodoUpdated("ses_1", emptyList()) + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"todo.updated"""")) + } + + @Test + fun `SessionIdle serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.SessionIdle("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.idle"""")) + } + + @Test + fun `SessionCompacted serializes with correct type`() { + val event: ChatEventDto = ChatEventDto.SessionCompacted("ses_1") + val encoded = json.encodeToString(ChatEventDto.serializer(), event) + assertTrue(encoded.contains(""""type":"session.compacted"""")) + } + + @Test + fun `PartDto callID is preserved in round-trip`() { + val part = PartDto( + id = "p1", sessionID = "s1", messageID = "m1", + type = "tool", tool = "bash", callID = "call_abc", + ) + val encoded = json.encodeToString(PartDto.serializer(), part) + assertTrue(encoded.contains(""""callID":"call_abc"""")) + + val decoded = json.decodeFromString(PartDto.serializer(), encoded) + assertEquals("call_abc", decoded.callID) + } + + // ------ helpers ------ + + private fun msg(id: String) = MessageDto( + id = id, sessionID = "ses_1", role = "assistant", + time = MessageTimeDto(created = 0.0), + ) + + private fun part(id: String) = PartDto( + id = id, sessionID = "ses_1", messageID = "msg_1", type = "text", + ) +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 91f525ba1c5..40fb0a47f3c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -3,8 +3,11 @@ package ai.kilocode.backend.cli import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.QuestionReplyDto import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -465,6 +468,380 @@ class KiloCliDataParserTest { assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) } + // ================================================================ + // parseChatEvent — permission / question events + // ================================================================ + + @Test + fun `parseChatEvent - permission asked`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": ["*.kt"], + "always": [], + "metadata": {"file": "src/A.kt"}, + "tool": {"messageID": "msg_1", "callID": "call_1"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.request.id) + assertEquals("edit", result.request.permission) + assertEquals(listOf("*.kt"), result.request.patterns) + assertEquals("src/A.kt", result.request.metadata["file"]) + assertEquals("msg_1", result.request.tool?.messageID) + } + + @Test + fun `parseChatEvent - permission replied`() { + val data = globalEvent(""" + "type": "permission.replied", + "properties": { "sessionID": "ses_1", "requestID": "perm_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("permission.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionReplied) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.requestID) + } + + @Test + fun `parseChatEvent - question asked`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_1", + "sessionID": "ses_1", + "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], + "tool": null + } + """) + + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("q_1", result.request.id) + assertEquals(1, result.request.questions.size) + assertEquals("Pick one", result.request.questions[0].question) + assertEquals("A", result.request.questions[0].options[0].label) + } + + @Test + fun `parseChatEvent - question replied`() { + val data = globalEvent(""" + "type": "question.replied", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionReplied) + assertEquals("q_1", result.requestID) + } + + @Test + fun `parseChatEvent - question rejected`() { + val data = globalEvent(""" + "type": "question.rejected", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.rejected", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionRejected) + assertEquals("q_1", result.requestID) + } + + // ================================================================ + // parseChatEvent — session.status with retry/offline detail + // ================================================================ + + @Test + fun `parseChatEvent - session status idle`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("idle", result.status.type) + assertNull(result.status.attempt) + assertNull(result.status.requestID) + } + + @Test + fun `parseChatEvent - session status retry with attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("retry", result.status.type) + assertEquals("Retrying...", result.status.message) + assertEquals(2, result.status.attempt) + assertEquals(5000L, result.status.next) + } + + @Test + fun `parseChatEvent - session status offline with requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("offline", result.status.type) + assertEquals("No network", result.status.message) + assertEquals("req_abc", result.status.requestID) + } + + // ================================================================ + // parseChatEvent — message.part.removed + // ================================================================ + + @Test + fun `parseChatEvent - message part removed`() { + val data = globalEvent(""" + "type": "message.part.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + } + + // ================================================================ + // parseChatEvent — todo.updated + // ================================================================ + + @Test + fun `parseChatEvent - todo updated`() { + val data = globalEvent(""" + "type": "todo.updated", + "properties": { + "sessionID": "ses_1", + "todos": [ + {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Review PR", "status": "pending", "priority": "medium"} + ] + } + """) + + val result = KiloCliDataParser.parseChatEvent("todo.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TodoUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals(2, result.todos.size) + assertEquals("Write tests", result.todos[0].content) + assertEquals("high", result.todos[0].priority) + } + + // ================================================================ + // parseChatEvent — session.idle / session.compacted / session.diff + // ================================================================ + + @Test + fun `parseChatEvent - session idle`() { + val data = globalEvent(""" + "type": "session.idle", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.idle", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionIdle) + assertEquals("ses_1", result.sessionID) + } + + @Test + fun `parseChatEvent - session compacted`() { + val data = globalEvent(""" + "type": "session.compacted", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.compacted", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCompacted) + } + + @Test + fun `parseChatEvent - session diff`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.diff", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionDiffChanged) + assertEquals(1, result.diff.size) + assertEquals("src/A.kt", result.diff[0].file) + assertEquals(3, result.diff[0].additions) + } + + // ================================================================ + // parseChatEvent — part with callID + // ================================================================ + + @Test + fun `parseChatEvent - part updated with callID`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_abc", + "state": { "status": "running" } + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("call_abc", result.part.callID) + assertEquals("bash", result.part.tool) + } + + // ================================================================ + // parseSessionStatus — full detail + // ================================================================ + + @Test + fun `parseSessionStatus - retry preserves attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_retry", + "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_retry", result.first) + assertEquals("retry", result.second.type) + assertEquals(3, result.second.attempt) + assertEquals(10000L, result.second.next) + } + + @Test + fun `parseSessionStatus - offline preserves requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_off", + "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("req_xyz", result.second.requestID) + } + + // ================================================================ + // buildPermissionReplyJson + // ================================================================ + + @Test + fun `buildPermissionReplyJson - once reply`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) + assertEquals("""{"reply":"once"}""", result) + } + + @Test + fun `buildPermissionReplyJson - always reply with message`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) + assertTrue(result.contains(""""reply":"always"""")) + assertTrue(result.contains(""""message":"approved"""")) + } + + // ================================================================ + // buildPermissionAlwaysRulesJson + // ================================================================ + + @Test + fun `buildPermissionAlwaysRulesJson - approved list`() { + val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( + PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) + ) + assertTrue(result.contains(""""approvedAlways":["src/**"]""")) + assertTrue(result.contains(""""deniedAlways":[]""")) + } + + // ================================================================ + // buildQuestionReplyJson + // ================================================================ + + @Test + fun `buildQuestionReplyJson - single question single answer`() { + val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) + assertEquals("""{"answers":[["A"]]}""", result) + } + + @Test + fun `buildQuestionReplyJson - multiple questions`() { + val result = KiloCliDataParser.buildQuestionReplyJson( + QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) + ) + assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) + } + + // ================================================================ + // parsePermissionRequests / parseQuestionRequests + // ================================================================ + + @Test + fun `parsePermissionRequests - parses list`() { + val raw = """[ + {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} + ]""" + val result = KiloCliDataParser.parsePermissionRequests(raw) + assertEquals(1, result.size) + assertEquals("p1", result[0].id) + assertEquals("edit", result[0].permission) + } + + @Test + fun `parsePermissionRequests - empty list`() { + assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) + } + + @Test + fun `parseQuestionRequests - parses list`() { + val raw = """[ + {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + ]""" + val result = KiloCliDataParser.parseQuestionRequests(raw) + assertEquals(1, result.size) + assertEquals("q1", result[0].id) + } + // ================================================================ // Helpers // ================================================================ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 6a0bfcebdef..50e80b05afe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -6,8 +6,13 @@ 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.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.Service @@ -143,4 +148,34 @@ class KiloSessionService internal constructor( suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { call { updateConfig(dir, config) } } + + // ------ permission / question resolution ------ + + /** Reply to a pending permission request. */ + suspend fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) { + call { replyPermission(requestId, dir, reply) } + } + + /** Save always-rules for a pending permission request. */ + suspend fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) { + call { savePermissionRules(requestId, dir, rules) } + } + + /** Reply to a pending question with user answers. */ + suspend fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) { + call { replyQuestion(requestId, dir, answers) } + } + + /** Reject a pending question. */ + suspend fun rejectQuestion(requestId: String, dir: String) { + call { rejectQuestion(requestId, dir) } + } + + /** List pending permissions (caller filters by session ID). */ + suspend fun pendingPermissions(dir: String): List = + call { pendingPermissions(dir) } + + /** List pending questions (caller filters by session ID). */ + suspend fun pendingQuestions(dir: String): List = + call { pendingQuestions(dir) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index 453873c38ba..bf8ab071141 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -20,7 +20,10 @@ import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -133,6 +136,39 @@ class SessionController( fire(SessionControllerEvent.WorkspaceReady) } + // ------ permission / question resolution ------ + + fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + cs.launch { + try { + if (rules != null) sessions.savePermissionRules(requestId, directory, rules) + sessions.replyPermission(requestId, directory, reply) + } catch (e: Exception) { + LOG.warn("replyPermission failed", e) + } + } + } + + fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + cs.launch { + try { + sessions.replyQuestion(requestId, directory, answers) + } catch (e: Exception) { + LOG.warn("replyQuestion failed", e) + } + } + } + + fun rejectQuestion(requestId: String) { + cs.launch { + try { + sessions.rejectQuestion(requestId, directory) + } catch (e: Exception) { + LOG.warn("rejectQuestion failed", e) + } + } + } + init { if (sessionId != null) { loadHistory() @@ -193,6 +229,7 @@ class SessionController( this@SessionController.model.loadHistory(history) if (!model.isEmpty()) showMessages() } + recoverPending(id) } catch (e: Exception) { LOG.warn("loadHistory failed", e) } @@ -204,16 +241,35 @@ class SessionController( eventJob?.cancel() eventJob = cs.launch { sessions.events(id, directory).collect { event -> + // Defensive session ID guard: skip events for other sessions + if (!matchesSession(event, id)) return@collect edt { handle(event) } } } } + /** Rehydrate pending permissions/questions after history load or reconnect. */ + private suspend fun recoverPending(id: String) { + try { + val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } + val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } + edt { + if (permissions.isNotEmpty()) { + model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) + } else if (questions.isNotEmpty()) { + model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) + } + } + } catch (e: Exception) { + LOG.warn("recoverPending failed", e) + } + } + private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.MessageUpdated -> { - model.addMessage(event.info) ?: return - showMessages() + val added = model.upsertMessage(event.info) + if (added) showMessages() } is ChatEventDto.PartUpdated -> { @@ -231,6 +287,10 @@ class SessionController( } } + is ChatEventDto.PartRemoved -> { + model.removeContent(event.messageID, event.partID) + } + is ChatEventDto.TurnOpen -> { partType = null tool = null @@ -240,7 +300,15 @@ class SessionController( is ChatEventDto.TurnClose -> { partType = null tool = null - model.setState(SessionState.Idle) + // "completed" always transitions to idle. + // Other reasons: don't clobber a more specific terminal state (Error, + // AwaitingPermission, AwaitingQuestion) that arrived just before close. + val current = model.state + val clobberOk = event.reason == "completed" + || current is SessionState.Busy + || current is SessionState.Retry + || current is SessionState.Offline + if (clobberOk) model.setState(SessionState.Idle) } is ChatEventDto.Error -> { @@ -259,7 +327,8 @@ class SessionController( } is ChatEventDto.PermissionReplied -> { - if (model.state is SessionState.AwaitingPermission) { + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } @@ -269,13 +338,15 @@ class SessionController( } is ChatEventDto.QuestionReplied -> { - if (model.state is SessionState.AwaitingQuestion) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } } is ChatEventDto.QuestionRejected -> { - if (model.state is SessionState.AwaitingQuestion) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { model.setState(SessionState.Idle) } } @@ -289,12 +360,36 @@ class SessionController( SessionState.Busy(KiloBundle.message("session.status.considering")) else return // already in a more specific phase } - "retry" -> SessionState.Retry(event.status.message ?: "", 0, 0) - "offline" -> SessionState.Offline(event.status.message ?: "", "") + "retry" -> SessionState.Retry( + message = event.status.message ?: "", + attempt = event.status.attempt ?: 0, + next = event.status.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = event.status.message ?: "", + requestId = event.status.requestID ?: "", + ) else -> return } model.setState(state) } + + is ChatEventDto.SessionIdle -> { + // Treat session.idle as an explicit signal to return to Idle. + // Only apply if we're not in a more specific non-terminal state. + val current = model.state + if (current !is SessionState.Error + && current !is SessionState.AwaitingPermission + && current !is SessionState.AwaitingQuestion + ) { + model.setState(SessionState.Idle) + } + } + + // These events don't change visible session state — ignore for now. + is ChatEventDto.SessionCompacted -> Unit + is ChatEventDto.SessionDiffChanged -> Unit + is ChatEventDto.TodoUpdated -> Unit } } @@ -389,6 +484,28 @@ class SessionController( } } +/** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */ +private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) { + is ChatEventDto.MessageUpdated -> event.sessionID == id + is ChatEventDto.PartUpdated -> event.sessionID == id + is ChatEventDto.PartDelta -> event.sessionID == id + is ChatEventDto.PartRemoved -> event.sessionID == id + is ChatEventDto.TurnOpen -> event.sessionID == id + is ChatEventDto.TurnClose -> event.sessionID == id + is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id + is ChatEventDto.MessageRemoved -> event.sessionID == id + is ChatEventDto.PermissionAsked -> event.sessionID == id + is ChatEventDto.PermissionReplied -> event.sessionID == id + is ChatEventDto.QuestionAsked -> event.sessionID == id + is ChatEventDto.QuestionReplied -> event.sessionID == id + is ChatEventDto.QuestionRejected -> event.sessionID == id + is ChatEventDto.SessionStatusChanged -> event.sessionID == id + is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionCompacted -> event.sessionID == id + is ChatEventDto.SessionDiffChanged -> event.sessionID == id + is ChatEventDto.TodoUpdated -> event.sessionID == id +} + private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } val file = dto.metadata["file"] ?: dto.metadata["path"] diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index e4a9f08f2a6..02daa0ecfc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -31,6 +31,12 @@ class Tool(id: String, val name: String) : Content(id) { /** Context compaction marker. */ class Compaction(id: String) : Content(id) +/** + * Generic fallback for part types not yet given a dedicated class. + * Preserves the [type] string so unknown content is not silently dropped. + */ +class Generic(id: String, val type: String) : Content(id) + enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR } data class ToolCallRef( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 88e5305734c..d3904a6a314 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -50,6 +50,25 @@ class SessionModel { fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY + /** + * Add a message if it doesn't exist, or update its [MessageDto] info if it does. + * Returns true when the message was newly added (caller can decide to show messages). + */ + fun upsertMessage(dto: MessageDto): Boolean { + val existing = entries[dto.id] + if (existing != null) { + val updated = Message(dto).also { it.parts.putAll(existing.parts) } + entries[dto.id] = updated + fire(SessionModelEvent.MessageUpdated(updated)) + return false + } + val msg = Message(dto) + entries[dto.id] = msg + fire(SessionModelEvent.MessageAdded(msg)) + return true + } + + /** @deprecated Use [upsertMessage] instead. Kept for incremental migration. */ fun addMessage(dto: MessageDto): Message? { if (entries.containsKey(dto.id)) return null val msg = Message(dto) @@ -63,6 +82,12 @@ class SessionModel { fire(SessionModelEvent.MessageRemoved(id)) } + fun removeContent(messageId: String, contentId: String) { + val msg = entries[messageId] ?: return + if (msg.parts.remove(contentId) == null) return + fire(SessionModelEvent.ContentRemoved(messageId, contentId)) + } + fun updateContent(messageId: String, dto: PartDto) { val msg = entries[messageId] ?: return val existing = msg.parts[dto.id] @@ -70,7 +95,7 @@ class SessionModel { updateExisting(messageId, existing, dto) return } - val content = fromDto(dto) ?: return + val content = fromDto(dto) msg.parts[dto.id] = content fire(SessionModelEvent.ContentAdded(messageId, content)) } @@ -106,7 +131,7 @@ class SessionModel { val item = Message(msg.info) for (part in msg.parts) { val content = fromDto(part, part.text) - if (content != null) item.parts[content.id] = content + item.parts[content.id] = content } entries[msg.info.id] = item } @@ -136,11 +161,12 @@ class SessionModel { existing.title = dto.title } is Compaction -> return + is Generic -> return } fire(SessionModelEvent.ContentUpdated(messageId, existing)) } - private fun fromDto(dto: PartDto, text: CharSequence? = null): Content? { + private fun fromDto(dto: PartDto, text: CharSequence? = null): Content { val content = text ?: dto.text return when (dto.type) { "text" -> Text(dto.id).apply { @@ -154,7 +180,7 @@ class SessionModel { title = dto.title } "compaction" -> Compaction(dto.id) - else -> null + else -> Generic(dto.id, dto.type) } } @@ -213,6 +239,7 @@ private fun renderMessage(msg: Message): List { } is Tool -> out.add(renderTool(part)) is Compaction -> out.add("compaction#${part.id}") + is Generic -> out.add("${part.type}#${part.id}") } } return out diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index c135cc176ae..dcdc8ddb4e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -10,6 +10,9 @@ sealed class SessionModelEvent { data class MessageAdded(val info: Message) : SessionModelEvent() { override fun toString() = "MessageAdded ${info.info.id}" } + data class MessageUpdated(val info: Message) : SessionModelEvent() { + override fun toString() = "MessageUpdated ${info.info.id}" + } data class MessageRemoved(val id: String) : SessionModelEvent() { override fun toString() = "MessageRemoved $id" } @@ -19,6 +22,9 @@ sealed class SessionModelEvent { data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() { override fun toString() = "ContentUpdated $messageId/${content.id}" } + data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() { + override fun toString() = "ContentRemoved $messageId/$contentId" + } data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() { override fun toString() = "ContentDelta $messageId/$contentId" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index 121833f6e7d..57044b0aa2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.Text @@ -65,9 +66,11 @@ class MessageListUi( model.addListener(parent) { event -> when (event) { is SessionModelEvent.MessageAdded -> onAdded(event.info) + is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert is SessionModelEvent.MessageRemoved -> onRemoved(event.id) is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) + is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId) is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) is SessionModelEvent.StateChanged -> onState(event.state) is SessionModelEvent.HistoryLoaded -> onHistory() @@ -100,6 +103,11 @@ class MessageListUi( refresh() } + private fun onContentRemoved(messageId: String, contentId: String) { + blocks[messageId]?.removeContent(contentId) + refresh() + } + private fun onContentDelta(messageId: String, contentId: String, delta: String) { blocks[messageId]?.appendDelta(contentId, delta) refresh() @@ -207,16 +215,24 @@ private class MessageBlock(info: Message) : JPanel() { labels[content.id] = lbl add(lbl) } + is Generic -> {} // unknown part type — not rendered in this pass } revalidate() } + fun removeContent(contentId: String) { + areas.remove(contentId)?.let { remove(it) } + labels.remove(contentId)?.let { remove(it) } + revalidate() + } + fun updateContent(content: Content) { when (content) { is Text -> areas[content.id]?.text = content.content.toString() is Reasoning -> areas[content.id]?.text = content.content.toString() is Tool -> labels[content.id]?.text = toolText(content) is Compaction -> {} + is Generic -> {} } revalidate() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index ac8907c3946..174c05879a2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer @@ -57,6 +58,18 @@ class ListenerLifecycleTest : SessionControllerTestBase() { """, events2) } + fun `test session status idle fires StateChanged to Idle`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertModelEvents("StateChanged Idle", modelEvents) + assertEquals(SessionState.Idle, m.model.state) + } + fun `test session status busy fires StateChanged to Busy`() { val (_, _, modelEvents) = prompted() @@ -64,4 +77,53 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertModelEvents("StateChanged Busy", modelEvents) } + + fun `test session status busy ignored when already busy`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy"))) + + // Already in Busy — status busy is ignored + assertTrue(modelEvents.isEmpty()) + } + + fun `test session status retry with zero attempt`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("retry", "Waiting...", attempt = 0, next = 1000L), + )) + + val state = m.model.state as SessionState.Retry + assertEquals("Waiting...", state.message) + assertEquals(0, state.attempt) + assertEquals(1000L, state.next) + } + + fun `test session status offline`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("offline", "Disconnected", requestID = "req_1"), + )) + + val state = m.model.state as SessionState.Offline + assertEquals("Disconnected", state.message) + assertEquals("req_1", state.requestId) + } + + fun `test session status unknown type is ignored`() { + val (m, _, modelEvents) = prompted() + modelEvents.clear() + + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("weird_future_status"))) + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index f106384c8a5..3290a336b1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageTimeDto class MessageListTest : SessionControllerTestBase() { @@ -14,6 +15,32 @@ class MessageListTest : SessionControllerTestBase() { assertNotNull(m.model.message("msg1")) } + fun `test MessageUpdated second event updates existing message`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + // Second update for the same message ID (e.g. tokens/cost finalized) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + // Should fire MessageUpdated, not MessageAdded + assertModelEvents("MessageUpdated msg1", modelEvents) + assertNotNull(m.model.message("msg1")) + } + + fun `test MessageUpdated second event preserves existing parts`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + + // Update the message info (should not wipe parts) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + + assertNotNull(m.model.message("msg1")!!.parts["prt1"]) + } + fun `test PartUpdated text updates SessionModel`() { val (m, _, modelEvents) = prompted() @@ -53,6 +80,30 @@ class MessageListTest : SessionControllerTestBase() { ) } + fun `test PartRemoved removes content from message`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + modelEvents.clear() + + emit(ChatEventDto.PartRemoved("ses_test", "msg1", "prt1")) + + assertNull(m.model.message("msg1")!!.parts["prt1"]) + assertModelEvents("ContentRemoved msg1/prt1", modelEvents) + } + + fun `test PartRemoved unknown part is noop`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartRemoved("ses_test", "msg1", "no_such_part")) + + assertTrue(modelEvents.isEmpty()) + } + fun `test MessageRemoved removes from SessionModel`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt index 871b879b56f..38cb5abf77b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt @@ -1,9 +1,13 @@ package ai.kilocode.client.session +import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.ToolRefDto @@ -94,6 +98,83 @@ class PromptLifecycleTest : SessionControllerTestBase() { ) } + fun `test PermissionReplied with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + emit(ChatEventDto.PermissionReplied("ses_test", "wrong_id")) + + // State must remain AwaitingPermission + assertTrue(m.model.state is SessionState.AwaitingPermission) + } + + fun `test QuestionReplied with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionReplied("ses_test", "wrong_id")) + + // State must remain AwaitingQuestion + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + + fun `test QuestionRejected with wrong requestID is ignored`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + emit(ChatEventDto.QuestionRejected("ses_test", "wrong_id")) + + // State must remain AwaitingQuestion + assertTrue(m.model.state is SessionState.AwaitingQuestion) + } + + fun `test replyPermission calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + edt { m.replyPermission("perm1", PermissionReplyDto("once")) } + flush() + + assertEquals(1, rpc.permissionReplies.size) + assertEquals("perm1", rpc.permissionReplies[0].first) + assertEquals("once", rpc.permissionReplies[0].third.reply) + } + + fun `test replyPermission with rules saves always rules first`() { + val (m, _, _) = prompted() + emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1"))) + + val rules = PermissionAlwaysRulesDto(approvedAlways = listOf("src/**")) + edt { m.replyPermission("perm1", PermissionReplyDto("always"), rules) } + flush() + + assertEquals(1, rpc.permissionRulesSaved.size) + assertEquals("perm1", rpc.permissionRulesSaved[0].first) + assertEquals(1, rpc.permissionReplies.size) + } + + fun `test replyQuestion calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + + edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) } + flush() + + assertEquals(1, rpc.questionReplies.size) + assertEquals("q1", rpc.questionReplies[0].first) + } + + fun `test rejectQuestion calls RPC`() { + val (m, _, _) = prompted() + emit(ChatEventDto.QuestionAsked("ses_test", question("q1"))) + + edt { m.rejectQuestion("q1") } + flush() + + assertEquals(1, rpc.questionRejects.size) + assertEquals("q1", rpc.questionRejects[0].first) + } + private fun permission(id: String) = PermissionRequestDto( id = id, sessionID = "ses_test", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt new file mode 100644 index 00000000000..6657077a0b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -0,0 +1,107 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionTimeDto + +/** + * Tests for pending permission/question recovery after history load. + * + * VS Code rehydrates pending prompts by calling list endpoints after + * reconnect. JetBrains now does the same in [SessionController.recoverPending]. + */ +class SessionRecoveryTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + // Set a pre-existing session in the fake API + rpc.session = rpc.session.copy(id = "ses_test") + } + + fun `test pending permission is recovered on history load`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_pending", + sessionID = "ses_test", + permission = "read", + patterns = listOf("*.json"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingPermission) + val perm = (m.model.state as SessionState.AwaitingPermission).permission + assertEquals("perm_pending", perm.id) + assertEquals("read", perm.name) + } + + fun `test pending question is recovered when no pending permissions`() { + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_pending", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("What?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + val q = (m.model.state as SessionState.AwaitingQuestion).question + assertEquals("q_pending", q.id) + } + + fun `test pending from other session is ignored`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_other", + sessionID = "ses_other", // different session + permission = "read", + patterns = emptyList(), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // State should remain Idle — other session's pending is irrelevant + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test permission takes priority over question in recovery`() { + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_pending", + sessionID = "ses_test", + permission = "edit", + patterns = emptyList(), + ) + ) + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_pending", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("What?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + // Permission list non-empty → AwaitingPermission wins + assertTrue(m.model.state is SessionState.AwaitingPermission) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt index 07d0371d3fa..317271b1a60 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt @@ -1,7 +1,9 @@ package ai.kilocode.client.session +import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.SessionStatusDto class TurnLifecycleTest : SessionControllerTestBase() { @@ -32,6 +34,37 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test TurnClose error reason does not clobber Error state`() { + val (m, _, _) = prompted() + + // Error event arrives just before TurnClose + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.TurnClose("ses_test", "error")) + + // Error state must survive + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + fun `test TurnClose completed clobbers Error state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + // "completed" always wins over error + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + fun `test Error fires StateChanged to Error`() { val (m, _, _) = prompted() @@ -57,4 +90,75 @@ class TurnLifecycleTest : SessionControllerTestBase() { m, ) } + + fun `test SessionStatusChanged retry with full detail`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("retry", "Rate limited", attempt = 2, next = 5000L), + )) + + val state = m.model.state as SessionState.Retry + assertEquals("Rate limited", state.message) + assertEquals(2, state.attempt) + assertEquals(5000L, state.next) + } + + fun `test SessionStatusChanged offline with requestID`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionStatusChanged( + "ses_test", + SessionStatusDto("offline", "No network", requestID = "req_abc"), + )) + + val state = m.model.state as SessionState.Offline + assertEquals("No network", state.message) + assertEquals("req_abc", state.requestId) + } + + fun `test SessionIdle transitions to Idle from Busy`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionIdle("ses_test")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionIdle does not clobber Error state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.SessionIdle("ses_test")) + + // Error state must survive + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + fun `test events for wrong session are ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_other")) + + // No state change — event was filtered out + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + assertModelEvents("", modelEvents) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index a3ee5aa6dac..edcf83c1a66 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -150,14 +150,16 @@ class SessionModelTest : UsefulTestCase() { assertTrue(model.message("m1")!!.parts["p1"] is Compaction) } - fun `test updateContent unknown type ignored`() { + fun `test updateContent unknown type stored as Generic`() { model.addMessage(msg("m1", "assistant")) events.clear() model.updateContent("m1", part("p1", "m1", "step-start")) - assertNull(model.message("m1")!!.parts["p1"]) - assertTrue(events.isEmpty()) + val p = model.message("m1")!!.parts["p1"] + assertTrue("Expected Generic fallback but got: ${p?.javaClass?.simpleName}", p is Generic) + assertEquals("step-start", (p as Generic).type) + assertTrue(events.single() is SessionModelEvent.ContentAdded) } fun `test appendDelta appends to existing text content`() { @@ -334,7 +336,7 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.single() is SessionModelEvent.HistoryLoaded) } - fun `test loadHistory skips unknown content types`() { + fun `test loadHistory stores unknown content types as Generic`() { val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") val snapshot = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "snapshot") @@ -342,7 +344,67 @@ class SessionModelTest : UsefulTestCase() { val entry = model.message("m1")!! assertTrue(entry.parts.containsKey("p1")) - assertFalse(entry.parts.containsKey("p2")) + assertTrue(entry.parts.containsKey("p2")) + assertTrue(entry.parts["p2"] is Generic) + assertEquals("snapshot", (entry.parts["p2"] as Generic).type) + } + + fun `test upsertMessage adds new message and returns true`() { + val added = model.upsertMessage(msg("m1", "user")) + + assertTrue(added) + assertNotNull(model.message("m1")) + val event = events.single() as SessionModelEvent.MessageAdded + assertEquals("m1", event.info.info.id) + } + + fun `test upsertMessage updates existing message and returns false`() { + model.upsertMessage(msg("m1", "user")) + events.clear() + + val updated = msg("m1", "assistant") // same id, different role (simulating metadata update) + val added = model.upsertMessage(updated) + + assertFalse(added) + assertEquals("assistant", model.message("m1")!!.info.role) + val event = events.single() as SessionModelEvent.MessageUpdated + assertEquals("m1", event.info.info.id) + } + + fun `test upsertMessage update preserves existing parts`() { + model.upsertMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + events.clear() + + model.upsertMessage(msg("m1", "assistant")) + + assertNotNull(model.message("m1")!!.parts["p1"]) + } + + fun `test removeContent fires ContentRemoved`() { + model.addMessage(msg("m1", "assistant")) + model.updateContent("m1", part("p1", "m1", "text", text = "hello")) + events.clear() + + model.removeContent("m1", "p1") + + assertNull(model.message("m1")!!.parts["p1"]) + val event = events.single() as SessionModelEvent.ContentRemoved + assertEquals("m1", event.messageId) + assertEquals("p1", event.contentId) + } + + fun `test removeContent unknown messageId is noop`() { + events.clear() + model.removeContent("missing", "p1") + assertTrue(events.isEmpty()) + } + + fun `test removeContent unknown contentId is noop`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + model.removeContent("m1", "missing_part") + assertTrue(events.isEmpty()) } fun `test clear resets messages and state`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 85b8c255488..573db3e2c12 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,7 +4,12 @@ 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.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -43,11 +48,21 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Push status updates here. */ val statuses = MutableStateFlow>(emptyMap()) + /** Pending permissions returned by [pendingPermissions]. */ + val pendingPermissionList = mutableListOf() + + /** Pending questions returned by [pendingQuestions]. */ + val pendingQuestionList = mutableListOf() + // --- Call tracking --- val prompts = mutableListOf>() val aborts = mutableListOf>() val configs = mutableListOf>() + val permissionReplies = mutableListOf>() + val permissionRulesSaved = mutableListOf>() + val questionReplies = mutableListOf>() + val questionRejects = mutableListOf>() var creates = 0 private set @@ -111,4 +126,34 @@ class FakeSessionRpcApi : KiloSessionRpcApi { assertNotEdt("updateConfig") configs.add(directory to config) } + + override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { + assertNotEdt("replyPermission") + permissionReplies.add(Triple(requestId, directory, reply)) + } + + override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) { + assertNotEdt("savePermissionRules") + permissionRulesSaved.add(Triple(requestId, directory, rules)) + } + + override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) { + assertNotEdt("replyQuestion") + questionReplies.add(Triple(requestId, directory, answers)) + } + + override suspend fun rejectQuestion(requestId: String, directory: String) { + assertNotEdt("rejectQuestion") + questionRejects.add(requestId to directory) + } + + override suspend fun pendingPermissions(directory: String): List { + assertNotEdt("pendingPermissions") + return pendingPermissionList.toList() + } + + override suspend fun pendingQuestions(directory: String): List { + assertNotEdt("pendingQuestions") + return pendingQuestionList.toList() + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index a482176d674..bffa2bf5be7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -3,7 +3,12 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto +import ai.kilocode.rpc.dto.PermissionReplyDto +import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionReplyDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -66,4 +71,24 @@ interface KiloSessionRpcApi : RemoteApi { /** Update config (model, agent/mode, temperature). */ suspend fun updateConfig(directory: String, config: ConfigUpdateDto) + + // ------ permission / question resolution ------ + + /** Reply to a pending permission request (once, always, or reject). */ + suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) + + /** Save always-rules for a pending permission request before replying. */ + suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) + + /** Reply to a pending question with user answers. */ + suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) + + /** Reject a pending question. */ + suspend fun rejectQuestion(requestId: String, directory: String) + + /** List all pending permission requests (caller filters by session). */ + suspend fun pendingPermissions(directory: String): List + + /** List all pending question requests (caller filters by session). */ + suspend fun pendingQuestions(directory: String): List } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index c04d6fa9e2d..3fe3d313faf 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -47,7 +47,7 @@ data class MessageWithPartsDto( val parts: List, ) -// --- Parts (simplified for basic chat) --- +// --- Parts --- @Serializable data class PartDto( @@ -57,6 +57,7 @@ data class PartDto( val type: String, val text: String? = null, val tool: String? = null, + val callID: String? = null, val state: String? = null, val title: String? = null, ) @@ -90,14 +91,14 @@ sealed class ChatEventDto { ) : ChatEventDto() @Serializable - @SerialName("part.updated") + @SerialName("message.part.updated") data class PartUpdated( val sessionID: String, val part: PartDto, ) : ChatEventDto() @Serializable - @SerialName("part.delta") + @SerialName("message.part.delta") data class PartDelta( val sessionID: String, val messageID: String, @@ -107,20 +108,28 @@ sealed class ChatEventDto { ) : ChatEventDto() @Serializable - @SerialName("turn.open") + @SerialName("message.part.removed") + data class PartRemoved( + val sessionID: String, + val messageID: String, + val partID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.turn.open") data class TurnOpen( val sessionID: String, ) : ChatEventDto() @Serializable - @SerialName("turn.close") + @SerialName("session.turn.close") data class TurnClose( val sessionID: String, val reason: String, ) : ChatEventDto() @Serializable - @SerialName("error") + @SerialName("session.error") data class Error( val sessionID: String?, val error: MessageErrorDto? = null, @@ -174,6 +183,32 @@ sealed class ChatEventDto { val sessionID: String, val status: SessionStatusDto, ) : ChatEventDto() + + @Serializable + @SerialName("session.idle") + data class SessionIdle( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.compacted") + data class SessionCompacted( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("session.diff") + data class SessionDiffChanged( + val sessionID: String, + val diff: List = emptyList(), + ) : ChatEventDto() + + @Serializable + @SerialName("todo.updated") + data class TodoUpdated( + val sessionID: String, + val todos: List = emptyList(), + ) : ChatEventDto() } // --- Permission DTOs --- @@ -195,6 +230,18 @@ data class ToolRefDto( val callID: String, ) +@Serializable +data class PermissionReplyDto( + val reply: String, + val message: String? = null, +) + +@Serializable +data class PermissionAlwaysRulesDto( + val approvedAlways: List = emptyList(), + val deniedAlways: List = emptyList(), +) + // --- Question DTOs --- @Serializable @@ -220,6 +267,30 @@ data class QuestionOptionDto( val description: String, ) +@Serializable +data class QuestionReplyDto( + val answers: List>, +) + +// --- Todo DTO --- + +@Serializable +data class TodoDto( + val content: String, + val status: String, + val priority: String, +) + +// --- Diff DTO --- + +@Serializable +data class DiffFileDto( + val file: String, + val additions: Int, + val deletions: Int, + val patch: String? = null, +) + // --- Config Update --- @Serializable diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt index 47958e66bce..4c7c2c25307 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -32,6 +32,9 @@ data class SessionSummaryDto( data class SessionStatusDto( val type: String, val message: String? = null, + val attempt: Int? = null, + val next: Long? = null, + val requestID: String? = null, ) @Serializable From da7b600add225a4472cfba98e72887a4d83c58e1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 19 Apr 2026 14:23:12 -0400 Subject: [PATCH 19/27] refactor(jetbrains): use JBHtmlPane with editor-aware styling in MdView - Replace plain JEditorPane + manual CSS with JBHtmlPane, the IntelliJ platform's flagship HTML component - Font and colours now default to the global editor colour scheme via JBHtmlPane's built-in EditorCssFontResolver and colorSchemeProvider - Code font defaults to _EditorFontNoLigatures_ placeholder resolved by EditorCssFontResolver at render time - All style properties become optional overrides applied via a customStyleSheetProvider; the override sheet is empty until a property is set, so editor defaults always win unless explicitly overridden - Add resetStyles() to revert all overrides back to editor defaults - Transparency (opaque=false) handled by Swing isOpaque + transparent CSS body rule; no manual background injection when transparent - Update tests to use overrideSheet() and component state assertions --- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 259 ++++++++++------- .../ai/kilocode/client/ui/md/MdViewTest.kt | 261 +++++++++++------- 2 files changed, 311 insertions(+), 209 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 5b3d6b20f35..146075250e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.ui.md +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import org.commonmark.ext.autolink.AutolinkExtension @@ -11,22 +14,27 @@ import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent -import javax.swing.JEditorPane import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.HTMLEditorKit +import javax.swing.text.html.StyleSheet /** - * Markdown rendering component that hides the concrete rendering strategy - * behind a uniform API. Create instances via [MdView.html]. + * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. * - * All public methods must be called on the EDT. + * By default, font and colors are derived from the global editor colour scheme. + * All style properties are optional overrides on top of those defaults. + * Call [resetStyles] to revert to editor defaults after overriding. + * + * Create instances via [MdView.html]. All public methods must be called on the EDT. */ +@Suppress("UnstableApiUsage") abstract class MdView private constructor() { abstract val component: JComponent abstract fun set(text: String) abstract fun append(delta: String) abstract fun clear() + /** Revert all style overrides to editor-derived defaults. */ + abstract fun resetStyles() abstract fun addLinkListener(listener: LinkListener) abstract fun removeLinkListener(listener: LinkListener) @@ -43,8 +51,8 @@ abstract class MdView private constructor() { abstract var tableBorder: Color /** - * When `false`, body background CSS is omitted and the Swing component - * is set to non-opaque so the parent's background shows through. + * When `false`, the component is transparent — the parent's background shows through + * and no background is forced in the CSS body rule. */ abstract var opaque: Boolean @@ -59,20 +67,20 @@ abstract class MdView private constructor() { internal abstract fun markdown(): String internal abstract fun html(): String - internal abstract fun styledHtml(): String + /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ + internal abstract fun overrideSheet(): String internal abstract fun simulateLink(href: String) companion object { fun html(): MdView = HtmlImpl() } + @Suppress("UnstableApiUsage") private class HtmlImpl : MdView() { private val listeners = mutableListOf() private val source = StringBuilder() private var rendered = "" - private var wrapped = "" - private var dirty = false private val extensions = listOf( AutolinkExtension.create(), @@ -80,9 +88,7 @@ abstract class MdView private constructor() { StrikethroughExtension.create(), ) - private val parser: Parser = Parser.builder() - .extensions(extensions) - .build() + private val parser: Parser = Parser.builder().extensions(extensions).build() private val renderer: HtmlRenderer = HtmlRenderer.builder() .extensions(extensions) @@ -90,72 +96,126 @@ abstract class MdView private constructor() { .sanitizeUrls(true) .build() - private val pane: JEditorPane = JEditorPane().apply { + // nullable overrides — null means "use JBHtmlPane / editor default" + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val pane: JBHtmlPane = JBHtmlPane( + JBHtmlPaneStyleConfiguration { + // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService + customStyleSheetProvider { buildOverrideStyleSheet() } + } + ).apply { isEditable = false - contentType = "text/html" - editorKit = HTMLEditorKit() - putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) - border = JBUI.Borders.empty() + isOpaque = true + background = UIUtil.getPanelBackground() addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { val href = e.description ?: return@addHyperlinkListener val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point val event = LinkEvent(href, pt) - for (l in listeners) { - l.onLink(event) - } + for (l in listeners) l.onLink(event) } } } - override var font: Font = JBUI.Fonts.label() - set(value) { field = value; markDirty() } + override val component: JComponent get() = pane - override var foreground: Color = UIUtil.getLabelForeground() - set(value) { field = value; pane.foreground = value; markDirty() } + // -- style properties (non-null API backed by nullable overrides) ---- - override var background: Color = UIUtil.getPanelBackground() - set(value) { field = value; pane.background = value; markDirty() } + override var font: Font + get() = fontOverride ?: JBUI.Fonts.label() + set(value) { fontOverride = value; markDirty() } - override var linkColor: Color = Color(0x58, 0x9D, 0xF6) - set(value) { field = value; markDirty() } + override var foreground: Color + get() = foregroundOverride ?: UIUtil.getLabelForeground() + set(value) { foregroundOverride = value; markDirty() } - override var codeBg: Color = Color(0x3C, 0x3F, 0x41) - set(value) { field = value; markDirty() } - - override var preBg: Color = Color(0x2B, 0x2B, 0x2B) - set(value) { field = value; markDirty() } - - override var preFg: Color = Color(0xA9, 0xB7, 0xC6) - set(value) { field = value; markDirty() } - - override var codeFont: String = "JetBrains Mono" - set(value) { field = value; markDirty() } - - override var quoteBorder: Color = Color(0x55, 0x55, 0x55) - set(value) { field = value; markDirty() } - - override var quoteFg: Color = Color(0x99, 0x99, 0x99) - set(value) { field = value; markDirty() } - - override var tableBorder: Color = Color(0x55, 0x55, 0x55) - set(value) { field = value; markDirty() } - - override var opaque: Boolean = true + override var background: Color + get() = backgroundOverride ?: pane.background set(value) { - field = value - pane.isOpaque = value + backgroundOverride = value + if (opaqueState) pane.background = value markDirty() } - init { - pane.font = font - pane.foreground = foreground - pane.background = background + override var linkColor: Color + get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) + set(value) { linkColorOverride = value; markDirty() } + + override var codeBg: Color + get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) + set(value) { codeBgOverride = value; markDirty() } + + override var preBg: Color + get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) + set(value) { preBgOverride = value; markDirty() } + + override var preFg: Color + get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) + set(value) { preFgOverride = value; markDirty() } + + override var codeFont: String + // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font + get() = codeFontOverride ?: "_EditorFontNoLigatures_" + set(value) { codeFontOverride = value; markDirty() } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { quoteBorderOverride = value; markDirty() } + + override var quoteFg: Color + get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) + set(value) { quoteFgOverride = value; markDirty() } + + override var tableBorder: Color + get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { tableBorderOverride = value; markDirty() } + + override var opaque: Boolean + get() = opaqueState + set(value) { + opaqueState = value + pane.isOpaque = value + if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() + markDirty() + } + + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + pane.isOpaque = true + pane.background = UIUtil.getPanelBackground() + markDirty() } - override val component: JComponent get() = pane + // -- content API --------------------------------------------------- override fun set(text: String) { source.clear() @@ -171,73 +231,68 @@ abstract class MdView private constructor() { override fun clear() { source.clear() rendered = "" - wrapped = "" pane.text = "" } - override fun addLinkListener(listener: LinkListener) { - listeners.add(listener) - } - - override fun removeLinkListener(listener: LinkListener) { - listeners.remove(listener) - } + override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } + override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } override fun markdown(): String = source.toString() override fun html(): String = rendered - override fun styledHtml(): String = wrapped + override fun overrideSheet(): String = buildOverrideRulesString() override fun simulateLink(href: String) { val event = LinkEvent(href) - for (l in listeners) { - l.onLink(event) - } + for (l in listeners) l.onLink(event) } private fun markDirty() { - dirty = true + pane.reloadCssStylesheets() if (source.isNotEmpty()) render() } private fun render() { - dirty = false - val md = source.toString() - val body = renderer.render(parser.parse(md)) + val body = renderer.render(parser.parse(source.toString())) rendered = body - wrapped = wrap(body) - pane.text = wrapped + pane.text = "$body" pane.caretPosition = 0 } - private fun wrap(body: String): String { - val fg = hex(foreground) - val size = font.size - val family = font.family - val codeSize = (size - 1).coerceAtLeast(1) - val bgCss = if (opaque) "background: ${hex(background)}; " else "" - return """ - - - $body - - """.trimIndent() + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = buildOverrideRulesString() + if (rules.isNotEmpty()) { + try { sheet.addRule(rules) } catch (_: Exception) {} + } + return sheet + } + + private fun buildOverrideRulesString(): String { + val rules = StringBuilder() + + val body = mutableListOf() + foregroundOverride?.let { body.add("color: ${hex(it)}") } + if (!opaqueState) body.add("background: transparent") + fontOverride?.let { + body.add("font-family: '${it.family}', sans-serif") + body.add("font-size: ${it.size}pt") + } + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } + codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } + preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } + codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } + quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } + quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } + tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } + + return rules.toString().trim() } companion object { - private fun hex(c: Color): String = - String.format("#%02x%02x%02x", c.red, c.green, c.blue) + private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 675b04168af..4fa8ff2d04b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -5,11 +5,12 @@ import java.awt.Color import java.awt.Font /** - * Tests for [MdView] created via [MdView.html] factory. + * Tests for [MdView] created via [MdView.html]. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that - * Swing/HTMLEditorKit initialisation works correctly. + * JBHtmlPane initialisation works correctly. */ +@Suppress("UnstableApiUsage") class MdViewTest : BasePlatformTestCase() { private lateinit var view: MdView @@ -34,82 +35,70 @@ class MdViewTest : BasePlatformTestCase() { fun `test set renders bold`() { view.set("hello **world**") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) - assertTrue("Expected 'world' inside strong", html.contains("world")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("world")) } fun `test set renders italic`() { view.set("hello *world*") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) } fun `test set renders inline code`() { view.set("use `foo()` here") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) - assertTrue("Expected foo() in code", html.contains("foo()")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("foo()")) } fun `test set renders fenced code block`() { view.set("```kotlin\nval x = 1\n```") - val html = view.html() - assertTrue("Expected
             in rendered HTML", html.contains("
            "))
            -        assertTrue("Expected "))
            +        assertTrue(view.html().contains(" in rendered HTML", html.contains("

            ")) + assertTrue(view.html().contains("

            ")) } fun `test set renders unordered list`() { view.set("- one\n- two\n- three") - val html = view.html() - assertTrue("Expected
              in rendered HTML", html.contains("
                ")) - assertTrue("Expected
              • in rendered HTML", html.contains("
              • ")) + assertTrue(view.html().contains("
                  ")) + assertTrue(view.html().contains("
                • ")) } fun `test set renders ordered list`() { view.set("1. one\n2. two\n3. three") - val html = view.html() - assertTrue("Expected
                    in rendered HTML", html.contains("
                      ")) + assertTrue(view.html().contains("
                        ")) } fun `test set renders blockquote`() { view.set("> quoted text") - val html = view.html() - assertTrue("Expected
                        in rendered HTML", html.contains("
                        ")) + assertTrue(view.html().contains("
                        ")) } fun `test set renders strikethrough`() { view.set("~~deleted~~") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) } fun `test set renders table`() { view.set("| a | b |\n|---|---|\n| 1 | 2 |") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("
                        ")) - assertTrue("Expected
                        in rendered HTML", html.contains("")) - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("
                        ")) + assertTrue(view.html().contains("")) } fun `test set renders autolink`() { view.set("Visit https://example.com for details") - val html = view.html() - assertTrue("Expected autolinked URL", html.contains(" after append", html.contains("")) + assertTrue(view.html().contains("")) } fun `test append after set extends content`() { @@ -144,8 +132,7 @@ class MdViewTest : BasePlatformTestCase() { fun `test clear resets rendered html`() { view.set("some content") view.clear() - val html = view.html() - assertFalse("Expected no 'some content' after clear", html.contains("some content")) + assertFalse(view.html().contains("some content")) } // ---- link listener ---- @@ -175,7 +162,7 @@ class MdViewTest : BasePlatformTestCase() { view.addLinkListener(listener) view.removeLinkListener(listener) view.simulateLink("https://example.com") - assertTrue("Removed listener should not fire", received.isEmpty()) + assertTrue(received.isEmpty()) } // ---- component ---- @@ -218,137 +205,197 @@ class MdViewTest : BasePlatformTestCase() { fun `test streaming simulation appends tokens`() { val tokens = listOf("Hello ", "**wor", "ld**", "\n\n", "Done.") - for (token in tokens) { - view.append(token) - } + for (token in tokens) view.append(token) assertEquals("Hello **world**\n\nDone.", view.markdown()) - val html = view.html() - assertTrue(html.contains("")) - assertTrue(html.contains("Done.")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("Done.")) } - // ---- styling ---- + // ---- style overrides (empty by default) ---- - fun `test foreground color appears in CSS`() { + fun `test no overrides produces empty override sheet`() { + assertEquals("", view.overrideSheet()) + } + + // ---- style overrides appear in override sheet when set ---- + + fun `test foreground override appears in override sheet`() { view.foreground = Color(0xAA, 0xBB, 0xCC) view.set("text") - assertTrue(view.styledHtml().contains("#aabbcc")) + assertTrue(view.overrideSheet().contains("#aabbcc")) } - fun `test background color appears in CSS`() { - view.background = Color(0x11, 0x22, 0x33) - view.set("text") - assertTrue(view.styledHtml().contains("#112233")) - } - - fun `test link color appears in CSS`() { + fun `test link color override appears in override sheet`() { view.linkColor = Color(0xFF, 0x00, 0x77) view.set("[a](https://x.com)") - assertTrue(view.styledHtml().contains("#ff0077")) + assertTrue(view.overrideSheet().contains("#ff0077")) } - fun `test code background appears in CSS`() { + fun `test code bg override appears in override sheet`() { view.codeBg = Color(0x10, 0x20, 0x30) view.set("`code`") - assertTrue(view.styledHtml().contains("#102030")) + assertTrue(view.overrideSheet().contains("#102030")) } - fun `test pre background and foreground appear in CSS`() { + fun `test pre bg and fg overrides appear in override sheet`() { view.preBg = Color(0x0A, 0x0B, 0x0C) view.preFg = Color(0xD0, 0xE0, 0xF0) view.set("```\ncode\n```") - val css = view.styledHtml() - assertTrue(css.contains("#0a0b0c")) - assertTrue(css.contains("#d0e0f0")) + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("#d0e0f0")) } - fun `test code font family appears in CSS`() { + fun `test code font override appears in override sheet`() { view.codeFont = "Fira Code" view.set("`x`") - assertTrue(view.styledHtml().contains("Fira Code")) + assertTrue(view.overrideSheet().contains("Fira Code")) } - fun `test blockquote colors appear in CSS`() { + fun `test blockquote color overrides appear in override sheet`() { view.quoteBorder = Color(0xAA, 0x00, 0x00) view.quoteFg = Color(0x00, 0xBB, 0x00) view.set("> quote") - val css = view.styledHtml() - assertTrue(css.contains("#aa0000")) - assertTrue(css.contains("#00bb00")) + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#aa0000")) + assertTrue(sheet.contains("#00bb00")) } - fun `test table border color appears in CSS`() { + fun `test table border override appears in override sheet`() { view.tableBorder = Color(0x12, 0x34, 0x56) view.set("| a |\n|---|\n| 1 |") - assertTrue(view.styledHtml().contains("#123456")) + assertTrue(view.overrideSheet().contains("#123456")) } - fun `test font family appears in CSS`() { + fun `test font family override appears in override sheet`() { view.font = Font("Courier New", Font.PLAIN, 14) view.set("text") - assertTrue(view.styledHtml().contains("Courier New")) + assertTrue(view.overrideSheet().contains("Courier New")) } - fun `test font size appears in CSS`() { + fun `test font size override appears in override sheet`() { view.font = Font("Arial", Font.PLAIN, 18) view.set("text") - assertTrue(view.styledHtml().contains("18pt")) + assertTrue(view.overrideSheet().contains("18pt")) } - fun `test style change re-renders existing content`() { + fun `test style change re-renders and override sheet reflects change`() { view.set("hello") view.foreground = Color(0xDE, 0xAD, 0x00) - assertTrue(view.styledHtml().contains("#dead00")) - assertTrue(view.styledHtml().contains("hello")) + assertTrue(view.overrideSheet().contains("#dead00")) + assertTrue(view.html().contains("hello")) } fun `test style change without content does not crash`() { view.foreground = Color.RED view.linkColor = Color.BLUE view.codeFont = "Monospaced" - // no content set — should not throw assertEquals("", view.markdown()) } + // ---- default codeFont uses editor font placeholder ---- + + fun `test default codeFont is editor font placeholder`() { + // When no codeFont override is set, the getter returns the editor font placeholder + assertTrue(view.codeFont.contains("_Editor")) + } + + fun `test default override sheet is empty before any set`() { + // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane + assertEquals("", view.overrideSheet()) + } + + // ---- background sets component background ---- + + fun `test background override sets component background`() { + view.background = Color(0x11, 0x22, 0x33) + assertEquals(Color(0x11, 0x22, 0x33), view.component.background) + } + + fun `test background override does not appear in override sheet`() { + // background is applied to the Swing component, not via CSS override rule + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertFalse(view.overrideSheet().contains("#112233")) + } + // ---- opaque / transparent ---- - fun `test opaque true includes background in CSS`() { + fun `test opaque true sets component opaque`() { view.opaque = true - view.background = Color(0x11, 0x22, 0x33) - view.set("text") - assertTrue(view.styledHtml().contains("background: #112233")) + assertTrue(view.component.isOpaque) } - fun `test opaque false omits background from body CSS`() { - view.opaque = false - view.background = Color(0x11, 0x22, 0x33) - view.set("text") - assertFalse(view.styledHtml().contains("background: #112233")) - } - - fun `test opaque false does not affect pre background`() { - view.opaque = false - view.preBg = Color(0x0A, 0x0B, 0x0C) - view.set("```\ncode\n```") - assertTrue(view.styledHtml().contains("#0a0b0c")) - } - - fun `test opaque toggle re-renders`() { - view.background = Color(0xFE, 0xFE, 0xFE) - view.set("hello") - view.opaque = false - assertFalse(view.styledHtml().contains("background: #fefefe")) - view.opaque = true - assertTrue(view.styledHtml().contains("background: #fefefe")) - } - - fun `test component is not opaque when opaque is false`() { + fun `test opaque false sets component non-opaque`() { view.opaque = false assertFalse(view.component.isOpaque) } - fun `test component is opaque when opaque is true`() { + fun `test opaque false adds transparent background to override sheet`() { + view.opaque = false + view.set("text") + assertTrue(view.overrideSheet().contains("transparent")) + } + + fun `test opaque true does not add transparent rule`() { + view.opaque = true + view.set("text") + assertFalse(view.overrideSheet().contains("transparent")) + } + + fun `test opaque false does not affect pre background override`() { + view.opaque = false + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.set("```\ncode\n```") + assertTrue(view.overrideSheet().contains("#0a0b0c")) + } + + fun `test background override applied to component when opaque is true`() { + view.background = Color(0xFE, 0xFE, 0xFE) + view.opaque = true + assertEquals(Color(0xFE, 0xFE, 0xFE), view.component.background) + } + + fun `test opaque toggle updates component opacity`() { + view.opaque = false + assertFalse(view.component.isOpaque) view.opaque = true assertTrue(view.component.isOpaque) } + + // ---- resetStyles ---- + + fun `test resetStyles clears foreground override`() { + view.foreground = Color.RED + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles clears all overrides`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeBg = Color.GREEN + view.preBg = Color.ORANGE + view.preFg = Color.CYAN + view.codeFont = "Monospaced" + view.quoteBorder = Color.PINK + view.quoteFg = Color.MAGENTA + view.tableBorder = Color.YELLOW + view.font = Font("Arial", Font.PLAIN, 18) + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles restores opaque to true`() { + view.opaque = false + view.resetStyles() + assertTrue(view.component.isOpaque) + } + + fun `test resetStyles after set still renders content`() { + view.set("hello **world**") + view.foreground = Color.RED + view.resetStyles() + assertTrue(view.html().contains("")) + } } From efb867a9f61986be8e6c774e14c4faba103f42e0 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 19 Apr 2026 14:32:09 -0400 Subject: [PATCH 20/27] feat(vscode): support session artifact events and seed status on load in JetBrains plugin - Forward session.compacted, session.diff, todo.updated to SessionModel instead of ignoring them - Add DiffUpdated, TodosUpdated, Compacted model events and matching state fields - Seed busy/retry/offline state from KiloSessionService.statuses on existing-session load - Fix null sessionID filter so global session.error events reach the frontend - Add SessionArtifactsTest and extend SessionModelTest and SessionRecoveryTest --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 2 +- .../client/session/SessionController.kt | 36 +++- .../client/session/model/SessionModel.kt | 45 +++++ .../client/session/model/SessionModelEvent.kt | 12 ++ .../client/session/ui/MessageListUi.kt | 7 + .../client/session/SessionArtifactsTest.kt | 184 ++++++++++++++++++ .../client/session/SessionRecoveryTest.kt | 159 +++++++++++++++ .../client/session/model/SessionModelTest.kt | 85 ++++++++ 8 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 406faed16fc..a32477c53c2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -109,7 +109,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID } - sid == id + sid == null || sid == id } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index bf8ab071141..e1aa9cd827d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -25,6 +25,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger @@ -248,16 +249,19 @@ class SessionController( } } - /** Rehydrate pending permissions/questions after history load or reconnect. */ + /** Rehydrate pending permissions/questions and current session status after history load. */ private suspend fun recoverPending(id: String) { try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } + val status = sessions.statuses.value[id] edt { if (permissions.isNotEmpty()) { model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) } else if (questions.isNotEmpty()) { model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) + } else if (status != null) { + seedStatus(status) } } } catch (e: Exception) { @@ -265,6 +269,29 @@ class SessionController( } } + /** + * Seed initial session state from a snapshot status value. + * + * Used only during recovery — does not apply the live-event clobbering guard + * for "busy" because no more-specific state has arrived yet. + */ + private fun seedStatus(dto: SessionStatusDto) { + val state = when (dto.type) { + "busy" -> SessionState.Busy(KiloBundle.message("session.status.considering")) + "retry" -> SessionState.Retry( + message = dto.message ?: "", + attempt = dto.attempt ?: 0, + next = dto.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = dto.message ?: "", + requestId = dto.requestID ?: "", + ) + else -> return // idle or unknown — leave as Idle + } + model.setState(state) + } + private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.MessageUpdated -> { @@ -386,10 +413,9 @@ class SessionController( } } - // These events don't change visible session state — ignore for now. - is ChatEventDto.SessionCompacted -> Unit - is ChatEventDto.SessionDiffChanged -> Unit - is ChatEventDto.TodoUpdated -> Unit + is ChatEventDto.SessionCompacted -> model.markCompacted() + is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) + is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index d3904a6a314..6d90a918382 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -7,6 +8,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -33,6 +35,15 @@ class SessionModel { var state: SessionState = SessionState.Idle private set + var diff: List = emptyList() + private set + + var todos: List = emptyList() + private set + + var compactionCount: Int = 0 + private set + private val listeners = mutableListOf() fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) { @@ -124,9 +135,27 @@ class SessionModel { fire(SessionModelEvent.StateChanged(state)) } + fun setDiff(diff: List) { + this.diff = diff + fire(SessionModelEvent.DiffUpdated(diff)) + } + + fun setTodos(todos: List) { + this.todos = todos + fire(SessionModelEvent.TodosUpdated(todos)) + } + + fun markCompacted() { + compactionCount++ + fire(SessionModelEvent.Compacted(compactionCount)) + } + fun loadHistory(history: List) { entries.clear() state = SessionState.Idle + diff = emptyList() + todos = emptyList() + compactionCount = 0 for (msg in history) { val item = Message(msg.info) for (part in msg.parts) { @@ -141,6 +170,9 @@ class SessionModel { fun clear() { entries.clear() state = SessionState.Idle + diff = emptyList() + todos = emptyList() + compactionCount = 0 fire(SessionModelEvent.Cleared) } @@ -208,6 +240,19 @@ class SessionModel { else -> {} } + if (diff.isNotEmpty()) { + if (out.isNotEmpty()) out.add("---") + out.add("diff: ${diff.joinToString(" ") { it.file }}") + } + if (todos.isNotEmpty()) { + if (out.isNotEmpty()) out.add("---") + todos.forEach { out.add("todo: [${it.status}] ${it.content}") } + } + if (compactionCount > 0) { + if (out.isNotEmpty()) out.add("---") + out.add("compacted: $compactionCount") + } + return out.joinToString("\n") } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index dcdc8ddb4e5..6ddb5be4f26 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.session.model +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.TodoDto + /** * Change events fired by [SessionModel]. * @@ -31,6 +34,15 @@ sealed class SessionModelEvent { data class StateChanged(val state: SessionState) : SessionModelEvent() { override fun toString() = "StateChanged ${state::class.simpleName}" } + data class DiffUpdated(val diff: List) : SessionModelEvent() { + override fun toString() = "DiffUpdated files=${diff.size}" + } + data class TodosUpdated(val todos: List) : SessionModelEvent() { + override fun toString() = "TodosUpdated count=${todos.size}" + } + data class Compacted(val count: Int) : SessionModelEvent() { + override fun toString() = "Compacted count=$count" + } data object HistoryLoaded : SessionModelEvent() data object Cleared : SessionModelEvent() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index 57044b0aa2d..f7a106ffbbc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -37,6 +37,7 @@ class MessageListUi( ) : JPanel(BorderLayout()) { private val blocks = LinkedHashMap() + private var errorLabel: JBLabel? = null private val inner = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -75,6 +76,9 @@ class MessageListUi( is SessionModelEvent.StateChanged -> onState(event.state) is SessionModelEvent.HistoryLoaded -> onHistory() is SessionModelEvent.Cleared -> onCleared() + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.Compacted -> Unit } } } @@ -114,6 +118,7 @@ class MessageListUi( } private fun onState(state: SessionState) { + errorLabel?.let { inner.remove(it); errorLabel = null } when (state) { is SessionState.Busy -> { label.text = state.text @@ -127,6 +132,7 @@ class MessageListUi( border = JBUI.Borders.empty(4, 0) alignmentX = LEFT_ALIGNMENT } + errorLabel = err inner.add(err, inner.componentCount - 1) } else -> { @@ -154,6 +160,7 @@ class MessageListUi( private fun clear() { blocks.clear() + errorLabel = null inner.removeAll() inner.add(spinner) spinner.isVisible = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt new file mode 100644 index 00000000000..e25d544235a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt @@ -0,0 +1,184 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.TodoDto + +/** + * Controller-level tests for session artifact events: + * [ChatEventDto.SessionDiffChanged], [ChatEventDto.TodoUpdated], and + * [ChatEventDto.SessionCompacted]. + * + * Verifies that the controller forwards them to the model and fires the + * expected model events, and that the session ID filter drops events + * belonging to other sessions. + */ +class SessionArtifactsTest : SessionControllerTestBase() { + + // ------ SessionDiffChanged ------ + + fun `test SessionDiffChanged updates model and fires DiffUpdated`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionDiffChanged( + sessionID = "ses_test", + diff = listOf( + DiffFileDto("src/A.kt", additions = 5, deletions = 2), + DiffFileDto("src/B.kt", additions = 1, deletions = 0), + ), + )) + + assertModelEvents("DiffUpdated files=2", modelEvents) + assertSession( + """ + diff: src/A.kt src/B.kt + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionDiffChanged from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionDiffChanged( + sessionID = "ses_other", + diff = listOf(DiffFileDto("src/Other.kt", additions = 1, deletions = 0)), + )) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionDiffChanged with empty list clears diff`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 3, 1)))) + emit(ChatEventDto.SessionDiffChanged("ses_test", emptyList())) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + // ------ TodoUpdated ------ + + fun `test TodoUpdated updates model and fires TodosUpdated`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TodoUpdated( + sessionID = "ses_test", + todos = listOf( + TodoDto("Write tests", "pending", "high"), + TodoDto("Ship it", "in_progress", "medium"), + ), + )) + + assertModelEvents("TodosUpdated count=2", modelEvents) + assertSession( + """ + todo: [pending] Write tests + todo: [in_progress] Ship it + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test TodoUpdated from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.TodoUpdated( + sessionID = "ses_other", + todos = listOf(TodoDto("alien task", "pending", "low")), + )) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test TodoUpdated replaces previous todo list`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TodoUpdated("ses_test", listOf( + TodoDto("task1", "pending", "high"), + TodoDto("task2", "pending", "low"), + ))) + emit(ChatEventDto.TodoUpdated("ses_test", listOf( + TodoDto("only task", "completed", "medium"), + ))) + + assertSession( + """ + todo: [completed] only task + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + // ------ SessionCompacted ------ + + fun `test SessionCompacted fires Compacted event`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_test")) + + assertModelEvents("Compacted count=1", modelEvents) + assertSession( + """ + compacted: 1 + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test multiple SessionCompacted events accumulate`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_test"), flush = false) + emit(ChatEventDto.SessionCompacted("ses_test"), flush = false) + emit(ChatEventDto.SessionCompacted("ses_test")) + + assertSession( + """ + compacted: 3 + + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test SessionCompacted from other session is ignored`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionCompacted("ses_other")) + + assertModelEvents("", modelEvents) + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt index 6657077a0b3..87d68d4ff32 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto /** @@ -104,4 +105,162 @@ class SessionRecoveryTest : SessionControllerTestBase() { // Permission list non-empty → AwaitingPermission wins assertTrue(m.model.state is SessionState.AwaitingPermission) } + + // ------ Status seeding from KiloSessionService.statuses ------ + + fun `test busy status is seeded from statuses map`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, show = false, + ) + } + + fun `test retry status is seeded with message attempt and next`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto( + type = "retry", + message = "Rate limited", + attempt = 3, + next = 5000L, + )) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [retry] [Rate limited] + """, + m, show = false, + ) + val state = m.model.state as SessionState.Retry + assertEquals(3, state.attempt) + assertEquals(5000L, state.next) + } + + fun `test offline status is seeded with message and requestId`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto( + type = "offline", + message = "No network", + requestID = "req_xyz", + )) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [offline] [No network] + """, + m, show = false, + ) + assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId) + } + + fun `test idle status in map leaves controller in Idle`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle")) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, show = false, + ) + } + + fun `test missing status entry leaves controller in Idle`() { + rpc.statuses.value = emptyMap() + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, show = false, + ) + } + + fun `test pending permission overrides a seeded busy status`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + rpc.pendingPermissionList.add( + PermissionRequestDto( + id = "perm_p", + sessionID = "ses_test", + permission = "read", + patterns = listOf("*.kt"), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + permission#perm_p + tool: + name: read + patterns: *.kt + always: + file: + state: PENDING + metadata: + + [code] [kilo/gpt-5] [awaiting-permission] + """, + m, show = false, + ) + } + + fun `test pending question overrides a seeded retry status`() { + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L)) + rpc.pendingQuestionList.add( + QuestionRequestDto( + id = "q_p", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("Proceed?", "Q")), + ) + ) + + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + question#q_p + tool: + header: Q + prompt: Proceed? + multiple: false + custom: true + + [code] [kilo/gpt-5] [awaiting-question] + """, + m, show = false, + ) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index edcf83c1a66..50cad2007ce 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -8,6 +9,7 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.UsefulTestCase @@ -407,6 +409,89 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.isEmpty()) } + fun `test setDiff stores diff and fires DiffUpdated`() { + model.setDiff(listOf( + DiffFileDto("src/A.kt", additions = 3, deletions = 1), + DiffFileDto("src/B.kt", additions = 0, deletions = 2), + )) + + assertModel("diff: src/A.kt src/B.kt") + assertEquals("DiffUpdated files=2", events.single().toString()) + } + + fun `test setDiff with empty list clears diff`() { + model.setDiff(listOf(DiffFileDto("src/A.kt", additions = 1, deletions = 0))) + events.clear() + + model.setDiff(emptyList()) + + assertModel("") + assertEquals("DiffUpdated files=0", events.single().toString()) + } + + fun `test setTodos stores todos and fires TodosUpdated`() { + model.setTodos(listOf( + TodoDto("Write tests", "pending", "high"), + TodoDto("Ship it", "in_progress", "medium"), + )) + + assertModel(""" + todo: [pending] Write tests + todo: [in_progress] Ship it + """) + assertEquals("TodosUpdated count=2", events.single().toString()) + } + + fun `test setTodos with empty list clears todos`() { + model.setTodos(listOf(TodoDto("old task", "completed", "low"))) + events.clear() + + model.setTodos(emptyList()) + + assertModel("") + assertEquals("TodosUpdated count=0", events.single().toString()) + } + + fun `test markCompacted increments count and fires Compacted`() { + model.markCompacted() + + assertModel("compacted: 1") + assertEquals("Compacted count=1", events.single().toString()) + } + + fun `test markCompacted accumulates across multiple calls`() { + model.markCompacted() + model.markCompacted() + model.markCompacted() + + assertModel("compacted: 3") + assertEquals("Compacted count=3", events.last().toString()) + } + + fun `test clear resets diff todos and compactionCount`() { + model.setDiff(listOf(DiffFileDto("a.kt", 1, 0))) + model.setTodos(listOf(TodoDto("task", "pending", "high"))) + model.markCompacted() + events.clear() + + model.clear() + + assertModel("") + assertTrue(events.single() is SessionModelEvent.Cleared) + } + + fun `test loadHistory resets diff todos and compactionCount`() { + model.setDiff(listOf(DiffFileDto("a.kt", 1, 0))) + model.setTodos(listOf(TodoDto("task", "pending", "high"))) + model.markCompacted() + events.clear() + + model.loadHistory(emptyList()) + + assertModel("") + assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + } + fun `test clear resets messages and state`() { model.addMessage(msg("m1", "user")) model.setState(SessionState.Busy("busy")) From 92ce5a7a1ea628a0a60642bbadde74b687b75d6b Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 19 Apr 2026 14:45:55 -0400 Subject: [PATCH 21/27] chore: remove plan files from branch --- .kilo/plans/1776368757895-gentle-pixel.md | 684 -------------------- .kilo/plans/1776447841515-misty-eagle.md | 276 -------- .kilo/plans/1776454044955-clever-circuit.md | 225 ------- .kilo/plans/1776459614129-curious-squid.md | 451 ------------- 4 files changed, 1636 deletions(-) delete mode 100644 .kilo/plans/1776368757895-gentle-pixel.md delete mode 100644 .kilo/plans/1776447841515-misty-eagle.md delete mode 100644 .kilo/plans/1776454044955-clever-circuit.md delete mode 100644 .kilo/plans/1776459614129-curious-squid.md diff --git a/.kilo/plans/1776368757895-gentle-pixel.md b/.kilo/plans/1776368757895-gentle-pixel.md deleted file mode 100644 index 166d0f453ff..00000000000 --- a/.kilo/plans/1776368757895-gentle-pixel.md +++ /dev/null @@ -1,684 +0,0 @@ -# Unified SessionModel Architecture - -## Goal - -Restructure the JetBrains chat frontend model layer so there is a **single source of truth** called `SessionModel`. - -`SessionModel` should own: - -- all message content with typed per-part models -- session runtime state (`idle` / `busy`) -- tool execution state (`pending` / `running` / `completed` / `error`) -- prompt-blocking interactive state through a single `PromptState?` -- permission request state -- question request state - -UI work is explicitly deferred until the model architecture is in place. - ---- - -## Design Decision - -Use a **single `PromptState?`** with sealed variants instead of separate `question` and `permission` fields. - -This keeps the state machine explicit and prevents the model from representing impossible UI states such as “active question and active permission at the same time” unless we intentionally expand the architecture later. - -Use **typed state objects and enums** instead of loose booleans and nullable strings. - ---- - -## Current Problems - -### Split responsibility - -- `SessionManager` currently owns runtime/controller state: `busy`, `partType`, `tool`, subscriptions, prompt sending, history loading -- `SessionState` owns raw chat history and workspace/app config state, which creates an unnecessary extra entity -- `MessageListModel` owns a second copy of render-focused message state - -This creates two competing models: - -1. `SessionState` as session truth for messages -2. `MessageListModel` as UI truth for messages, status, questions - -### Naming mismatch - -- `SessionManager` is actually doing controller + derived state work -- `MessageListModel` is closer to the model you want to become `SessionModel` -- `SessionState` duplicates responsibilities that should belong to `SessionModel` - -### Untyped prompt/request state - -- questions were added as `question: QuestionState?` -- permissions do not exist yet -- status is still modeled as `status: String?` -- runtime turn state is only `TurnState`, but prompt-blocking flow is not unified - ---- - -## Target Architecture - -### High-level split - -Keep **orchestration** and **model** separate while preserving the current `SessionManager` name: - -- `SessionManager` - - remains the orchestration class - - owns RPC subscriptions/actions - - converts CLI/RPC DTOs into `SessionModel` mutations - - contains no authoritative session state except coroutine/job wiring - -- `SessionModel` (rename from current `MessageListModel`) - - owns the full frontend session state used by the chat UI - - publishes `SessionModelEvent` - - becomes the only model the future UI should render from - - directly handles message add/remove/update, delta streaming, history loading, phase changes, prompt state, permissions, questions, errors, and status - -There is **no `SessionState` / `SessionCache` / extra backing entity** in the target architecture. - -The key point: after the refactor, `SessionManager` mutates `SessionModel` directly and the UI reads only `SessionModel`. - ---- - -## Core Model Shape - -### `SessionModel` (renamed from `MessageListModel`) - -```kotlin -class SessionModel { - val messages: Collection - - var phase: SessionPhase - private set - - var prompt: PromptState? - private set - - var status: StatusState? - private set - - val errors: List - - fun addMessage(info: MessageInfo) - fun removeMessage(id: String) - fun updateContent(messageId: String, dto: PartDto) - fun appendDelta(messageId: String, partId: String, delta: String) - fun loadHistory(history: List) - - fun setPhase(phase: SessionPhase) - fun setStatus(state: StatusState?) - fun addError(err: ErrorState) - - fun askQuestion(state: QuestionState) - fun askPermission(state: PermissionState) - fun resolvePrompt(id: String) - - fun clear() -} -``` - -`SessionModel` directly absorbs the current responsibilities split across `MessageListModel` and `SessionState`. - -### Session-level state types - -```kotlin -enum class SessionPhase { - IDLE, - BUSY, -} - -sealed class StatusState { - data class Thinking(val text: String) : StatusState() - data class Working(val text: String) : StatusState() -} - -data class ErrorState( - val message: String, - val kind: String? = null, -) -``` - -Notes: - -- `SessionPhase` replaces ad-hoc `busy: Boolean` -- `StatusState?` replaces raw `status: String?` -- `ErrorState` replaces raw `List` and gives room for typed error handling later - ---- - -## Message Models - -### `MessageModel` - -Keep a typed message object with ordered parts. - -```kotlin -class MessageModel( - val info: MessageInfo, -) { - val parts: LinkedHashMap -} -``` - -### `MessageInfo` - -Introduce a frontend/domain message info model instead of exposing DTOs directly as the long-term model surface. - -```kotlin -data class MessageInfo( - val id: String, - val role: String, - val created: Double, - val completed: Double? = null, - val agent: String? = null, - val providerId: String? = null, - val modelId: String? = null, - val parentId: String? = null, - val cost: Double? = null, -) -``` - -This can be built from `MessageDto` by the `SessionManager`/model boundary. - -`SessionModel` owns conversion from DTO-shaped history/event data into domain message state. `SessionManager` forwards DTOs in, but does not store them. - ---- - -## Content Models - -The user asked that “everything we put into session model has its own models, such as Text, Reasoning, Question & etc”. - -To make that explicit, split the current `PartModel` and prompt-blocking models under a common naming scheme. - -### Message content inside messages - -```kotlin -sealed class ContentModel(val id: String) { - class Text(id: String) : ContentModel(id) { - val content = StringBuilder() - } - - class Reasoning(id: String) : ContentModel(id) { - val content = StringBuilder() - } - - class Tool(id: String, val name: String) : ContentModel(id) { - var state: ToolExecState = ToolExecState.PENDING - var title: String? = null - } - - class Compaction(id: String) : ContentModel(id) -} - -enum class ToolExecState { - PENDING, - RUNNING, - COMPLETED, - ERROR, -} -``` - -Notes: - -- Rename current `PartModel` to `ContentModel` -- Rename current `ToolState` to `ToolExecState` to avoid confusion with future permission/question state - -### Prompt-blocking interaction state - -```kotlin -sealed class PromptState { - abstract val id: String - - data class Question( - override val id: String, - val state: QuestionState, - ) : PromptState() - - data class Permission( - override val id: String, - val state: PermissionState, - ) : PromptState() -} -``` - -This becomes the session model’s authoritative prompt gate. - ---- - -## Question Models - -The current `QuestionState.kt` is directionally correct, but should be framed as part of the unified domain model. - -```kotlin -data class QuestionState( - val id: String, - val items: List, - val tool: ToolCallRef? = null, -) - -data class QuestionItem( - val question: String, - val header: String, - val options: List, - val multiple: Boolean, - val custom: Boolean, -) - -data class QuestionOption( - val label: String, - val description: String, -) -``` - -Notes: - -- `questions` -> `items` reads better as a nested structure -- `ToolRef` should become a shared `ToolCallRef` - ---- - -## Permission Models - -Based on the VS Code model and CLI schema, add typed permission models now even before UI work. - -### Core permission state - -```kotlin -data class PermissionState( - val id: String, - val sessionId: String, - val name: String, - val patterns: List, - val always: List, - val meta: PermissionMeta, - val message: String? = null, - val tool: ToolCallRef? = null, - val state: PermissionRequestState = PermissionRequestState.PENDING, -) - -enum class PermissionRequestState { - PENDING, - RESPONDING, - RESOLVED, - ERROR, -} -``` - -### Tool association - -```kotlin -data class ToolCallRef( - val messageId: String, - val callId: String, -) -``` - -### Permission metadata - -Start with a generic container, but typed enough to avoid `Map` as the final model surface. - -```kotlin -data class PermissionMeta( - val rules: List = emptyList(), - val diff: String? = null, - val filePath: String? = null, - val fileDiff: PermissionFileDiff? = null, - val raw: Map = emptyMap(), -) - -data class PermissionFileDiff( - val file: String, - val patch: String? = null, - val before: String? = null, - val after: String? = null, - val additions: Int, - val deletions: Int, -) -``` - -### Permission response intent - -This is useful even before UI implementation. - -```kotlin -enum class PermissionReply { - ONCE, - ALWAYS, - REJECT, -} -``` - -### Why model permission state now - -VS Code effectively tracks permissions using: - -- pending queue -- responding set -- resolution/error events - -For JetBrains, since you want a true state machine and single point of truth, encode that lifecycle directly on the model rather than split it across multiple collections. - ---- - -## Events - -Rename `MessageModelEvent` to `SessionModelEvent` once the rename happens. - -```kotlin -sealed class SessionModelEvent { - data class MessageAdded(val info: MessageInfo) : SessionModelEvent() - data class MessageRemoved(val id: String) : SessionModelEvent() - - data class ContentAdded(val messageId: String, val content: ContentModel) : SessionModelEvent() - data class ContentUpdated(val messageId: String, val content: ContentModel) : SessionModelEvent() - data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() - - data class PhaseChanged(val phase: SessionPhase) : SessionModelEvent() - data class StatusChanged(val status: StatusState?) : SessionModelEvent() - data class PromptChanged(val prompt: PromptState?) : SessionModelEvent() - data class ErrorAdded(val err: ErrorState) : SessionModelEvent() - - data object HistoryLoaded : SessionModelEvent() - data object Cleared : SessionModelEvent() -} -``` - -Key change: - -- instead of separate `QuestionAsked` / `QuestionResolved` and future `Permission...` events, use one authoritative `PromptChanged(prompt)` event because prompt state is single-source and exclusive - -This is a cleaner state-machine API. - ---- - -## Renames - -### Rename `MessageListModel` → `SessionModel` - -This is the user-requested rename, but it should come with the responsibility transfer described above. - -### Keep `SessionManager` - -Do not rename `SessionManager`. - -It remains the orchestration class: - -- `SessionManager` = orchestration / RPC / subscriptions -- `SessionModel` = state - -### Remove `SessionState` - -`SessionState` should be deleted, not renamed. - -Reason: - -- the user explicitly does not want an extra entity -- `SessionModel` should be the point of truth -- message add/remove/update/history logic should live in `SessionModel` -- runtime session state should live in `SessionModel` -- prompt state should live in `SessionModel` - -If temporary helpers are needed during the refactor, keep them as private conversion functions inside `SessionManager` or `SessionModel`, not as a long-lived separate class. - ---- - -## Migration Plan - -### Phase 1: Naming + model consolidation - -1. Rename current `MessageListModel` to `SessionModel` -2. Keep `SessionManager` name and narrow its responsibilities to orchestration only -3. Rename current `MessageModelEvent` to `SessionModelEvent` -4. Rename `PartModel` to `ContentModel` -5. Rename `ToolState` to `ToolExecState` -6. Delete `SessionState` and move its responsibilities into `SessionModel` - -This is mostly mechanical, but important before deeper changes. - -### Phase 2: Expand `SessionModel` to true source of truth - -Move the following session-facing state into `SessionModel`: - -- `phase` / busy-idle runtime state -- `status` -- `errors` -- active prompt state -- typed messages / contents -- message history loading from `MessageWithPartsDto` -- message/part lookup APIs currently served by `SessionState` - -`SessionManager` should mutate only `SessionModel`; the UI should read only `SessionModel`. - -### Phase 3: Add typed prompt models - -1. Replace `question: QuestionState?` with `prompt: PromptState?` -2. Add `PermissionState`, `PermissionMeta`, `PermissionFileDiff`, `PermissionReply`, `ToolCallRef` -3. Add mutations: - -```kotlin -fun setPrompt(state: PromptState?) -fun askQuestion(state: QuestionState) -fun askPermission(state: PermissionState) -fun resolvePrompt(id: String) -fun failPrompt(id: String) -``` - -Recommended actual internal API: - -- keep `setPrompt` private -- expose `askQuestion`, `askPermission`, `resolvePrompt`, `setPermissionState` - -### Phase 4: Remove remaining `SessionState` usage - -At that point `SessionManager` should stop making the UI read back through former cache-style lookups like: - -- `model.chat.part(...)` -- `model.chat.message(...)` - -Instead, `SessionManager` should transform DTOs directly into `SessionModel` mutations. - -Target: - -- no `SessionState` -- no `SessionCache` -- no second backing entity behind `SessionModel` - ---- - -## Permission State Machine - -Since you explicitly want pending/running/question/permission state represented in the model, this is the recommended permission lifecycle. - -### States - -```kotlin -enum class PermissionRequestState { - PENDING, - RESPONDING, - RESOLVED, - ERROR, -} -``` - -### Transitions - -```text -null - -> PENDING on permission.asked -PENDING - -> RESPONDING when user submits response -RESPONDING - -> RESOLVED on permission.replied -RESPONDING - -> ERROR if reply RPC fails -ERROR - -> RESPONDING retry -RESOLVED - -> null when prompt is dismissed from model/UI flow -``` - -Because `PromptState` is exclusive, resolved prompts can often transition directly to `null` rather than lingering in a visible resolved state. - ---- - -## Question State Machine - -```text -null - -> Question(PENDING) on question.asked -Question(PENDING) - -> Question(RESPONDING) when user submits/rejects -Question(RESPONDING) - -> null on question.replied/question.rejected -Question(RESPONDING) - -> Question(ERROR) on reply/reject RPC failure -Question(ERROR) - -> Question(RESPONDING) retry -``` - -This suggests `QuestionState` may also need a lifecycle field, not just payload. - -Recommended update: - -```kotlin -enum class QuestionRequestState { - PENDING, - RESPONDING, - ERROR, -} -``` - -Then: - -```kotlin -data class QuestionState( - val id: String, - val items: List, - val tool: ToolCallRef? = null, - val state: QuestionRequestState = QuestionRequestState.PENDING, -) -``` - ---- - -## Model-First Test Plan - -Do this before UI work. - -### `SessionModelTest.kt` (renamed from current `MessageListModelTest.kt`) - -#### Naming and shape - -- rename the test file/class to `SessionModelTest` -- keep it lightweight (`UsefulTestCase`) - -### Content/message tests - -- message add/remove still work after rename -- `updatePart` creates `ContentModel.Text` -- `updatePart` creates `ContentModel.Reasoning` -- `updatePart` creates `ContentModel.Tool` -- tool state transitions: pending -> running -> completed -> error -- compaction creation works -- unknown content type ignored -- deltas append only to text/reasoning - -### Session state tests - -- initial phase is `IDLE` -- `setPhase(BUSY)` updates state and fires event -- `setStatus(Thinking(...))` stores typed status and fires event -- clear resets phase/status/errors/messages/prompt - -### Question prompt tests - -- `askQuestion` sets `prompt` to `PromptState.Question` -- asking a second question replaces the existing prompt -- `resolvePrompt(id)` clears matching question prompt -- wrong id is noop -- question lifecycle can move `PENDING -> RESPONDING -> ERROR` - -### Permission prompt tests - -- `askPermission` sets `prompt` to `PromptState.Permission` -- permission fields (`patterns`, `always`, `tool`, `meta`) are preserved -- permission lifecycle can move `PENDING -> RESPONDING -> ERROR` -- `resolvePrompt(id)` clears matching permission prompt - -### Exclusivity tests - -- asking a permission replaces a question prompt -- asking a question replaces a permission prompt -- prompt state is always at most one variant - -### Event tests - -- `PromptChanged(prompt)` fires on question set -- `PromptChanged(prompt)` fires on permission set -- `PromptChanged(null)` fires on resolve - ---- - -## Implementation Order - -### Step 1 - -Mechanical rename pass: - -1. `MessageListModel` -> `SessionModel` -2. keep `SessionManager` name -3. `MessageModelEvent` -> `SessionModelEvent` -4. `PartModel` -> `ContentModel` -5. `ToolState` -> `ToolExecState` -6. remove `SessionState` and fold its responsibilities into `SessionModel` - -### Step 2 - -Introduce the unified session model types: - -1. `SessionPhase` -2. `StatusState` -3. `ErrorState` -4. `PromptState` -5. `QuestionRequestState` -6. `PermissionRequestState` -7. `PermissionState` -8. `PermissionMeta` -9. `PermissionFileDiff` -10. `ToolCallRef` - -### Step 3 - -Refactor `SessionModel` API: - -1. replace raw `status: String?` -2. replace `question: QuestionState?` with `prompt: PromptState?` -3. replace raw `errors: List` with `List` -4. rename content-related events and methods -5. absorb `SessionState.load`, `message`, `messages`, and `part` responsibilities directly into `SessionModel` - -### Step 4 - -Refactor `SessionManager` wiring so it mutates only `SessionModel`. - -### Step 5 - -Add model-only tests for permissions, prompt exclusivity, and typed runtime state. - -### Step 6 - -After this model layer stabilizes, start UI work for: - -- permission prompt panel -- question prompt panel -- rendering tool-associated prompts inline or docked - ---- - -## Recommendation - -Implement this in **two commits/phases** even if done in one branch: - -1. **Pure model rename + state-shape refactor** -2. **SessionManager rewiring to the new model API** - -That keeps the migration understandable and makes review significantly easier. diff --git a/.kilo/plans/1776447841515-misty-eagle.md b/.kilo/plans/1776447841515-misty-eagle.md deleted file mode 100644 index 21e4e1aaa7a..00000000000 --- a/.kilo/plans/1776447841515-misty-eagle.md +++ /dev/null @@ -1,276 +0,0 @@ -# JetBrains Session Model — Unified Phase Cleanup - -## Goal - -Collapse the JetBrains `SessionModel`'s three independent state fields (`phase`, `prompt`, `status`) into a single `SessionPhase` sealed class. The UI should see exactly one "what is this session doing?" value at all times. No contradictory combinations possible. - ---- - -## Current Model (Problems) - -**`ContentModel.kt:28-33`** — State types: - -```kotlin -enum class SessionPhase { IDLE, BUSY } - -sealed class StatusState { - data class Thinking(val text: String) : StatusState() - data class Working(val text: String) : StatusState() -} -``` - -**`QuestionState.kt:63-75`** — Prompt overlay: - -```kotlin -sealed class PromptState { - data class Question(override val id: String, val state: QuestionState) : PromptState() - data class Permission(override val id: String, val state: PermissionState) : PromptState() -} -``` - -**`SessionModel.kt:35-42`** — Three independent fields: - -```kotlin -var phase: SessionPhase = SessionPhase.IDLE // IDLE or BUSY -var prompt: PromptState? = null // Question, Permission, or null -var status: StatusState? = null // Thinking, Working, or null -``` - -### Problems - -1. **`phase = BUSY` + `prompt = Permission(...)` is a real combo** — but the phase says "busy" when the session is actually blocked waiting for the user. The UI must check both. - -2. **`phase = BUSY` + `status = null` can happen** — between TurnOpen and the first PartUpdated, status is set but could be cleared/stale from a previous event. - -3. **Three fields create combinatorial states** — `2 × 3 × 3 = 18` combos, most invalid. Only ~6 are real. - -4. **Permission/Question events are not in `ChatEventDto`** — `SessionModel.askQuestion()` and `SessionModel.askPermission()` exist but `SessionManager.handle()` has no cases for them. These features are unwired. - -5. **`SessionManager` line 128-136 tracks `session.status` via a Flow BUT also sets phase from TurnOpen/TurnClose** — two competing sources of truth for the same field, creating race potential. - ---- - -## Proposed Model - -Replace the three fields with a single `SessionPhase` sealed class: - -```kotlin -sealed class SessionPhase { - /** Nothing happening. Ready for user input. */ - data object Idle : SessionPhase() - - /** LLM is actively processing. */ - data class Working(val status: StatusState) : SessionPhase() - - /** Waiting for user to answer a question or approve a permission. */ - data class Prompting(val prompt: PromptState) : SessionPhase() - - /** Request failed, retrying with backoff. */ - data class Retry(val attempt: Int, val message: String, val next: Long) : SessionPhase() - - /** Cannot reach the provider. */ - data class Offline(val requestId: String, val message: String) : SessionPhase() - - /** Turn ended with an error. User can still send a new prompt. */ - data class Error(val message: String, val kind: String? = null) : SessionPhase() -} -``` - -6 variants. Mutually exclusive. Matches how the CLI behaves — when a turn errors, the session stops and the error is the final state of that turn. The user can start a new turn (Error → Working), but the session is NOT idle — it's visibly in an error state until the user acts. - -The existing `ErrorState` data class and the `_errors` accumulated list can be removed from `SessionModel`. When the phase is `Error`, the UI renders the error message from the phase itself. Previous errors are visible in the message history (each assistant message has an `error` field in the CLI). There's no need to track a separate error list in the model. - -### What Gets Removed - -| Old field | Absorbed into | -| ---------------------------- | -------------------------------- | -| `phase: SessionPhase` (enum) | The sealed class itself | -| `prompt: PromptState?` | `SessionPhase.Prompting(prompt)` | -| `status: StatusState?` | `SessionPhase.Working(status)` | -| `_errors: List` | `SessionPhase.Error(message)` | - -The `PromptState` and `StatusState` types remain as-is — they become data _inside_ the relevant phase variant rather than independent model fields. `ErrorState` is no longer needed as a separate type. - -### State Transitions - -``` - ┌──── Retry ◄────┐ - │ │ -Idle ───► Working ───► Prompting - ▲ │ │ │ - │ │ └──► Offline │ - │ ▼ │ -Error ◄─────┴────────────────┘ - │ - └──► Working (user sends new prompt) -``` - -The valid transitions, matching CLI behavior: - -| From | To | Trigger | -| --------- | --------- | --------------------------------------------- | -| Idle | Working | TurnOpen / user sends prompt | -| Working | Idle | TurnClose (completed) | -| Working | Error | ChatEventDto.Error | -| Working | Prompting | permission.asked, question.asked | -| Working | Retry | session.status = retry | -| Working | Offline | session.status = offline | -| Prompting | Working | permission.replied, question.replied | -| Prompting | Idle | permission.rejected, question.rejected | -| Retry | Working | retry succeeds (session.status = busy) | -| Retry | Idle | abort during retry | -| Offline | Working | connectivity restored (session.status = busy) | -| Error | Working | TurnOpen / user sends new prompt | - ---- - -## Implementation Steps - -### Step 1: Update `SessionPhase` type - -**File**: `frontend/.../model/ContentModel.kt` - -Change `SessionPhase` from a 2-value enum to a sealed class with 6 variants. Keep `StatusState` as-is (it becomes the payload of `Working`). Remove `ErrorState` — its data is absorbed into `SessionPhase.Error`. - -### Step 2: Remove `prompt` and `status` from SessionModel - -**File**: `frontend/.../model/SessionModel.kt` - -- Remove `var prompt: PromptState?` field -- Remove `var status: StatusState?` field -- Remove `_errors: MutableList` field and `errors` getter -- Remove `askQuestion()`, `askPermission()`, `resolvePrompt()`, `setStatus()`, `addError()` methods -- All state changes go through `setPhase()` — one setter, one event - -Update `loadHistory()` and `clear()` to only reset `phase = Idle`. - -### Step 3: Add permission/question events to ChatEventDto - -**File**: `shared/.../dto/ChatDto.kt` - -Add new event variants: - -```kotlin -@SerialName("permission.asked") -data class PermissionAsked(val sessionID: String, val request: PermissionRequestDto) : ChatEventDto() - -@SerialName("permission.replied") -data class PermissionReplied(val sessionID: String, val requestID: String) : ChatEventDto() - -@SerialName("question.asked") -data class QuestionAsked(val sessionID: String, val request: QuestionRequestDto) : ChatEventDto() - -@SerialName("question.replied") -data class QuestionReplied(val sessionID: String, val requestID: String) : ChatEventDto() - -@SerialName("question.rejected") -data class QuestionRejected(val sessionID: String, val requestID: String) : ChatEventDto() - -@SerialName("session.status") -data class SessionStatus(val sessionID: String, val status: SessionStatusDto) : ChatEventDto() -``` - -Add corresponding DTOs for `PermissionRequestDto` and `QuestionRequestDto` based on the CLI's `Permission.Request` and `Question.Request` schemas. - -### Step 4: Update SessionManager.handle() - -**File**: `frontend/.../model/SessionManager.kt` - -Update the event handler to drive all transitions through `setPhase()`: - -- `TurnOpen` → `setPhase(Working(Thinking("Considering...")))` -- `TurnClose` → `setPhase(Idle)` -- `Error` → `setPhase(Error(message, kind))` -- `PartUpdated` while `phase is Working` → `setPhase(Working(status()))` (update status text) -- `PermissionAsked` → `setPhase(Prompting(Permission(...)))` -- `PermissionReplied` → `setPhase(Working(Thinking("Considering...")))` (resumes) -- `QuestionAsked` → `setPhase(Prompting(Question(...)))` -- `QuestionReplied` → `setPhase(Working(Thinking("Considering...")))` (resumes) -- `SessionStatus(type=retry)` → `setPhase(Retry(...))` -- `SessionStatus(type=offline)` → `setPhase(Offline(...))` -- `SessionStatus(type=idle)` → `setPhase(Idle)` -- `SessionStatus(type=busy)` → `setPhase(Working(...))` only if currently Idle or Error - -Remove the `statuses.collect` flow (lines 127-137) — session status changes should come through `ChatEventDto.SessionStatus` instead of a separate Flow, keeping one event source. - -### Step 5: Simplify SessionModelEvent - -**File**: `frontend/.../model/SessionModelEvent.kt` - -- Remove `StatusChanged(status)` and `PromptChanged(prompt)` events -- `PhaseChanged(phase)` is the only transition event needed -- The UI reacts to `PhaseChanged` and reads the phase variant to decide what to show - -### Step 6: Simplify SessionEvent - -**File**: `frontend/.../model/SessionEvent.kt` - -- Remove `StatusChanged(text)`, `BusyChanged(busy)`, and `Error(message)` events -- Replace with `PhaseChanged(phase: SessionPhase)` -- UI derives busy from `phase !is Idle && phase !is Error`, status text from `phase` variant -- Errors are visible via `phase is Error` — no separate error event needed - -### Step 7: Wire up backend SSE parsing - -**File**: `backend/.../cli/KiloCliDataParser.kt` (or wherever SSE events are parsed) - -Map the raw SSE events (`permission.asked`, `question.asked`, `session.status`, etc.) to the new `ChatEventDto` variants. Currently only `session.turn.open`, `session.turn.close`, `part.updated`, `part.delta`, `message.updated`, `message.removed`, and `error` are handled. - -### Step 8: Update SessionUi and other UI consumers - -**Files**: `frontend/.../session/SessionUi.kt`, `frontend/.../session/ui/StatusPanel.kt`, `frontend/.../session/ui/PromptPanel.kt` - -Switch on a single `phase` value: - -```kotlin -when (val phase = chat.phase) { - is SessionPhase.Idle -> showPromptInput() - is SessionPhase.Working -> showSpinner(phase.status) - is SessionPhase.Prompting -> showPromptDock(phase.prompt) - is SessionPhase.Retry -> showRetryCountdown(phase) - is SessionPhase.Offline -> showOfflineMessage(phase) - is SessionPhase.Error -> showError(phase.message) -} -``` - -### Step 9: Update tests - -Update existing tests (`TurnLifecycleTest`, `StatusComputationTest`) to assert on `PhaseChanged` instead of `BusyChanged` + `StatusChanged`. Add new tests: - -- Permission asked → phase transitions to `Prompting(Permission(...))` -- Permission replied → phase transitions back to `Working` -- Question asked → phase transitions to `Prompting(Question(...))` -- Question rejected → phase transitions to `Idle` -- Error event → phase transitions to `Error(message)` -- New prompt from Error state → phase transitions to `Working` -- Retry status → phase transitions to `Retry` -- Phase is always exactly one variant (no contradictory combos) - ---- - -## Files Changed - -| File | Change | -| ------------------------------------------------ | ------------------------------------------------------------------------------------- | -| `frontend/.../model/ContentModel.kt` | `SessionPhase` becomes sealed class with 6 variants; remove `ErrorState` | -| `frontend/.../model/SessionModel.kt` | Remove `prompt`, `status`, `_errors` fields; collapse into `setPhase()` | -| `frontend/.../model/SessionModelEvent.kt` | Remove `StatusChanged`, `PromptChanged`, `ErrorAdded`; keep `PhaseChanged` | -| `frontend/.../model/SessionEvent.kt` | Remove `StatusChanged`, `BusyChanged`, `Error`; add `PhaseChanged` | -| `frontend/.../model/SessionManager.kt` | Rewrite `handle()` to drive all state through `setPhase()`; remove `statuses.collect` | -| `frontend/.../model/QuestionState.kt` | No change (PromptState stays as-is, used as payload) | -| `shared/.../dto/ChatDto.kt` | Add permission/question/status event variants | -| `shared/.../dto/SessionDto.kt` | Add permission/question DTOs if needed | -| `backend/.../cli/KiloCliDataParser.kt` | Parse new SSE event types into ChatEventDto variants | -| `frontend/.../session/SessionUi.kt` | Switch on single `phase` | -| `frontend/.../session/ui/StatusPanel.kt` | Derive text from `phase` | -| `frontend/.../session/ui/PromptPanel.kt` | Derive dock from `phase` | -| `frontend/src/test/.../TurnLifecycleTest.kt` | Update assertions | -| `frontend/src/test/.../StatusComputationTest.kt` | Update assertions | -| New test file | Phase transition / invariant tests | - -## What This Does NOT Change - -- CLI backend (`packages/opencode/`) — no changes -- Server API / SSE events — consumed as-is -- `PermissionState`, `QuestionState`, `StatusState` types — reused as phase payloads -- `ContentModel`, `MessageModel` — untouched diff --git a/.kilo/plans/1776454044955-clever-circuit.md b/.kilo/plans/1776454044955-clever-circuit.md deleted file mode 100644 index 65efeffd143..00000000000 --- a/.kilo/plans/1776454044955-clever-circuit.md +++ /dev/null @@ -1,225 +0,0 @@ -# JetBrains Frontend Session State Cleanup - -## Goal - -Replace the current `SessionPhase` + `PromptState` + `StatusState` stack with one explicit session state model that answers a single question cleanly: `what is this session doing right now?` - -## Why The Current Model Feels Messy - -### Current shape - -- `SessionModel.phase: SessionPhase` -- `SessionPhase.Working(val status: StatusState)` -- `SessionPhase.Prompting(val prompt: PromptState)` -- `StatusState` has only `Thinking(text)` and `Working(text)` -- `PromptState` has only `Asking(question)` and `Permitting(permission)` - -### Problems - -1. The real state is split across three types. - `SessionPhase` alone is not enough; callers often need the nested payload type too. - -2. `StatusState` does not carry independent behavior. - In practice the UI only reads `text`, and the `Thinking` vs `Working` distinction is not used meaningfully. - -3. `PromptState` is a wrapper around already meaningful domain models. - `Question` and `Permission` are already specific enough to be top-level session states. - -4. `SessionUi` already treats multiple variants as one concept. - Busy is derived with `phase is SessionPhase.Working || phase is SessionPhase.Prompting`, which is a sign the state model is indirect. - -5. Naming is working against readability. - `phase` sounds like an internal lifecycle step, while the user-facing need is a single `state`. - -## Recommended Target Model - -Rename the concept from `phase` to `state` and flatten it into one sealed class. - -```kotlin -sealed class SessionState { - data object Idle : SessionState() - data class Busy(val text: String) : SessionState() - data class AwaitingQuestion(val question: Question) : SessionState() - data class AwaitingPermission(val permission: Permission) : SessionState() - data class Retry(val message: String, val attempt: Int, val next: Long) : SessionState() - data class Offline(val message: String, val requestId: String) : SessionState() - data class Error(val message: String, val kind: String? = null) : SessionState() -} -``` - -### Design notes - -- `Busy(text)` replaces `SessionPhase.Working(StatusState.*)`. -- `AwaitingQuestion` replaces `SessionPhase.Prompting(PromptState.Asking(...))`. -- `AwaitingPermission` replaces `SessionPhase.Prompting(PromptState.Permitting(...))`. -- `Retry`, `Offline`, and `Error` stay top-level because they represent distinct user-visible conditions. -- `StatusState` and `PromptState` are removed entirely. - -## Intended Rules - -At any point, `SessionModel.state` must be exactly one of: - -1. `Idle` -2. `Busy` -3. `AwaitingQuestion` -4. `AwaitingPermission` -5. `Retry` -6. `Offline` -7. `Error` - -No nested state machine for normal rendering. The UI should only switch on `state`. - -## Transition Mapping - -Map chat events directly to the new state: - -| Event | New state | -| ------------------------------- | --------------------------------------------------------------- | -| `TurnOpen` | `Busy("Considering...")` | -| `PartUpdated(reasoning)` | `Busy("Thinking...")` | -| `PartUpdated(text)` | `Busy("Writing...")` | -| `PartUpdated(tool=...)` | `Busy(tool-specific text)` | -| `TurnClose` | `Idle` | -| `Error` | `Error(message, kind)` | -| `PermissionAsked` | `AwaitingPermission(permission)` | -| `PermissionReplied` | `Busy("Considering...")` | -| `QuestionAsked` | `AwaitingQuestion(question)` | -| `QuestionReplied` | `Busy("Considering...")` | -| `QuestionRejected` | `Idle` | -| `SessionStatusChanged(idle)` | `Idle` | -| `SessionStatusChanged(busy)` | `Busy("Considering...")` only when recovering from coarse state | -| `SessionStatusChanged(retry)` | `Retry(...)` | -| `SessionStatusChanged(offline)` | `Offline(...)` | - -## Implementation Plan - -### 1. Replace the state types - -Files: - -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionPhase.kt` -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptState.kt` -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/StatusState.kt` - -Changes: - -- Introduce `SessionState` as the single sealed class. -- Remove `PromptState`. -- Remove `StatusState`. -- Either rename `SessionPhase.kt` to `SessionState.kt` or replace its contents with the new model and then rename references. - -### 2. Rename the model field and event - -Files: - -- `SessionModel.kt` -- `SessionModelEvent.kt` - -Changes: - -- Rename `phase` to `state`. -- Rename `setPhase()` to `setState()`. -- Rename `PhaseChanged` to `StateChanged`. -- Reset `state` to `Idle` in `loadHistory()` and `clear()`. - -Result: - -- `SessionModel` exposes exactly one current state field. - -### 3. Flatten session manager transitions - -File: - -- `SessionManager.kt` - -Changes: - -- Replace every `chat.setPhase(...)` call with `chat.setState(...)`. -- Remove `toPermissionPrompt()` and `toQuestionPrompt()` returning wrapper types. - Instead return `Permission` and `Question` directly or inline the construction. -- Replace `status(): StatusState` with `status(): String`. -- Convert all event branches to produce a direct `SessionState` variant. - -Important simplification: - -- There should be no code path that first decides `state = Prompting` and then asks the UI to inspect another type. -- There should be no code path that wraps a text label inside a second state class. - -### 4. Update UI consumers to switch on one type - -Files: - -- `SessionUi.kt` -- `MessageListUi.kt` -- any prompt or status panels that react to `PhaseChanged` - -Changes: - -- Switch listeners from `PhaseChanged` to `StateChanged`. -- Derive busy directly from `state`: - `Busy`, `AwaitingQuestion`, `AwaitingPermission`, `Retry`, and `Offline` are busy-like. -- Render message/footer text from `SessionState.Busy.text`. -- Render errors from `SessionState.Error.message`. - -Recommendation: - -- Add a small helper near the UI if needed: - `private fun SessionState.isBusy(): Boolean` - so the busy rule is centralized and readable. - -### 5. Update tests to assert direct states - -Files: - -- `SessionModelTest.kt` -- `TurnLifecycleTest.kt` -- `StatusComputationTest.kt` -- `ListenerLifecycleTest.kt` -- any prompt-related tests - -Changes: - -- Rename assertions from `PhaseChanged` to `StateChanged`. -- Replace `SessionPhase.Working(StatusState.Working(...))` style expectations with `SessionState.Busy(...)`. -- Replace `SessionPhase.Prompting(PromptState...)` expectations with `SessionState.AwaitingQuestion(...)` or `SessionState.AwaitingPermission(...)`. - -Add or update coverage for: - -- `TurnOpen -> Busy` -- `TurnClose -> Idle` -- `Error -> Error` -- `PermissionAsked -> AwaitingPermission` -- `PermissionReplied -> Busy` -- `QuestionAsked -> AwaitingQuestion` -- `QuestionRejected -> Idle` -- tool-specific `PartUpdated -> Busy(text)` -- `SessionStatusChanged(retry/offline)` producing explicit states - -### 6. Verify dead branches and naming fallout - -Search for and remove any remaining references to: - -- `SessionPhase` -- `PromptState` -- `StatusState` -- `phase` -- `PhaseChanged` - -Then verify there is only one current-state concept left in the frontend session model. - -## Recommended Implementation Order - -1. Add `SessionState` and update `SessionModel`/`SessionModelEvent`. -2. Update `SessionManager` transitions and status helper. -3. Update UI listeners and rendering. -4. Update tests. -5. Run frontend tests. - -## Expected Outcome - -After the refactor, the session model should read like this: - -- `chat.state` tells the full story. -- UI switches on one sealed class. -- No extra prompt/status wrapper types are needed. -- State transitions become straightforward to trace from chat events. diff --git a/.kilo/plans/1776459614129-curious-squid.md b/.kilo/plans/1776459614129-curious-squid.md deleted file mode 100644 index 8c58a06075c..00000000000 --- a/.kilo/plans/1776459614129-curious-squid.md +++ /dev/null @@ -1,451 +0,0 @@ -# JetBrains Session Test Plan - -## Goal - -Change the JetBrains frontend session tests from many small event-only assertions toward readable string snapshots built into the production types themselves, with a transcript-first `SessionController.toString()` and a compact status line that reflects readiness and runtime state. Permission and question data should be rendered in the same order the real UI presents them: after the message transcript, at the bottom of the visible content, just above the final status line. - -## Scope - -1. Rename `SessionManagerTestBase` to `SessionControllerTestBase`. -2. Remove stored `SessionModel.ready` and replace it with a computed readiness method based on app/workspace status. -3. Ensure app/workspace state changes applied to `SessionModel` happen on the EDT immediately before their controller event is fired. -4. Add readable `toString()` on `SessionModel` and `SessionController`. -5. Add assertion helpers so tests compare multiline expected strings and separately verify whether messages are visible. -6. Convert permission/question assertions to use the `toString()` output instead of field-by-field state extraction. -7. Convert or add tests to use the new string representation. - -## Current State - -- The existing base class is `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt`. -- It is already controller-oriented: it creates `SessionController`, wires fake RPC services, exposes `prompted()`, `flush()`, and DTO factories. -- 11 frontend tests extend this base. -- `SessionModelTest` is separate and tests the pure model directly. -- There is no existing snapshot or golden assertion helper in `packages/kilo-jetbrains`. -- The nearest local convention is inline triple-quoted strings with `trimIndent()` and plain `assertEquals()`. -- `SessionModel` currently stores: - - `app` - - `workspace` - - `ready` - - `showMessages` - - message content and runtime state -- `SessionController` currently mutates `model.app` and `model.workspace` inside `edt { ... }`, then fires `AppChanged` / `WorkspaceChanged`. -- `SessionController` currently sets `model.ready = true` when workspace status becomes `READY`. -- Permission/question tests still contain field-by-field assertions like: - - cast `model.state` to `AwaitingPermission` or `AwaitingQuestion` - - assert `patterns`, `always`, `tool`, `filePath`, and other fields individually - -## Recommended Implementation - -### 1. Rename The Base - -Rename: - -- File: `SessionManagerTestBase.kt` -> `SessionControllerTestBase.kt` -- Class: `SessionManagerTestBase` -> `SessionControllerTestBase` - -Update all 11 subclasses to extend the new name. - -Note: - -- I will assume the intended final spelling is `SessionControllerTestBase` even though the request contains typos. -- I would not move packages or directories in this pass. The tests currently live under `session/model`, but the requested change only requires renaming the base class and updating usage. - -### 2. Make Readiness Computed, Not Stored - -Remove `SessionModel.ready`. - -Add a method on `SessionModel` that computes readiness from the app and workspace state, for example: - -- `fun isReady(): Boolean` - -Planned logic: - -- app is ready when `app.status == KiloAppStatusDto.READY` -- workspace is ready when `workspace.status == KiloWorkspaceStatusDto.READY` -- overall readiness is true only when both are ready - -This removes duplicated state and avoids drift between `ready` and the two underlying statuses. - -### 3. Update App/Workspace Model State On EDT Right Before Firing - -For app/workspace changes, mutate the model only on the EDT, then fire the matching controller event. - -Requested approach: - -- extend `fire()` with an optional closure that runs immediately before listeners are notified -- when already on the EDT, run the closure first and then notify listeners -- when not on the EDT, schedule both together so the mutation and event delivery still happen on the EDT in order - -Planned shape: - -- `private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null)` - -Use it for app/workspace updates like this conceptually: - -- `fire(SessionControllerEvent.AppChanged) { model.app = state; model.version = app.version }` -- `fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state; ... }` - -This gives two guarantees: - -- UI reads the updated model from the EDT -- listeners observe the event only after the model was updated on that same EDT turn - -### 4. Add Readable `toString()` Implementations - -The formatter should live in production types, not only in test helpers. - -Planned shape: - -- `SessionModel.toString()` -- `SessionController.toString()` - -Recommended responsibilities: - -- `SessionModel.toString()`: - - render message content and current interaction state - - include reasoning, tools, text, compaction, question, and permission data - - place question/permission blocks after the message transcript, matching the UI order - - omit app/workspace readiness noise -- `SessionController.toString()`: - - render the full user-facing transcript plus a final one-line status summary - - if the controller is not ready, summarize app/workspace status instead of repeating runtime status details - -### 5. Separate Dump Structure From Visibility Assertion - -Do not include visibility inside the dump string body. - -Instead: - -- the dump string should exclude message visibility metadata -- assertion helpers should separately verify whether messages are visible - -Planned helper shape: - -- `protected fun assertController(expected: String, c: SessionController, show: Boolean = true)` -- `protected fun assertModel(expected: String, m: SessionModel)` -- helper compares normalized string output -- helper then checks `c.model.showMessages == show` for controller assertions - -This keeps the dump readable and avoids noisy lines like `View: Messages` in every assertion. - -### 6. New String Format - -The controller dump should look like a real transcript first. - -Top section: - -- all messages in order -- no `Messages` header -- include reasoning, tool calls, text, and compaction entries - -Then, at the bottom of the transcript content: - -- if a question exists in current state, include it after the messages as its own block -- if a permission request exists in current state, include it after the messages as its own block -- this is where the real UI exposes those pending interactions -- separate top-level transcript blocks with `---` - -Bottom section: - -- one final status line - -If ready: - -- `[code] [kilo/gpt-5] [busy] [running commands]` - -If not ready: - -- do not include message visibility or runtime status text there -- include agent/model first when already known -- then summarize app/workspace readiness, for example: - - `[code] [kilo/gpt-5] [app: READY] [workspace: PENDING]` - - if agent/model are still unknown: `[app: READY] [workspace: PENDING]` - -### 7. Proposed Text Shape - -Ready + busy example: - -```text -user#msg1 -text#p1: - go ---- -assistant#msg2 -reasoning#p2: - Need to inspect files first -tool#p3 bash [RUNNING] ls -text#p4: - I found the issue - -[code] [kilo/gpt-5] [busy] [running commands] -``` - -Question example: - -```text -user#msg1 -text#p1: - go ---- -question#q1 -tool: msg1/call1 -header: Choice -prompt: Pick one -option: A - Option A -multiple: false -custom: true - -[code] [kilo/gpt-5] [awaiting-question] -``` - -Permission example: - -```text -user#msg1 -text#p1: - go ---- -permission#perm1 -tool: msg1/call1 -name: edit -patterns: *.kt -always: src/** -file: src/A.kt -state: RESPONDING -metadata: kind=edit - -[code] [kilo/gpt-5] [awaiting-permission] -``` - -Not ready example: - -```text -[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] -``` - -If agent/model are not known yet: - -```text -[app: READY] [workspace: PENDING] -``` - -Or, if there is already transcript content but readiness is still incomplete: - -```text -user#msg1 -text#p1: - go - -[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] -``` - -### 8. Permission/Question Assertions Through `toString()` - -Replace direct field assertions like this: - -```kotlin -val state = model.state as SessionState.AwaitingPermission -assertEquals(listOf("*.kt"), state.permission.patterns) -assertEquals(listOf("src/**"), state.permission.always) -assertEquals("src/A.kt", state.permission.meta.filePath) -assertEquals(PermissionRequestState.RESPONDING, state.permission.state) -assertEquals("msg1", state.permission.tool!!.messageId) -``` - -with a string assertion that renders the same information in UI order, for example: - -```kotlin -assertModel( - """ - user#msg1 - text#p1: - go - --- - permission#p1 - tool: msg1/call1 - name: edit - patterns: *.kt - always: src/** - file: src/A.kt - state: RESPONDING - metadata: kind=edit - """, - model, -) -``` - -Do the same for question data. - -This turns low-level state assertions into readable UI-like snapshots while still covering the same fields. - -### 9. `SessionModel.toString()` Role - -`SessionModel.toString()` should stay useful, but it does not need to be the main integration dump anymore. - -Recommended behavior: - -- same transcript content formatting used by `SessionController.toString()` -- no readiness line based on app/workspace -- no visibility information -- question/permission blocks still appear after message content -- optionally a compact final state line derived only from `SessionState` if useful for model-only tests - -This keeps model tests focused on model content/state while controller tests cover readiness and visibility separately. - -### 10. Formatting Rules - -- Preserve message order and part order using the existing `LinkedHashMap` order. -- Separate top-level transcript blocks with `---`. -- Render multiline text with an indented block under the entry header. -- Use `` for empty optional values to keep diffs explicit. -- Keep status words normalized and lowercase in brackets, for example `busy`, `idle`, `awaiting-question`. -- Include useful permission/question details directly in the dump: - - permission: id, tool ref, name, patterns, always, file path if present, request state if present, metadata - - question: id, tool ref, header, prompt, options, multiple/custom flags -- Avoid raw DTO dumps, autogenerated data-class output, or transport-layer formatting because they are harder to scan and less stable. - -### 11. Add Snapshot-Style Tests Around Controller Behavior - -Recommended first conversions or additions: - -- `PromptLifecycleTest` - - permission asked -> assert controller string shows transcript + permission block + final status line - - question asked -> assert controller string shows transcript + question block + final status line - - question rejected -> assert controller string returns to transcript + idle status line -- `TurnLifecycleTest` - - turn open -> assert controller string shows busy status line - - error -> assert controller string shows error state in the final line or error section if needed -- `StatusComputationTest` - - tool part update with `bash` -> assert final line becomes `[code] [kilo/gpt-5] [busy] [running commands]` -- `MessageListTest` - - assistant message with text / reasoning / tool parts -> assert transcript block formatting -- `HistoryLoadingTest` - - loaded history appears in the transcript exactly as the UI would render it -- `AppWatchingTest` / `WorkspaceWatchingTest` - - assert not-ready line shape such as `[app: READY] [workspace: PENDING]` -- `SessionModelTest` - - convert permission/question unit assertions to `assertModel(...)` snapshots when the goal is to verify visible content, not raw object storage -- visibility checks - - assertion helper verifies `showMessages` separately instead of embedding it in the dump string - -The existing fine-grained assertions do not all need to disappear in one pass. The safe approach is: - -- keep narrow unit tests in `SessionModelTest` where object identity or event behavior is the real subject -- add or convert scenario tests to assert the full readable dump -- keep visibility as a separate assertion -- prefer `toString()` assertions for permission/question content that is meant to mirror the UI - -### 12. Verification - -After implementation, run the relevant JetBrains frontend tests, ideally the session test package first, then broader frontend tests if needed. - -Primary verification target: - -- the `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/` suite - -## Concrete Edit List - -Expected touched files: - -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionManagerTestBase.kt` -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt` -- `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/PromptLifecycleTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt` -- `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt` - -## Risks And Mitigations - -- Risk: dump strings become too noisy. - - Mitigation: keep transcript first, visibility separate, and status compressed into one final line. -- Risk: readiness could become inconsistent if still stored separately. - - Mitigation: remove `ready` and derive readiness from app/workspace statuses only. -- Risk: UI could read app/workspace state off-EDT. - - Mitigation: apply app/workspace model mutations inside the EDT fire path before notifying listeners. -- Risk: tests become brittle if wording changes. - - Mitigation: keep labels stable, lowercase normalized state words, and compact bracket syntax. -- Risk: permission/question assertions lose coverage of important fields. - - Mitigation: include the important fields directly in the `toString()` format so the snapshot still covers them. - -## Recommended Final Shape - -1. Rename the base to `SessionControllerTestBase`. -2. Remove stored `SessionModel.ready` and add computed `isReady()`. -3. Update `SessionController.fire()` to support a `before` closure executed on the EDT before listener notification. -4. Move app/workspace model mutation into that EDT fire path. -5. Add transcript-style `toString()` implementations. -6. Place permission/question blocks after the messages, matching the real UI order. -7. Replace field-by-field permission/question assertions with `toString()` snapshot assertions. -8. Keep visibility checks out of the dump and assert them separately. -9. Convert controller tests to use the new transcript + one-line-status format. - -## Text Preview For The User - -Busy tool run: - -```text -user#msg1 -text#p1: - go ---- -assistant#msg2 -reasoning#p2: - Need to inspect files first -tool#p3 bash [RUNNING] ls -text#p4: - I found the issue - -[code] [kilo/gpt-5] [busy] [running commands] -``` - -Awaiting question: - -```text -user#msg1 -text#p1: - go ---- -question#q1 -tool: msg1/call1 -header: Choice -prompt: Pick one -option: A - Option A -multiple: false -custom: true - -[code] [kilo/gpt-5] [awaiting-question] -``` - -Awaiting permission: - -```text -user#msg1 -text#p1: - go ---- -permission#perm1 -tool: msg1/call1 -name: edit -patterns: *.kt -always: src/** -file: src/A.kt -state: RESPONDING -metadata: kind=edit - -[code] [kilo/gpt-5] [awaiting-permission] -``` - -Not ready: - -```text -[code] [kilo/gpt-5] [app: READY] [workspace: PENDING] -``` From f970d34278d08f45c6bdd0fbbafdb7387db4b4cd Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 20 Apr 2026 14:56:40 +0300 Subject: [PATCH 22/27] fix(vscode,cli): make plan_exit "Continue here" work again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore custom: false on the plan follow-up question — the "Type your own answer" row was redundant because the main prompt already routes typed text as a question reply. Auto-submit single-question single-select option picks in the VS Code QuestionDock so the button behaves like the TUI instead of silently waiting for a second Submit click. --- .changeset/plan-followup-continue-click.md | 5 ++++ .../tests/unit/question-dock-utils.test.ts | 23 ++++++++++++++ .../src/components/chat/QuestionDock.tsx | 16 ++++++++-- .../components/chat/question-dock-utils.ts | 16 ++++++++++ .../opencode/src/kilocode/plan-followup.ts | 6 +++- .../test/kilocode/plan-followup.test.ts | 30 +++++++++++++++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .changeset/plan-followup-continue-click.md diff --git a/.changeset/plan-followup-continue-click.md b/.changeset/plan-followup-continue-click.md new file mode 100644 index 00000000000..b3a6be4d396 --- /dev/null +++ b/.changeset/plan-followup-continue-click.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the "Continue here" button not submitting after a plan is finished. Picking an option on a single-question prompt now sends the reply immediately — matching the CLI behaviour — and the redundant "Type your own answer" row no longer appears on the plan follow-up question. diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts index 2048e2b5e39..46d61385c2b 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test" import { + pickOutcome, resolveOptimisticQuestionAgent, resolveQuestionMode, resolveSelectedQuestionMode, @@ -128,3 +129,25 @@ describe("resolveOptimisticQuestionAgent", () => { expect(result).toEqual({ base: "ask", agent: "architect" }) }) }) + +describe("pickOutcome", () => { + it("submits immediately on a single-question single-select option pick", () => { + expect(pickOutcome({ single: true, multi: false, custom: false })).toEqual({ kind: "submit" }) + }) + + it("advances to the next tab on a multi-question single-select option pick", () => { + expect(pickOutcome({ single: false, multi: false, custom: false })).toEqual({ kind: "advance" }) + }) + + it("stays on the current tab for a multi-select pick", () => { + expect(pickOutcome({ single: true, multi: true, custom: false })).toEqual({ kind: "stay" }) + }) + + it("defers submission for a single-select custom-input pick (handleCustomSubmit owns the submit)", () => { + expect(pickOutcome({ single: true, multi: false, custom: true })).toEqual({ kind: "advance" }) + }) + + it("stays on the current tab for a multi-select custom-input pick", () => { + expect(pickOutcome({ single: false, multi: true, custom: true })).toEqual({ kind: "stay" }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 36c21303b1f..41d747bc365 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -12,7 +12,12 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import type { QuestionRequest } from "../../types/messages" -import { resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, toggleAnswer } from "./question-dock-utils" +import { + pickOutcome, + resolveOptimisticQuestionAgent, + resolveSelectedQuestionMode, + toggleAnswer, +} from "./question-dock-utils" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const session = useSession() @@ -119,7 +124,14 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => syncAgent(answers, kinds) - if (!single() && !multi()) { + const outcome = pickOutcome({ single: single(), multi: multi(), custom }) + if (outcome.kind === "submit") { + // Mirror TUI behaviour: a single-question single-select option pick submits immediately. + // handleCustomSubmit covers the custom-input path via its own submit() call. + reply([[answer]]) + return + } + if (outcome.kind === "advance") { setStore("tab", store.tab + 1) } } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index 9911fece1f0..ff518b14d21 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -1,5 +1,21 @@ import type { QuestionOption } from "../../types/messages" +export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "stay" } + +/** + * Decide what should happen after a user picks an option in the question dock. + * + * - Multi-select prompts: the pick only toggles local state; no tab change, no submit. + * - Single-question single-select, option pick: submit immediately (matches the TUI). + * - Multi-question single-select, option pick: advance to the next tab. + * - Custom-input path for a single-select is handled separately in handleCustomSubmit. + */ +export function pickOutcome(input: { single: boolean; multi: boolean; custom: boolean }): PickOutcome { + if (input.multi) return { kind: "stay" } + if (input.single && !input.custom) return { kind: "submit" } + return { kind: "advance" } +} + export function toggleAnswer(existing: string[], answer: string): string[] { const next = [...existing] const index = next.indexOf(answer) diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 32f69e5d0e9..b3098ac3bd7 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -264,7 +264,11 @@ export namespace PlanFollowup { { question: "Ready to implement?", header: "Implement", - custom: true, + // Keep false: the main prompt input already routes typed text as a question reply, + // so "Type your own answer" would be redundant. This was set to false intentionally + // in 65566af7f8 and got flipped back to true during the v1.4.4 upstream merge — + // do not change without updating that history. + custom: false, options: [ { label: ANSWER_NEW_SESSION, diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 779c7c6b0b3..342ab5dc6b3 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -266,6 +266,36 @@ describe("plan follow-up", () => { await expect(pending).resolves.toBe("break") })) + test("ask - emits a non-custom single-select question with the canonical answers", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + const q = item.questions[0] + expect(q).toBeDefined() + if (!q) return + + // custom must stay false — "Type your own answer" is redundant because the main prompt + // input already routes typed text as a question reply. Regressed once during the v1.4.4 + // upstream merge, so pin it here. + expect(q.custom).toBe(false) + expect(q.multiple).not.toBe(true) + expect(q.options.map((item) => item.label)).toEqual([ + PlanFollowup.ANSWER_NEW_SESSION, + PlanFollowup.ANSWER_CONTINUE, + ]) + + await question.reject(item.id) + await expect(pending).resolves.toBe("break") + })) + test("ask - returns continue and creates code message on Continue here", () => withInstance(async () => { const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { From c529142d7273092f657f02c66f39f06e9ed55ecc Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 20 Apr 2026 16:15:06 +0300 Subject: [PATCH 23/27] feat(i18n): translate plan_exit follow-up options in the VS Code sidebar Add optional `labelKey`/`descriptionKey` to Question.Option and `questionKey`/`headerKey` to Question.Info, annotated with `kilocode_change` markers. Populate these keys from the plan follow-up so the "Ready to implement?" question, "Start new session", and "Continue here" buttons render in the sidebar language while the canonical English labels remain on the reply wire (unchanged server-side matching). --- .changeset/plan-followup-translations.md | 5 ++ packages/kilo-i18n/src/ar.ts | 8 +++ packages/kilo-i18n/src/br.ts | 8 +++ packages/kilo-i18n/src/bs.ts | 8 +++ packages/kilo-i18n/src/da.ts | 8 +++ packages/kilo-i18n/src/de.ts | 8 +++ packages/kilo-i18n/src/en.ts | 10 +++ packages/kilo-i18n/src/es.ts | 8 +++ packages/kilo-i18n/src/fr.ts | 8 +++ packages/kilo-i18n/src/ja.ts | 8 +++ packages/kilo-i18n/src/ko.ts | 8 +++ packages/kilo-i18n/src/nl.ts | 8 +++ packages/kilo-i18n/src/no.ts | 8 +++ packages/kilo-i18n/src/pl.ts | 8 +++ packages/kilo-i18n/src/ru.ts | 8 +++ packages/kilo-i18n/src/th.ts | 8 +++ packages/kilo-i18n/src/tr.ts | 8 +++ packages/kilo-i18n/src/uk.ts | 8 +++ packages/kilo-i18n/src/zh.ts | 8 +++ packages/kilo-i18n/src/zht.ts | 8 +++ .../unit/plan-followup-locale-keys.test.ts | 64 +++++++++++++++++++ .../tests/unit/question-dock-utils.test.ts | 30 +++++++++ .../src/components/chat/QuestionDock.tsx | 27 ++++++-- .../components/chat/question-dock-utils.ts | 12 ++++ .../webview-ui/src/types/messages.ts | 7 ++ .../opencode/src/kilocode/plan-followup.ts | 6 ++ packages/opencode/src/question/index.ts | 17 +++++ .../test/kilocode/plan-followup.test.ts | 38 +++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 16 +++++ packages/sdk/openapi.json | 16 +++++ 30 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 .changeset/plan-followup-translations.md create mode 100644 packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts diff --git a/.changeset/plan-followup-translations.md b/.changeset/plan-followup-translations.md new file mode 100644 index 00000000000..922a75c83f9 --- /dev/null +++ b/.changeset/plan-followup-translations.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Translate the follow-up prompt shown after finishing a plan. The "Ready to implement?" question, "Start new session" and "Continue here" buttons, and their descriptions now respect the language configured in the VS Code sidebar. diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index ee2a0ffdaf2..3a6374008bc 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "إلغاء", "marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟", "marketplace.contribute.cta": "ساهم على GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "نفّذ", + "plan.followup.question": "جاهز للتنفيذ؟", + "plan.followup.answer.newSession": "بدء جلسة جديدة", + "plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف", + "plan.followup.answer.continue": "المتابعة هنا", + "plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 38bfb6ae825..5a9db6c39d0 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?", "marketplace.contribute.cta": "Contribuir no GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "Pronto para implementar?", + "plan.followup.answer.newSession": "Iniciar nova sessão", + "plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo", + "plan.followup.answer.continue": "Continuar aqui", + "plan.followup.answer.continue.description": "Implementar o plano nesta sessão", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 63aad6b807e..1b88c607dbc 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -76,4 +76,12 @@ export const dict = { "marketplace.warning.cancel": "Otkaži", "marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?", "marketplace.contribute.cta": "Doprinesi na GitHub-u", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementiraj", + "plan.followup.question": "Spreman za implementaciju?", + "plan.followup.answer.newSession": "Pokreni novu sesiju", + "plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom", + "plan.followup.answer.continue": "Nastavi ovdje", + "plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 53442d3736b..912ecaaad72 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuller", "marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?", "marketplace.contribute.cta": "Bidrag på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementér", + "plan.followup.question": "Klar til at implementere?", + "plan.followup.answer.newSession": "Start ny session", + "plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst", + "plan.followup.answer.continue": "Fortsæt her", + "plan.followup.answer.continue.description": "Implementér planen i denne session", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 190ceff2d67..2e08b98b273 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Abbrechen", "marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?", "marketplace.contribute.cta": "Auf GitHub beitragen", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Umsetzen", + "plan.followup.question": "Bereit zur Umsetzung?", + "plan.followup.answer.newSession": "Neue Sitzung starten", + "plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen", + "plan.followup.answer.continue": "Hier fortfahren", + "plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 2e1f38828c5..cacebb54f97 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -73,4 +73,14 @@ export const dict = { "marketplace.warning.cancel": "Cancel", "marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?", "marketplace.contribute.cta": "Contribute on GitHub", + + // Plan follow-up question shown after plan_exit. The English strings here must match + // the canonical `label`/`header`/`question` sent by the backend — those canonical labels + // are still what the backend matches on (see packages/opencode/src/kilocode/plan-followup.ts). + "plan.followup.header": "Implement", + "plan.followup.question": "Ready to implement?", + "plan.followup.answer.newSession": "Start new session", + "plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context", + "plan.followup.answer.continue": "Continue here", + "plan.followup.answer.continue.description": "Implement the plan in this session", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 62fa5ec0ac2..9325ae29a42 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?", "marketplace.contribute.cta": "Contribuir en GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "¿Listo para implementar?", + "plan.followup.answer.newSession": "Iniciar nueva sesión", + "plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio", + "plan.followup.answer.continue": "Continuar aquí", + "plan.followup.answer.continue.description": "Implementar el plan en esta sesión", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 43e34edc4dd..51b5735b3e2 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuler", "marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?", "marketplace.contribute.cta": "Contribuer sur GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implémenter", + "plan.followup.question": "Prêt à implémenter ?", + "plan.followup.answer.newSession": "Démarrer une nouvelle session", + "plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge", + "plan.followup.answer.continue": "Continuer ici", + "plan.followup.answer.continue.description": "Implémenter le plan dans cette session", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index f75cccfb4ae..bf1ae85ff55 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "キャンセル", "marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?", "marketplace.contribute.cta": "GitHub で貢献する", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "実装", + "plan.followup.question": "実装する準備はできましたか?", + "plan.followup.answer.newSession": "新しいセッションを開始", + "plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する", + "plan.followup.answer.continue": "ここで続行", + "plan.followup.answer.continue.description": "このセッションで計画を実装する", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 23c9fe1740f..f1260654972 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "취소", "marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?", "marketplace.contribute.cta": "GitHub에서 기여하기", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "구현", + "plan.followup.question": "구현할 준비가 되셨나요?", + "plan.followup.answer.newSession": "새 세션 시작", + "plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현", + "plan.followup.answer.continue": "여기서 계속하기", + "plan.followup.answer.continue.description": "이 세션에서 계획 구현", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 00072f19847..20e2dd6d1c9 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -73,4 +73,12 @@ export const dict = { "marketplace.warning.cancel": "Annuleren", "marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?", "marketplace.contribute.cta": "Bijdragen op GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementeren", + "plan.followup.question": "Klaar om te implementeren?", + "plan.followup.answer.newSession": "Nieuwe sessie starten", + "plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context", + "plan.followup.answer.continue": "Hier doorgaan", + "plan.followup.answer.continue.description": "Het plan in deze sessie implementeren", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 85cfce2679e..e1c1ac61bcf 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Avbryt", "marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?", "marketplace.contribute.cta": "Bidra på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementer", + "plan.followup.question": "Klar til å implementere?", + "plan.followup.answer.newSession": "Start ny økt", + "plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst", + "plan.followup.answer.continue": "Fortsett her", + "plan.followup.answer.continue.description": "Implementer planen i denne økten", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 48d436413c3..0593170d9b0 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Anuluj", "marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?", "marketplace.contribute.cta": "Wnieś wkład na GitHubie", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Wdróż", + "plan.followup.question": "Gotowy do wdrożenia?", + "plan.followup.answer.newSession": "Rozpocznij nową sesję", + "plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem", + "plan.followup.answer.continue": "Kontynuuj tutaj", + "plan.followup.answer.continue.description": "Wdróż plan w tej sesji", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 0ac0e661fd8..74acae59272 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Отмена", "marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?", "marketplace.contribute.cta": "Внести вклад на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реализовать", + "plan.followup.question": "Готовы реализовать?", + "plan.followup.answer.newSession": "Начать новую сессию", + "plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом", + "plan.followup.answer.continue": "Продолжить здесь", + "plan.followup.answer.continue.description": "Реализовать план в этой сессии", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index d8a03606c75..48b02146af5 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "ยกเลิก", "marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?", "marketplace.contribute.cta": "ร่วมสมทบบน GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "ดำเนินการ", + "plan.followup.question": "พร้อมดำเนินการหรือยัง?", + "plan.followup.answer.newSession": "เริ่มเซสชันใหม่", + "plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า", + "plan.followup.answer.continue": "ดำเนินการต่อที่นี่", + "plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 0a3a39d3dbd..e307f790772 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "İptal", "marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?", "marketplace.contribute.cta": "GitHub'da katkıda bulun", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Uygula", + "plan.followup.question": "Uygulamaya hazır mısın?", + "plan.followup.answer.newSession": "Yeni oturum başlat", + "plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula", + "plan.followup.answer.continue": "Burada devam et", + "plan.followup.answer.continue.description": "Planı bu oturumda uygula", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index cb3b1090ce0..7d1707a3e51 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Скасувати", "marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?", "marketplace.contribute.cta": "Зробити внесок на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реалізувати", + "plan.followup.question": "Готові реалізувати?", + "plan.followup.answer.newSession": "Почати нову сесію", + "plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом", + "plan.followup.answer.continue": "Продовжити тут", + "plan.followup.answer.continue.description": "Реалізувати план у цій сесії", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index d444f4e8eec..d5cd78e8778 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?", "marketplace.contribute.cta": "在 GitHub 上贡献", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "实现", + "plan.followup.question": "准备好实现了吗?", + "plan.followup.answer.newSession": "开启新会话", + "plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现", + "plan.followup.answer.continue": "在此继续", + "plan.followup.answer.continue.description": "在本会话中实现计划", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 275727428b8..5cdb8dff670 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?", "marketplace.contribute.cta": "在 GitHub 上貢獻", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "實作", + "plan.followup.question": "準備好實作了嗎?", + "plan.followup.answer.newSession": "開啟新工作階段", + "plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作", + "plan.followup.answer.continue": "在此繼續", + "plan.followup.answer.continue.description": "在本工作階段中實作計畫", } diff --git a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts new file mode 100644 index 00000000000..638ce3ae2c4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { dict as ar } from "@kilocode/kilo-i18n/ar" +import { dict as br } from "@kilocode/kilo-i18n/br" +import { dict as bs } from "@kilocode/kilo-i18n/bs" +import { dict as da } from "@kilocode/kilo-i18n/da" +import { dict as de } from "@kilocode/kilo-i18n/de" +import { dict as en } from "@kilocode/kilo-i18n/en" +import { dict as es } from "@kilocode/kilo-i18n/es" +import { dict as fr } from "@kilocode/kilo-i18n/fr" +import { dict as ja } from "@kilocode/kilo-i18n/ja" +import { dict as ko } from "@kilocode/kilo-i18n/ko" +import { dict as nl } from "@kilocode/kilo-i18n/nl" +import { dict as no } from "@kilocode/kilo-i18n/no" +import { dict as pl } from "@kilocode/kilo-i18n/pl" +import { dict as ru } from "@kilocode/kilo-i18n/ru" +import { dict as th } from "@kilocode/kilo-i18n/th" +import { dict as tr } from "@kilocode/kilo-i18n/tr" +import { dict as uk } from "@kilocode/kilo-i18n/uk" +import { dict as zh } from "@kilocode/kilo-i18n/zh" +import { dict as zht } from "@kilocode/kilo-i18n/zht" + +const dicts: Record> = { + ar, + br, + bs, + da, + de, + en, + es, + fr, + ja, + ko, + nl, + no, + pl, + ru, + th, + tr, + uk, + zh, + zht, +} + +const keys = [ + "plan.followup.header", + "plan.followup.question", + "plan.followup.answer.newSession", + "plan.followup.answer.newSession.description", + "plan.followup.answer.continue", + "plan.followup.answer.continue.description", +] + +describe("plan follow-up i18n keys", () => { + for (const locale of Object.keys(dicts)) { + test(`${locale} defines every plan.followup.* key`, () => { + const d = dicts[locale]! + for (const key of keys) { + const value = d[key] + expect(value, `${locale} is missing ${key}`).toBeDefined() + expect(value, `${locale} has empty value for ${key}`).toBeTruthy() + } + }) + } +}) diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts index 46d61385c2b..f8b0171892b 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -5,6 +5,7 @@ import { resolveQuestionMode, resolveSelectedQuestionMode, toggleAnswer, + tr, } from "../../webview-ui/src/components/chat/question-dock-utils" describe("toggleAnswer", () => { @@ -151,3 +152,32 @@ describe("pickOutcome", () => { expect(pickOutcome({ single: false, multi: true, custom: true })).toEqual({ kind: "stay" }) }) }) + +describe("tr", () => { + it("returns the translated value when the key is present", () => { + const dict: Record = { "plan.followup.answer.continue": "Continuer ici" } + const t = (key: string) => dict[key] ?? key + expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continuer ici") + }) + + it("returns the fallback when key is undefined", () => { + const t = (key: string) => key + expect(tr(t, undefined, "Continue here")).toBe("Continue here") + }) + + it("returns the fallback when the key is missing from the dict (language.t echoes the key)", () => { + const t = (key: string) => key + expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continue here") + }) + + it("returns an empty string fallback when no key and no label are available", () => { + const t = (key: string) => key + expect(tr(t, undefined, "")).toBe("") + }) + + it("returns the translated value even when the fallback is empty", () => { + const dict: Record = { "plan.followup.question": "Prêt à implémenter ?" } + const t = (key: string) => dict[key] ?? key + expect(tr(t, "plan.followup.question", "")).toBe("Prêt à implémenter ?") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 41d747bc365..189e4773de7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -17,6 +17,7 @@ import { resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, toggleAnswer, + tr, } from "./question-dock-utils" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { @@ -69,6 +70,19 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => return language.t("question.summary", { n, total: total() }) }) + // Localized view of the current question. The wire-format `label` is preserved for reply + // matching; only the display text goes through `tr()`. + const questionText = createMemo(() => tr(language.t, question()?.questionKey, question()?.question ?? "")) + const translateOption = (opt: { + label: string + description?: string + labelKey?: string + descriptionKey?: string + }) => ({ + label: tr(language.t, opt.labelKey, opt.label), + description: opt.description ? tr(language.t, opt.descriptionKey, opt.description) : "", + }) + const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) const reply = (answers: string[][]) => { @@ -282,7 +296,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
                        {summary()}
                        -
                        {question()?.question}
                        +
                        {questionText()}
                        e.stopPropagation()}> @@ -325,7 +339,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
                        -
                        {question()?.question}
                        +
                        {questionText()}
                        {language.t("ui.question.singleHint")}
                        }>
                        {language.t("ui.question.multiHint")}
                        @@ -333,6 +347,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {(opt, i) => { const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + const localized = translateOption(opt) return ( @@ -436,7 +451,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const answered = () => Boolean(value()) return (
                        - {q.question} + {tr(language.t, q.questionKey, q.question)} {answered() ? value() : language.t("ui.question.review.notAnswered")} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index ff518b14d21..cb4f482c0eb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -1,5 +1,17 @@ import type { QuestionOption } from "../../types/messages" +/** + * Translate a backend-provided i18n key, falling back to the canonical label when no key is + * set or the key is missing from the dictionary. Our `language.t` returns the key string back + * when a translation isn't found, so we detect that and substitute the fallback. + */ +export function tr(translate: (key: string) => string, key: string | undefined, fallback: string): string { + if (!key) return fallback + const result = translate(key) + if (result === key) return fallback + return result +} + export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "stay" } /** diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 3b82ebc252e..c5f6e15cf7b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -206,6 +206,10 @@ export interface QuestionOption { label: string description: string mode?: string + // Optional i18n keys — the backend fills these for strings it wants translated in the webview. + // The canonical English `label` stays on the reply wire, so server-side matching is unaffected. + labelKey?: string + descriptionKey?: string } export interface QuestionInfo { @@ -214,6 +218,9 @@ export interface QuestionInfo { options: QuestionOption[] multiple?: boolean custom?: boolean + // Optional i18n keys for question text and header (see QuestionOption for details). + questionKey?: string + headerKey?: string } export interface QuestionRequest { diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index b3098ac3bd7..75fdf4e2e11 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -263,7 +263,9 @@ export namespace PlanFollowup { questions: [ { question: "Ready to implement?", + questionKey: "plan.followup.question", header: "Implement", + headerKey: "plan.followup.header", // Keep false: the main prompt input already routes typed text as a question reply, // so "Type your own answer" would be redundant. This was set to false intentionally // in 65566af7f8 and got flipped back to true during the v1.4.4 upstream merge — @@ -272,11 +274,15 @@ export namespace PlanFollowup { options: [ { label: ANSWER_NEW_SESSION, + labelKey: "plan.followup.answer.newSession", description: "Implement in a fresh session with a clean context", + descriptionKey: "plan.followup.answer.newSession.description", }, { label: ANSWER_CONTINUE, + labelKey: "plan.followup.answer.continue", description: "Implement the plan in this session", + descriptionKey: "plan.followup.answer.continue.description", }, ], }, diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 7bf93ce851e..6a1e5e246fe 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -21,6 +21,15 @@ export namespace Question { description: Schema.String.annotate({ description: "Explanation of choice", }), + // kilocode_change start - optional i18n keys so clients can translate while still + // replying with the canonical English label (backend matches on `label`). + labelKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the label; clients translate and still reply with `label`", + }), + descriptionKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the description", + }), + // kilocode_change end }) { static readonly zod = zod(this) } @@ -38,6 +47,14 @@ export namespace Question { multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices", }), + // kilocode_change start - optional i18n keys for question text and header + questionKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the question text; clients fall back to `question` when missing", + }), + headerKey: Schema.optional(Schema.String).annotate({ + description: "Optional i18n key for the header; clients fall back to `header` when missing", + }), + // kilocode_change end } export class Info extends Schema.Class("QuestionInfo")({ diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 342ab5dc6b3..940e72dcffd 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -296,6 +296,44 @@ describe("plan follow-up", () => { await expect(pending).resolves.toBe("break") })) + test("ask - emits i18n keys alongside the canonical English labels", () => + withInstance(async () => { + const seeded = await seed({ text: "1. Build" }) + const pending = PlanFollowup.ask({ + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + const q = item.questions[0] + expect(q).toBeDefined() + if (!q) return + + // i18n keys for question-level strings + expect(q.questionKey).toBe("plan.followup.question") + expect(q.headerKey).toBe("plan.followup.header") + + // i18n keys for option labels — order matters: newSession is first, continue second. + expect(q.options.map((o) => o.labelKey)).toEqual([ + "plan.followup.answer.newSession", + "plan.followup.answer.continue", + ]) + expect(q.options.map((o) => o.descriptionKey)).toEqual([ + "plan.followup.answer.newSession.description", + "plan.followup.answer.continue.description", + ]) + + // Canonical English labels stay on the wire — the server still matches on `label`, + // so translating the UI must not change the reply format. + expect(q.options.map((o) => o.label)).toEqual([PlanFollowup.ANSWER_NEW_SESSION, PlanFollowup.ANSWER_CONTINUE]) + + await question.reject(item.id) + await expect(pending).resolves.toBe("break") + })) + test("ask - returns continue and creates code message on Continue here", () => withInstance(async () => { const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 83824960942..9f167561cf8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -369,6 +369,14 @@ export type QuestionOption = { * Explanation of choice */ description: string + /** + * Optional i18n key for the label; clients translate and still reply with `label` + */ + labelKey?: string + /** + * Optional i18n key for the description + */ + descriptionKey?: string } export type QuestionInfo = { @@ -388,6 +396,14 @@ export type QuestionInfo = { * Allow selecting multiple choices */ multiple?: boolean + /** + * Optional i18n key for the question text; clients fall back to `question` when missing + */ + questionKey?: string + /** + * Optional i18n key for the header; clients fall back to `header` when missing + */ + headerKey?: string /** * Allow typing a custom answer (default: true) */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 4fe9ec6bac3..9de377e999a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11429,6 +11429,14 @@ "description": { "description": "Explanation of choice", "type": "string" + }, + "labelKey": { + "description": "Optional i18n key for the label; clients translate and still reply with `label`", + "type": "string" + }, + "descriptionKey": { + "description": "Optional i18n key for the description", + "type": "string" } }, "required": ["label", "description"] @@ -11455,6 +11463,14 @@ "description": "Allow selecting multiple choices", "type": "boolean" }, + "questionKey": { + "description": "Optional i18n key for the question text; clients fall back to `question` when missing", + "type": "string" + }, + "headerKey": { + "description": "Optional i18n key for the header; clients fall back to `header` when missing", + "type": "string" + }, "custom": { "description": "Allow typing a custom answer (default: true)", "type": "boolean" From 0643519541368ae7a87210e5be9d0c03ecff0b1e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 20 Apr 2026 16:23:24 +0300 Subject: [PATCH 24/27] fix(vscode): keep plan follow-up options reactive to language changes Return accessors from translateOption so SolidJS tracks the language signal through . Without this, a mid-dialog language switch leaves option labels frozen in the previous locale until the dock remounts. Also add inline notes explaining the wider auto-submit behaviour change in pickOutcome (covers every single-question single-select caller, not just the plan follow-up) and the tr() helper's coupling to language.t's miss-key echo contract so a future refactor of language.t notices the dependency. --- .../src/components/chat/QuestionDock.tsx | 21 ++++++++++++++----- .../components/chat/question-dock-utils.ts | 12 +++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 189e4773de7..9f53a2b628d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -72,6 +72,12 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => // Localized view of the current question. The wire-format `label` is preserved for reply // matching; only the display text goes through `tr()`. + // + // translateOption returns accessor functions, not plain strings. SolidJS runs the + // child callback in an untracked scope (via mapArray), so reading `language.t(...)` once + // at construction would freeze translations at the first render. Accessors force every + // JSX read to happen inside the binding's tracking scope, so switching the sidebar + // language while a question dock is visible re-renders the option labels. const questionText = createMemo(() => tr(language.t, question()?.questionKey, question()?.question ?? "")) const translateOption = (opt: { label: string @@ -79,8 +85,8 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => labelKey?: string descriptionKey?: string }) => ({ - label: tr(language.t, opt.labelKey, opt.label), - description: opt.description ? tr(language.t, opt.descriptionKey, opt.description) : "", + label: () => tr(language.t, opt.labelKey, opt.label), + description: () => (opt.description ? tr(language.t, opt.descriptionKey, opt.description) : ""), }) const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) @@ -142,6 +148,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => if (outcome.kind === "submit") { // Mirror TUI behaviour: a single-question single-select option pick submits immediately. // handleCustomSubmit covers the custom-input path via its own submit() call. + // + // NOTE: This auto-submit applies to every single-question single-select prompt, not only + // the plan follow-up. If a future caller needs the user to review before submitting, set + // `multiple: true` on the question (that path stays on the current tab and waits for the + // footer Submit button). reply([[answer]]) return } @@ -367,9 +378,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => - {localized.label} - - {localized.description} + {localized.label()} + + {localized.description()} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index cb4f482c0eb..51f1116cbe4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -2,8 +2,16 @@ import type { QuestionOption } from "../../types/messages" /** * Translate a backend-provided i18n key, falling back to the canonical label when no key is - * set or the key is missing from the dictionary. Our `language.t` returns the key string back - * when a translation isn't found, so we detect that and substitute the fallback. + * set or the key is missing from the dictionary. + * + * Implementation detail: our `language.t` (see webview-ui/src/context/language.tsx) returns + * the key string back when a translation is missing. We detect that "echo" and substitute + * the fallback. If `language.t` ever changes to return `undefined` / empty / throw on miss, + * this helper's fallback branch must be updated to match. + * + * Edge case: if a translator ever writes a value identical to the dotted key string, this + * helper would falsely fall back to the English label. Namespaced keys (`plan.followup.*`) + * make this practically impossible, but keep it in mind before picking ambiguous key names. */ export function tr(translate: (key: string) => string, key: string | undefined, fallback: string): string { if (!key) return fallback From 98f5ebb6e7e517577cd67a64add488133a7baaac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 13:43:11 +0000 Subject: [PATCH 25/27] chore: update kilo-vscode visual regression baselines --- ...essage-list-tool-to-queued-user-spacing-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index 0ccf5483f0a..f1ada1dd34e 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55616e4acb64a04ef5f5c37707f6123561065c22a2266197b031d47c068949a4 -size 14255 +oid sha256:579c1817866f19735c86ec00d4d74bf15149f37f8ce27cfe7873bdf79f1d103f +size 12066 From 8e0adfaf1392b455916c92db60e667b923efb7bb Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 20 Apr 2026 13:27:05 -0400 Subject: [PATCH 26/27] docs(jetbrains): document session component architecture and testing conventions in AGENTS.md --- packages/kilo-jetbrains/AGENTS.md | 129 ++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 046b7512323..d11def623b8 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -74,6 +74,135 @@ - Service classes ↔ ``/`` entries in the corresponding module XML - `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list +## Session Component + +The chat session feature uses a three-layer Model / Controller / View architecture. All files live under +`frontend/src/main/kotlin/ai/kilocode/client/session/`. + +### Layers + +**`SessionModel`** (`model/SessionModel.kt`) + +- Single source of truth for session content and runtime state. +- **EDT-only access** — no synchronisation. `SessionController` guarantees all reads and writes happen on the EDT. +- State is mutated only through dedicated methods (`setState`, `upsertMessage`, `setDiff`, etc.), never via direct field assignment from outside the model. +- Every mutation fires a sealed `SessionModelEvent` that carries the data needed for rendering — UI never needs to read back from the model after receiving an event. +- Each event overrides `toString()` with a compact, stable label (e.g. `"MessageAdded msg1"`, `"DiffUpdated files=2"`). Tests assert events by comparing joined `toString()` output. +- `loadHistory()` and `clear()` reset all state fields — diff, todos, compactionCount, messages, and `SessionState.Idle`. Call them when opening or clearing a session. + +**`SessionController`** (`SessionController.kt`) + +- Owns one `SessionModel`. UIs read from `model` and subscribe to `SessionModelEvent` via `model.addListener()`. +- Accepts an optional `id` at construction. + - `id = null` → lazily creates a new session on the first `prompt()` call. This guarantees events are subscribed before the prompt is sent, eliminating race conditions. + - `id != null` → immediately loads history and subscribes to SSE events on construction. +- After history load, `recoverPending()` seeds state in this priority order: (1) pending permission, (2) pending question, (3) current session status (`busy`/`retry`/`offline` from `KiloSessionService.statuses`), (4) `Idle`. +- All SSE events are filtered by `sessionID` before being handled. `session.error` events with `null` sessionID are treated as global and pass through. +- Publishes coarser lifecycle updates (app/workspace changes, view switching) via `SessionControllerEvent` to registered listeners — keep these separate from the fine-grained `SessionModelEvent` stream. + +**View** (UI classes under `ui/`) + +- Listens to `SessionModelEvent` via `model.addListener(parent) { event -> when(event) { ... } }`. +- The `when` block must be exhaustive — add `-> Unit` branches for events the view intentionally ignores so new events surface as compile errors. +- Views call `SessionController` actions (`prompt()`, `replyPermission()`, etc.) on the EDT; the controller dispatches RPC calls to a coroutine scope. +- Views must never access RPC or services directly — everything goes through the controller. + +### Adding a New Event + +1. Add a subclass to `SessionModelEvent` with a stable `toString()`. +2. Add the corresponding state field and mutation method to `SessionModel`. Reset the field in both `loadHistory()` and `clear()`. +3. Handle the new `ChatEventDto` in `SessionController.handle()` by calling the model mutation method. +4. Add `-> Unit` stubs for the new event in any existing exhaustive `when` blocks in view code. + +### Testing + +Controller tests extend `SessionControllerTestBase` (`test/…/session/SessionControllerTestBase.kt`), +which provides a real IntelliJ Application and EDT via `BasePlatformTestCase`, real frontend services +wired to `FakeSessionRpcApi`, and a set of shared helpers. + +**Two setups:** + +| Setup | When to use | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. | +| `controller("ses_test")` + manual `appRpc`/`projectRpc` setup + `flush()` | Existing-session flow — opens a specific session, triggers history load and `recoverPending()`. `model.showMessages` is `false`. Use for recovery and history tests. Pass `show = false` to `assertSession`. | + +**Core assertion helpers:** + +```kotlin +// Full controller state — includes model transcript + status line. +// show=true is the default; pass show=false for existing-session tests. +assertSession(""" + assistant#msg1 + text#prt1: + hello + + [code] [kilo/gpt-5] [idle] +""", m) + +// Just the model transcript (no status line) +assertModel("diff: src/A.kt src/B.kt", m) + +// Model event stream — one event per line via event.toString() +assertModelEvents(""" + MessageAdded msg1 + ContentAdded msg1/prt1 +""", modelEvents) + +// Controller lifecycle events +assertControllerEvents("WorkspaceReady", events) +``` + +**Emitting events and flushing:** + +```kotlin +emit(ChatEventDto.TurnOpen("ses_test")) // emits + flushes by default +emit(ChatEventDto.PartDelta(…), flush = false) // batch without intermediate flush +flush() // settle coroutines + drain EDT +``` + +**`FakeSessionRpcApi` configurable state:** + +| Field | Purpose | +| ------------------------------------------------------------------ | ------------------------------------------------------ | +| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive | +| `rpc.statuses` (`MutableStateFlow>`) | Seed the status map read during `recoverPending()` | +| `rpc.history` | Messages returned by `messages()` (history load) | +| `rpc.pendingPermissionList` | Permissions returned during recovery | +| `rpc.pendingQuestionList` | Questions returned during recovery | +| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects | + +**String format of `model.toString()`** (used by `assertModel` / `assertSession`): + +``` +role#msgId +text#partId: + line one + line two +--- +tool#partId toolName [STATE] optional title +--- +question#id +tool: msgId/callId +header: … +prompt: … +option: label - description +multiple: false +custom: true +--- +diff: file1 file2 +--- +todo: [status] content +--- +compacted: N +``` + +Sections are separated by `---`. Only non-empty sections appear. The status line appended by `SessionController.toString()` is: + +``` +[agentName] [provider/modelId] [idle|busy|retry|offline|error|awaiting-question|awaiting-permission] [optional detail] +``` + ## UI Design Guidelines Official references: From e6310c5292b43745c3c6e75a08bb584f7f1fd6d5 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:19:42 +0000 Subject: [PATCH 27/27] fix(cli): add alibaba to kiloProviderOptions thinking translation --- .changeset/alibaba-provider-options.md | 5 +++++ packages/opencode/src/kilocode/provider-options.ts | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 .changeset/alibaba-provider-options.md diff --git a/.changeset/alibaba-provider-options.md b/.changeset/alibaba-provider-options.md new file mode 100644 index 00000000000..5b46c9e003a --- /dev/null +++ b/.changeset/alibaba-provider-options.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add Alibaba to `kiloProviderOptions` so thinking is enabled correctly when routing through the Kilo gateway with `ai_sdk_provider: "alibaba"`. diff --git a/packages/opencode/src/kilocode/provider-options.ts b/packages/opencode/src/kilocode/provider-options.ts index 697e5b1f50a..4d91cb0f78e 100644 --- a/packages/opencode/src/kilocode/provider-options.ts +++ b/packages/opencode/src/kilocode/provider-options.ts @@ -1,3 +1,4 @@ +import type { AlibabaProviderOptions } from "@ai-sdk/alibaba" import type { AnthropicProviderOptions } from "@ai-sdk/anthropic" import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai" import type { OpenAICompatibleProviderOptions } from "@ai-sdk/openai-compatible" @@ -25,5 +26,8 @@ export function kiloProviderOptions(options: { [x: string]: any }) { openrouter.reasoning && "effort" in openrouter.reasoning ? openrouter.reasoning?.effort : undefined, textVerbosity: openrouter.verbosity, } satisfies OpenAICompatibleProviderOptions + result.alibaba = { + enableThinking: openrouter.reasoning?.enabled, + } satisfies AlibabaProviderOptions return result }