diff --git a/.changeset/jetbrains-session-errors.md b/.changeset/jetbrains-session-errors.md new file mode 100644 index 00000000000..380bb67579a --- /dev/null +++ b/.changeset/jetbrains-session-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Show provider errors and interrupted or failed turns in the JetBrains chat instead of returning silently to idle. 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 8f28b8ead37..12521c05ce4 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 @@ -50,6 +50,7 @@ import ai.kilocode.client.session.controller.SessionControllerEvent import ai.kilocode.client.session.context.EditorContextGatherer import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView +import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.KiloSettingsConfigurable @@ -188,6 +189,7 @@ class SessionUi( private lateinit var question: QuestionView private lateinit var permission: PermissionView private lateinit var login: LoginRequiredView + private lateinit var outcome: SessionOutcomeView private lateinit var connection: ConnectionPanel private lateinit var prompt: PromptPanel @@ -260,7 +262,8 @@ class SessionUi( is SessionState.Reverting, is SessionState.Retry, is SessionState.Offline, - is SessionState.Error -> null + is SessionState.Error, + is SessionState.TurnEnded -> null is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION is SessionState.AwaitingQuestion -> @@ -360,6 +363,10 @@ class SessionUi( selection = selection, focus = focus, ) + outcome = SessionOutcomeView( + selection = selection, + focus = focus, + ) messageBody = SessionMessageListPanel( controller.model, this, @@ -377,6 +384,7 @@ class SessionUi( deleteQueued = { id -> controller.deleteQueuedMessage(id) }, banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { + it.outcome = outcome it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } @@ -952,7 +960,7 @@ class SessionUi( if (wasBusy && state is SessionState.Idle) refreshBranchChanges() wasBusy = busy if (state is SessionState.Reverting) overlay.clear() - if (state is SessionState.Error) { + if (state is SessionState.Error || state is SessionState.TurnEnded) { pendingRollback = null pendingRedo = null } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0ae42b857bc..44f73a12f50 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -22,6 +22,9 @@ import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Outcome +import ai.kilocode.client.session.model.OutcomeTone +import ai.kilocode.client.session.model.TurnOutcome import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef import ai.kilocode.client.telemetry.Telemetry @@ -120,6 +123,7 @@ class SessionController( companion object { private val LOG = KiloLog.create(SessionController::class.java) + private const val ABORT_ERROR = "MessageAbortedError" internal const val RECENT_LIMIT = 5 internal const val DISPLAY_DELAY_MS = 1_000L internal const val REVERT_TIMEOUT_MS = 30_000L @@ -1310,6 +1314,8 @@ class SessionController( model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last()))) } else if (status != null) { seedStatus(status) + } else { + seedOutcome() } } } @@ -1342,6 +1348,15 @@ class SessionController( model.setState(state) } + private fun seedOutcome() { + val err = model.messages().lastOrNull { it.info.role == "assistant" }?.info?.error ?: return + if (err.type == ABORT_ERROR) { + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING)) + return + } + model.setState(SessionState.Error(err.message ?: err.type, err.type)) + } + private fun handle(event: ChatEventDto) { LOG.debug { ChatLogSummary.event(event) } when (event) { @@ -1419,20 +1434,25 @@ class SessionController( val current = model.state if (current is SessionState.AwaitingQuestion) return if (current is SessionState.AwaitingPermission) return - val clobberOk = event.reason == "completed" - || current is SessionState.Busy - || current is SessionState.Retry - || current is SessionState.Offline - if (clobberOk) { - if (event.reason == "completed") capture("Task Completed", sessionProps(event.sessionID)) - model.setState(SessionState.Idle) + if (current is SessionState.LoginRequired) return + if (current is SessionState.Error && event.reason != "completed") return + val ended = TurnOutcome.classify(event.reason, current is SessionState.Error) + when { + ended != null -> model.setState(SessionState.TurnEnded(ended.first, ended.second)) + event.reason == "completed" -> { + capture("Task Completed", sessionProps(event.sessionID)) + model.setState(SessionState.Idle) + } + current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline -> model.setState(SessionState.Idle) } } is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) + if (event.error?.type != ABORT_ERROR) { + capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) + } error(event, true) } @@ -1553,6 +1573,7 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } + if (event.error?.type == ABORT_ERROR) return val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") model.setState(SessionState.Error(msg, event.error?.type)) } @@ -1657,7 +1678,11 @@ class SessionController( val state = when (dto.type) { "idle" -> { val current = model.state - if (current is SessionState.LoginRequired || current is SessionState.Reverting) return + if (current is SessionState.Error + || current is SessionState.TurnEnded + || current is SessionState.LoginRequired + || current is SessionState.Reverting + ) return purgePending(sid) // purgePending may promote a still-queued permission from another (unpurged) child // session; mirror idle() and leave that card in place rather than clobbering it with Idle. @@ -1666,7 +1691,7 @@ class SessionController( } "busy" -> { val current = model.state - if (current is SessionState.Idle || current is SessionState.Error) + if (current is SessionState.Idle || current is SessionState.Error || current is SessionState.TurnEnded) SessionState.Busy(KiloBundle.message("session.status.considering")) else return // already in a more specific phase } @@ -1770,6 +1795,7 @@ class SessionController( // 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.TurnEnded && current !is SessionState.AwaitingPermission && current !is SessionState.AwaitingQuestion && current !is SessionState.LoginRequired @@ -2430,6 +2456,7 @@ class SessionController( out.add("[error]") out.add("[${state.message}]") } + is SessionState.TurnEnded -> out.add("[${state.outcome.name.lowercase()}]") is SessionState.LoginRequired -> { out.add("[login-required]") out.add("[${state.message}]") 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 da77b6d7ec3..a55a2253579 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 @@ -22,10 +22,12 @@ sealed class SessionState { data class Error(val message: String, val kind: String? = null) : SessionState() + data class TurnEnded(val outcome: Outcome, val tone: OutcomeTone) : SessionState() + data class LoginRequired(val message: String) : SessionState() fun isBusy(): Boolean = when (this) { - is Idle, is Loading, is Error, is LoginRequired -> false + is Idle, is Loading, is Error, is TurnEnded, is LoginRequired -> false else -> true } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt new file mode 100644 index 00000000000..fe4ad117b51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt @@ -0,0 +1,14 @@ +package ai.kilocode.client.session.model + +enum class Outcome { INTERRUPTED, FAILED } + +enum class OutcomeTone { WARNING, CRITICAL } + +object TurnOutcome { + fun classify(reason: String, provider: Boolean): Pair? = when { + provider -> null + reason == "interrupted" -> Outcome.INTERRUPTED to OutcomeTone.WARNING + reason == "error" -> Outcome.FAILED to OutcomeTone.CRITICAL + else -> null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 23a2bb65196..15d667b6dfd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.MessageView +import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView @@ -91,6 +92,16 @@ class SessionMessageListPanel( var onHover: ((PartView, Boolean) -> Unit)? = null var onReflow: ((Boolean) -> Unit)? = null + var outcome: SessionOutcomeView? = null + set(value) { + if (field === value) return + field?.let { remove(it) } + field = value + value?.applyStyle(style) + syncActive(model.state) + anchorFooter() + refresh() + } /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) @@ -424,25 +435,43 @@ class SessionMessageListPanel( setHiddenQuestionTool(state.question.tool) permission?.hideView() login?.hideView() + outcome?.hideView() question?.show(state.question) } is SessionState.AwaitingPermission -> { setHiddenQuestionTool(null) question?.hideView() login?.hideView() + outcome?.hideView() permission?.show(state.permission) } is SessionState.LoginRequired -> { setHiddenQuestionTool(null) question?.hideView() permission?.hideView() + outcome?.hideView() login?.show(state.message) } + is SessionState.Error -> { + setHiddenQuestionTool(null) + question?.hideView() + permission?.hideView() + login?.hideView() + outcome?.showError(state.message, state.kind) + } + is SessionState.TurnEnded -> { + setHiddenQuestionTool(null) + question?.hideView() + permission?.hideView() + login?.hideView() + outcome?.showOutcome(state.outcome, state.tone) + } else -> { setHiddenQuestionTool(null) question?.hideView() permission?.hideView() login?.hideView() + outcome?.hideView() } } } @@ -494,11 +523,13 @@ class SessionMessageListPanel( if (question != null) remove(question) if (permission != null) remove(permission) if (login != null) remove(login) + if (outcome != null) remove(outcome) if (banner != null) remove(banner) remove(progress) if (question != null) add(question) if (permission != null) add(permission) if (login != null) add(login) + if (outcome != null) add(outcome) if (banner != null) add(banner) add(progress) } @@ -616,6 +647,7 @@ class SessionMessageListPanel( question?.applyStyle(style) permission?.applyStyle(style) login?.applyStyle(style) + outcome?.applyStyle(style) banner?.applyStyle(style) progress.applyStyle(style) reflow() @@ -630,6 +662,7 @@ class SessionMessageListPanel( question?.hideView() permission?.hideView() login?.hideView() + outcome?.hideView() turnViews.values.forEach { remove(it) Disposer.dispose(it) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt new file mode 100644 index 00000000000..d138015ec72 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Outcome +import ai.kilocode.client.session.model.OutcomeTone +import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.base.BaseQuestionView +import com.intellij.icons.AllIcons +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.components.BorderLayoutPanel + +class SessionOutcomeView( + selection: SessionSelection? = null, + focus: (() -> Unit)? = null, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + + override val sessionViewKind = SessionView.Kind.Default + + private val card = BaseQuestionView(selection, focus) + + init { + isOpaque = false + isVisible = false + card.setActions(emptyList()) + addToCenter(card) + } + + @RequiresEdt + fun showError(message: String, kind: String?) { + card.setHeaderIcon(AllIcons.General.Error, kind ?: KiloBundle.message("session.error.title")) + card.setHeader(KiloBundle.message("session.error.title"), message) + isVisible = true + refresh() + } + + @RequiresEdt + fun showOutcome(outcome: Outcome, tone: OutcomeTone) { + val title = when (outcome) { + Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.title") + Outcome.FAILED -> KiloBundle.message("session.outcome.failed.title") + } + val desc = when (outcome) { + Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.description") + Outcome.FAILED -> KiloBundle.message("session.outcome.failed.description") + } + val icon = when (tone) { + OutcomeTone.WARNING -> AllIcons.General.Warning + OutcomeTone.CRITICAL -> AllIcons.General.Error + } + card.setHeaderIcon(icon, title) + card.setHeader(title, desc) + isVisible = true + refresh() + } + + @RequiresEdt + fun hideView() { + if (!isVisible) return + isVisible = false + refresh() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + card.applyStyle(style) + } + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 2c1c665a0ad..f9e2473abbd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -177,7 +177,12 @@ session.part.todo.hidden.later.many={0} later to-dos hidden session.error.prompt=Prompt failed session.error.compact=Session compact failed +session.error.title=Request failed session.error.unknown=Unknown error +session.outcome.failed.description=The model stopped this turn with an error. +session.outcome.failed.title=Response failed +session.outcome.interrupted.description=This turn was interrupted before it finished. +session.outcome.interrupted.title=Response stopped session.login.required.title=You need to sign in to use this model session.login.required.description=Go to User Profile settings to sign in, then continue this session. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index abe72480c28..d349956991e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -82,6 +83,46 @@ class SessionRecoveryTest : SessionControllerTestBase() { assertEquals(SessionState.Idle, m.model.state) } + fun `test provider error is recovered from assistant history`() { + rpc.history.add(MessageWithPartsDto( + msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low")), + emptyList(), + )) + + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + assistant#msg1 + + [code] [kilo/gpt-5] [error] [OpenRouter balance is too low] + """, + m, + ) + } + + fun `test aborted assistant history recovers interrupted outcome`() { + rpc.history.add(MessageWithPartsDto( + msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "MessageAbortedError", message = "aborted")), + emptyList(), + )) + + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + assertSession( + """ + assistant#msg1 + + [code] [kilo/gpt-5] [interrupted] + """, + m, + ) + } + fun `test permission takes priority over question in recovery`() { rpc.pendingPermissionList.add( PermissionRequestDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index ed622f88573..157d2ff5ca9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -1,7 +1,10 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Outcome +import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.model.TurnOutcome import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto @@ -180,6 +183,64 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test TurnClose interrupted shows interrupted outcome`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertSession( + """ + [code] [kilo/gpt-5] [interrupted] + """, + m, + ) + } + + fun `test TurnClose error without provider error shows failed outcome`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "error")) + + assertSession( + """ + [code] [kilo/gpt-5] [failed] + """, + m, + ) + } + + fun `test abort error waits for interrupted outcome`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "MessageAbortedError", message = "aborted"))) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertTrue(appRpc.telemetry.none { it.event == "Session Error" && it.properties["errorClass"] == "MessageAbortedError" }) + assertSession( + """ + [code] [kilo/gpt-5] [interrupted] + """, + m, + ) + } + + fun `test provider error wins over interrupted close`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low"))) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertSession( + """ + [code] [kilo/gpt-5] [error] [OpenRouter balance is too low] + """, + m, + ) + } + fun `test TurnClose completed clobbers Error state`() { val (m, _, _) = prompted() @@ -195,6 +256,30 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test TurnClose completed clobbers outcome state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test turn outcome classifier`() { + assertNull(TurnOutcome.classify("completed", false)) + assertNull(TurnOutcome.classify("superseded", false)) + assertEquals(Outcome.INTERRUPTED to OutcomeTone.WARNING, TurnOutcome.classify("interrupted", false)) + assertEquals(Outcome.FAILED to OutcomeTone.CRITICAL, TurnOutcome.classify("error", false)) + assertNull(TurnOutcome.classify("error", true)) + assertNull(TurnOutcome.classify("interrupted", true)) + } + fun `test TurnClose completed preserves AwaitingQuestion state`() { val (m, _, _) = prompted() @@ -291,6 +376,51 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test SessionStatus idle does not clobber Error state`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + fun `test idle signals do not clobber turn outcome`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + emit(ChatEventDto.SessionIdle("ses_test")) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertSession( + """ + [code] [kilo/gpt-5] [interrupted] + """, + m, + ) + } + + fun `test busy status clears turn outcome`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy"))) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + fun `test paid model auth error enters login required state`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 29795311ab5..242b3a2cdd8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Outcome +import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption @@ -14,6 +16,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView +import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.permission.PermissionView @@ -52,6 +55,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color @@ -1074,6 +1078,47 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertSame(item.progress, item.components.last()) } + fun `test error state makes outcome view visible and hides others`() { + val item = panelWithPrompts() + model.setState(SessionState.Error("OpenRouter balance is too low", "APIError")) + + val ov = find(item)!! + val qv = find(item)!! + val pv = find(item)!! + val lv = find(item)!! + + assertTrue(ov.isVisible) + assertFalse(qv.isVisible) + assertFalse(pv.isVisible) + assertFalse(lv.isVisible) + assertNotNull(text(item, "OpenRouter balance is too low")) + assertSame(item.progress, item.components.last()) + } + + fun `test turn ended state makes outcome view visible`() { + val item = panelWithPrompts() + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING)) + + val ov = find(item)!! + val comps = item.components.toList() + + assertTrue(ov.isVisible) + assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.description"))) + assertTrue(comps.indexOf(ov) < comps.indexOf(item.progress)) + assertSame(item.progress, comps.last()) + } + + fun `test returning to idle hides outcome view`() { + val item = panelWithPrompts() + model.setState(SessionState.TurnEnded(Outcome.FAILED, OutcomeTone.CRITICAL)) + model.setState(SessionState.Idle) + + val ov = find(item)!! + + assertFalse(ov.isVisible) + assertSame(item.progress, item.components.last()) + } + fun `test login required button invokes openProfile callback`() { var called = false val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {}) @@ -1524,11 +1569,14 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { reply = { _, _, _ -> }, ) val l = LoginRequiredView(openProfile = {}, dismiss = {}) - return SessionMessageListPanel(model, parent, q, p, l, openFile) + val o = SessionOutcomeView() + return SessionMessageListPanel(model, parent, q, p, l, openFile).also { it.outcome = o } } private inline fun find(root: Container): T? = findCls(root, T::class.java) + private fun text(root: Container, value: String) = components(root).filterIsInstance().firstOrNull { it.text == value } + private fun findCls(root: Container, cls: Class): T? { if (cls.isInstance(root)) return cls.cast(root) for (child in root.components) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt new file mode 100644 index 00000000000..070fa2e5b26 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt @@ -0,0 +1,103 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Outcome +import ai.kilocode.client.session.model.OutcomeTone +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea +import java.awt.Container + +@Suppress("UnstableApiUsage") +class SessionOutcomeViewTest : BasePlatformTestCase() { + + fun `test view is initially hidden`() { + edt { + val view = SessionOutcomeView() + assertFalse(view.isVisible) + } + } + + fun `test showError renders title and message`() { + edt { + val view = SessionOutcomeView() + view.showError("OpenRouter balance is too low", "APIError") + + assertTrue(view.isVisible) + assertNotNull(findText(view, KiloBundle.message("session.error.title"))) + assertNotNull(findText(view, "OpenRouter balance is too low")) + } + } + + fun `test showOutcome renders interrupted copy and warning icon`() { + edt { + val view = SessionOutcomeView() + view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + + assertTrue(view.isVisible) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.title"))) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) + assertTrue(findAll(view).any { it.icon != null }) + } + } + + fun `test showOutcome updates without stale text`() { + edt { + val view = SessionOutcomeView() + view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + view.showOutcome(Outcome.FAILED, OutcomeTone.CRITICAL) + + assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.title"))) + assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.description"))) + assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) + } + } + + fun `test hideView makes view invisible`() { + edt { + val view = SessionOutcomeView() + view.showError("Request failed", "APIError") + view.hideView() + + assertFalse(view.isVisible) + } + } + + fun `test description uses secondary font not editor font family`() { + edt { + val view = SessionOutcomeView() + view.showError("Provider balance is too low", "APIError") + val style = SessionEditorStyle.create(family = "Courier New", size = 20) + view.applyStyle(style) + + val desc = findText(view, "Provider balance is too low") + assertNotNull(desc) + assertFalse(desc!!.font.name == "Courier New") + assertEquals(SessionUiStyle.Text.Secondary.font(style), desc.font) + } + } + + private fun findText(root: Container, text: String) = findAll(root).firstOrNull { it.text == text } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + if (child is Container) result.addAll(findAllCls(child, cls)) + } + return result + } +}