From bac3e7a7a83bdcc0ec8b93ede2fa49adb88fd133 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 15:34:46 -0400 Subject: [PATCH] fix(jetbrains): surface failed turn errors --- .../jetbrains-failed-turn-notification.md | 5 + .../backend/app/KiloBackendActivityManager.kt | 4 + .../app/KiloBackendActivityManagerTest.kt | 48 ++++++ .../session/ui/SessionMessageListPanel.kt | 67 +++++++- .../client/session/views/MessageErrorView.kt | 104 ++++++++++++ .../client/session/views/MessageView.kt | 46 +++++- .../session/controller/SessionRecoveryTest.kt | 5 + .../session/ui/SessionMessageListPanelTest.kt | 151 ++++++++++++++++++ 8 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 .changeset/jetbrains-failed-turn-notification.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageErrorView.kt diff --git a/.changeset/jetbrains-failed-turn-notification.md b/.changeset/jetbrains-failed-turn-notification.md new file mode 100644 index 0000000000..790bec84d0 --- /dev/null +++ b/.changeset/jetbrains-failed-turn-notification.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show why a turn failed instead of letting the session stop with no visible reason. The provider's explanation now appears once for the failed turn when it is not already shown by the Retry card, and the session is flagged in history, worktree rows, and its editor tab the same way an error or a pending question is. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index a9a88b1e54..24c1752d3c 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -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) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 57961dff62..19f69b72ef 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -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" 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 ac59440a58..fa9e6c529e 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 @@ -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)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageErrorView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageErrorView.kt new file mode 100644 index 0000000000..b4cacf7618 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageErrorView.kt @@ -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), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 0454c277c9..b7912b392e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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() 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 8332c171ba..3a48589242 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 @@ -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() 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 ff7e0cdf47..7e1c0b5af3 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 @@ -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 { + val view = panel.findMessage(msgId) ?: return emptyList() + return components(view).filterIsInstance() + } + private fun summary(path: String) = MessageSummaryDto( diffs = listOf(DiffFileDto(path, 2, 1, PATCH)), )