fix(jetbrains): surface failed turn errors

This commit is contained in:
kirillk
2026-08-27 15:34:46 -04:00
parent e702f2a58a
commit bac3e7a7a8
8 changed files with 424 additions and 6 deletions
@@ -90,6 +90,10 @@ class KiloBackendActivityManager(
// it must not badge the session list, worktree rows, or the Agents tab attention dot.
is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) }
is ChatEventDto.TurnOpen -> errors.remove(event.sessionID)
// Not every failure publishes a session error — a turn whose provider ended the response in
// error writes the failure onto the message and only reports it through this close reason. The
// badge has to come from the close too, or such a session rests as if it finished cleanly.
is ChatEventDto.TurnClose -> if (event.reason == "error") errors.add(event.sessionID)
is ChatEventDto.SessionIdle -> clear(event.sessionID)
is ChatEventDto.SessionStatusChanged -> when (event.status.type) {
"idle" -> clear(event.sessionID)
@@ -115,6 +115,54 @@ class KiloBackendActivityManagerTest {
assertFalse("ses_1" in manager.activity.value)
}
/**
* A provider that ends the response in error writes the failure onto the message and reports it
* only through the close reason, so the badge cannot depend on a session error event.
*/
@Test
fun `turn closing in error badges the session without an error event`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
events.emit(ChatEventDto.TurnClose("ses_1", "error"))
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
events.emit(ChatEventDto.SessionIdle("ses_1"))
val snap = await("ses_1", SessionActivityKindDto.ERROR)
assertEquals("/repo/wt", snap["ses_1"]?.directory)
}
@Test
fun `turn closing without a failure leaves the session unbadged`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
for (reason in listOf("completed", "interrupted", "aborted")) {
events.emit(ChatEventDto.TurnClose("ses_1", reason))
}
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
events.emit(ChatEventDto.SessionIdle("ses_1"))
withTimeout(5_000) { manager.activity.first { "ses_1" !in it } }
assertFalse("ses_1" in manager.activity.value)
}
@Test
fun `a turn closed in error clears once the session is retried`() = runBlocking {
directories["ses_1"] = "/repo/wt"
start()
events.emit(ChatEventDto.TurnClose("ses_1", "error"))
await("ses_1", SessionActivityKindDto.ERROR)
events.emit(ChatEventDto.TurnOpen("ses_1"))
withTimeout(5_000) { manager.activity.first { "ses_1" !in it } }
assertFalse("ses_1" in manager.activity.value)
}
@Test
fun `aborted error does not badge the session`() = runBlocking {
directories["ses_1"] = "/repo/wt"
@@ -18,6 +18,7 @@ import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.failureText
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -164,6 +165,8 @@ class SessionMessageListPanel(
syncReverted()
syncReverting(event.state)
anchorFooter()
val turn = tail()?.let { msgToTurn[it] }
if (turn?.let(::syncFailures) == true) (layout as? SessionLayout)?.forget(turn)
refresh()
}
@@ -189,13 +192,18 @@ class SessionMessageListPanel(
is SessionModelEvent.MessageUpdated -> {
// message.updated fires on every streamed metadata delta (time/tokens/cost). Only
// relayout the transcript when the turn's modified-files card actually changed,
// not on each delta or when this message isn't a turn anchor.
val view = turnViews[event.info.info.id]
// relayout the transcript when something visible changed: the visible failure for
// this turn, or the modified-files card when this message anchors a turn.
val id = event.info.info.id
val turn = msgToTurn[id]
var changed = turn?.let(::syncFailures) == true
if (changed && turn != null) (layout as? SessionLayout)?.forget(turn)
val view = turnViews[id]
if (view?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) {
(layout as? SessionLayout)?.forget(view)
refresh()
changed = true
}
if (changed) refresh()
}
is SessionModelEvent.DiffUpdated -> {
@@ -312,6 +320,7 @@ class SessionMessageListPanel(
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncFailures()
syncReverted()
add(tv)
syncSettled()
@@ -341,6 +350,7 @@ class SessionMessageListPanel(
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncFailures()
syncReverted()
syncSettled()
@@ -352,6 +362,7 @@ class SessionMessageListPanel(
for (msgId in tv.messageIds()) unregister(msgId)
remove(tv)
Disposer.dispose(tv)
syncFailures()
syncSettled()
anchorFooter()
refresh()
@@ -387,6 +398,7 @@ class SessionMessageListPanel(
syncActive(model.state)
syncSettled(model.state)
syncQueued()
syncFailures()
syncReverted()
syncReverting(model.state)
banner?.update()
@@ -395,6 +407,53 @@ class SessionMessageListPanel(
refresh()
}
/** Last message in the transcript, which is the only message the outcome footer can describe. */
private fun tail(): String? = turnViews.values.lastOrNull()?.messageIds()?.lastOrNull()
/** Failure text currently owned by the footer, if it is showing a concrete error. */
private fun presented(): String? {
val state = model.state as? SessionState.Error ?: return null
return state.message.takeIf { it.isNotBlank() }
}
/** Apply failure visibility policy to every turn. */
private fun syncFailures(): Boolean {
var changed = false
for (view in turnViews.values) {
if (syncFailures(view)) {
(layout as? SessionLayout)?.forget(view)
changed = true
}
}
return changed
}
/**
* Shows at most one failure per turn, and lets the footer own the active tail failure when it is
* already displaying the same text with the Retry affordance.
*/
private fun syncFailures(view: TurnView): Boolean {
val ids = view.messageIds()
val last = ids.lastOrNull()
val tail = tail()
val shown = presented()
var changed = false
for (id in ids) {
val msg = msgToView[id] ?: continue
// Only the turn's final attempt speaks for the turn. Retry continues a turn by appending
// another assistant message, so every attempt keeps its own errored message and earlier
// ones would stack the same text; a turn that ended well says nothing at all.
val error = model.message(id)?.info?.error?.takeIf { id == last }
val text = failureText(error)
// The outcome footer owns the live failure while it is showing that exact text, because that
// is the card carrying Retry. A generic TurnEnded(FAILED) footer carries no message, so the
// card stays and remains the only place the reason is visible.
val duplicate = id == tail && text != null && text == shown
changed = msg.syncError(if (duplicate) null else error) || changed
}
return changed
}
private fun syncReverted() {
for ((id, view) in msgToView) {
view.setReverted(model.isRevertedMessage(id))
@@ -0,0 +1,104 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.MessageErrorDto
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import javax.swing.JPanel
/**
* What a failure reads as in the transcript, or null when there is nothing to show.
*
* Null for a Stop: an aborted turn is a deliberate user action that the footer already reports as
* "Stopped", and the TUI skips it the same way. Shared with the transcript panel so it can compare
* against the text the outcome card is already showing.
*/
internal fun failureText(error: MessageErrorDto?): String? {
if (error == null) return null
if (error.aborted) return null
return error.message?.trim()?.takeIf { it.isNotEmpty() } ?: error.type
}
/**
* The failure a turn ended with, rendered on the message that carries it.
*
* The footer outcome card is bound to live session state, so it is gone as soon as the next turn
* starts and it never comes back for an older turn. This card is the durable record: a turn that died
* mid-session still explains itself in scrollback and after a reload, which is what the TUI and the
* VS Code webview already do.
*
* An accented block rather than a full card: there is nothing to expand, and the text is the point.
*/
class MessageErrorView : JPanel(BorderLayout()), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private val body = ErrorText()
init {
// Containers stay transparent over the session backdrop; [ErrorText] is the raised surface.
isOpaque = false
add(body, BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
}
/** Returns true when the text changed, so callers only relayout on a real change. */
@RequiresEdt
fun setText(value: String): Boolean {
if (body.text == value) return false
body.text = value
body.caretPosition = 0
revalidate()
repaint()
return true
}
@RequiresEdt
fun text(): String = body.text
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
body.font = style.transcriptFont
revalidate()
repaint()
}
/** Re-resolved here so the accent survives a Look and Feel switch. */
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.customLineLeft(UiStyle.Colors.errorLabelForeground())
}
}
/**
* Selectable, wrapping error text over the raised editor surface.
*
* Owns its own theme values: assigning them from the parent's `init` would not survive a Look and Feel
* switch, and re-applying them in the parent's `updateUI` would run before this field exists.
*/
private class ErrorText : JBTextArea() {
init {
isEditable = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = true
lineWrap = true
wrapStyleWord = true
}
override fun updateUI() {
super.updateUI()
foreground = UiStyle.Colors.errorLabelForeground()
background = SessionUiStyle.Colors.codeBlockBackground()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
)
}
}
@@ -30,6 +30,7 @@ import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.MessageErrorDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -98,6 +99,7 @@ class MessageView(
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var reverted = false
private var failure: MessageErrorView? = null
init {
isOpaque = false
@@ -113,6 +115,43 @@ class MessageView(
syncVisibility()
}
/**
* Show, update, or drop the failure this message ended with. Returns true when anything visible
* changed, so the panel only relayouts on a real change — `message.updated` also fires on every
* streamed token/cost delta.
*
* [SessionModel.upsertMessage] replaces the [Message] instance while this view keeps the original,
* so the error has to be passed in rather than read back off [msg].
*/
@RequiresEdt
fun syncError(error: MessageErrorDto?): Boolean {
val text = failureText(error)
val existing = failure
if (text == null) {
if (existing == null) return false
failure = null
remove(existing)
refresh()
return true
}
if (existing != null) {
if (!existing.setText(text)) return false
refresh()
return true
}
val view = MessageErrorView().also {
it.applyStyle(style)
it.setText(text)
}
failure = view
add(view)
refresh()
return true
}
/** Insertion slot that keeps the failure card last, or -1 to append when there is none. */
private fun tail(): Int = failure?.let { components.indexOf(it) } ?: -1
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
@@ -229,7 +268,7 @@ class MessageView(
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
wrapPrompt(view)?.let { add(it) }
wrapPrompt(view)?.let { add(it, tail()) }
}
@RequiresEdt
@@ -241,7 +280,7 @@ class MessageView(
attachments = it
val node = ensurePromptWrap()
promptBox?.add(it, BorderLayout.SOUTH)
if (node.parent == null) add(node)
if (node.parent == null) add(node, tail())
}
view.upsert(content)
parts[content.id] = view
@@ -454,6 +493,7 @@ class MessageView(
this.style = style
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style)
for (view in parts.values) view.applyStyle(style)
failure?.applyStyle(style)
refresh()
}
@@ -464,6 +504,8 @@ class MessageView(
Disposer.dispose(it)
}
wrap?.let { remove(it) }
failure?.let { remove(it) }
failure = null
parts.clear()
aliases.clear()
sources.clear()
@@ -152,6 +152,9 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test busy status is seeded from statuses map`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
// recoverPending() reads the service's status map once, and that map arrives through a flow, so
// the seed has to be observable before the controller loads or recovery races it to Idle.
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "busy" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -173,6 +176,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
attempt = 3,
next = 5000L,
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "retry" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -196,6 +200,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
message = "No network",
requestID = "req_xyz",
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "offline" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.views.MessageErrorView
import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PromptAttachmentView
@@ -37,6 +38,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
@@ -140,6 +142,148 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals("1 file", card.countText())
}
// ------ failed turns ------
fun `test failed message renders the provider text in the transcript`() {
model.upsertMessage(msg("a1", "assistant"))
assertTrue(cards("a1").isEmpty())
model.upsertMessage(msg("a1", "assistant").copy(error = failure("The provider ended the response with an error")))
val card = cards("a1").single()
assertEquals("The provider ended the response with an error", card.text())
assertSame("The failure belongs after the content it interrupted", card, panel.findMessage("a1")!!.components.last())
}
fun `test failed message with no text falls back to the error type`() {
model.upsertMessage(msg("a1", "assistant").copy(error = MessageErrorDto("ProviderAuthError")))
assertEquals("ProviderAuthError", cards("a1").single().text())
}
/** A Stop is a deliberate user action the footer already reports as "Stopped", not a failure. */
fun `test stopped message renders no failure card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = MessageErrorDto(MessageErrorDto.ABORTED, "aborted")))
assertTrue(cards("a1").isEmpty())
}
fun `test repeated retry failures collapse to the last failed attempt in the turn`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a2", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a3", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
assertTrue(cards("a1").isEmpty())
assertTrue(cards("a2").isEmpty())
assertEquals("Missing credentials", cards("a3").single().text())
}
fun `test recovered turn hides an earlier failed attempt`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a2", "assistant").copy(parentID = "u1"))
assertTrue(cards("a1").isEmpty())
assertTrue(cards("a2").isEmpty())
}
fun `test outcome card owns the active tail error while it shows the same text`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
assertEquals("Missing credentials", cards("a1").single().text())
model.setState(SessionState.Error("Missing credentials"))
assertTrue("The footer already shows this message next to Retry", cards("a1").isEmpty())
}
fun `test tail failure card returns once the footer moves on`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.Error("Missing credentials"))
assertTrue(cards("a1").isEmpty())
model.setState(SessionState.Busy("thinking"))
assertEquals("Missing credentials", cards("a1").single().text())
}
fun `test generic failed outcome keeps the provider error in the transcript`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.TurnEnded(Outcome.FAILED))
assertEquals("Missing credentials", cards("a1").single().text())
}
fun `test unrelated footer error does not hide the message failure`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.Error("Workspace failed"))
assertEquals("Missing credentials", cards("a1").single().text())
}
fun `test history load paints a failure that arrived before the panel existed`() {
model.loadHistory(
listOf(
MessageWithPartsDto(
msg("a1", "assistant").copy(error = failure("Context window exceeded")),
emptyList(),
),
),
)
assertEquals("Context window exceeded", cards("a1").single().text())
}
/** The footer outcome card is state-bound and vanishes on the next turn; this record must not. */
fun `test failure card survives the following turn`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
model.upsertMessage(msg("u2", "user"))
model.upsertMessage(msg("a2", "assistant"))
assertEquals("Provider overloaded", cards("a1").single().text())
assertTrue(cards("a2").isEmpty())
}
fun `test clearing the failure removes its card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
assertEquals(1, cards("a1").size)
model.upsertMessage(msg("a1", "assistant"))
assertTrue(cards("a1").isEmpty())
}
/** message.updated also fires on every token/cost delta, so an unchanged failure must be inert. */
fun `test repeated identical failure update does not refresh panel`() {
val failed = msg("a1", "assistant").copy(error = failure("Provider overloaded"))
model.upsertMessage(failed)
val view = panel.findMessage("a1")!!
val card = cards("a1").single()
val repaint = TrackingRepaintManager(setOf(panel, view, card))
val old = RepaintManager.currentManager(panel)
try {
RepaintManager.setCurrentManager(repaint)
model.upsertMessage(failed)
assertSame("The card must be reused, not rebuilt", card, cards("a1").single())
assertTrue(repaint.dirty.isEmpty())
assertTrue(repaint.invalid.isEmpty())
} finally {
RepaintManager.setCurrentManager(old)
}
}
fun `test streamed content stays above the failure card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
model.updateContent("a1", part("p1", "a1", "text", "partial answer"))
val view = panel.findMessage("a1")!!
assertTrue(view.components.first() is TextView)
assertSame(cards("a1").single(), view.components.last())
}
fun `test transcript content has symmetric side padding`() {
model.upsertMessage(msg("a1", "assistant"))
@@ -1615,6 +1759,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
)
private fun failure(message: String) = MessageErrorDto("APIError", message)
private fun cards(msgId: String): List<MessageErrorView> {
val view = panel.findMessage(msgId) ?: return emptyList()
return components(view).filterIsInstance<MessageErrorView>()
}
private fun summary(path: String) = MessageSummaryDto(
diffs = listOf(DiffFileDto(path, 2, 1, PATCH)),
)