From ec94fdc791ea52fb1faee1ec21536fc58a2f29bf Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 11:14:48 -0400 Subject: [PATCH 1/7] fix(jetbrains): avoid error badges for stopped sessions --- .../jetbrains-stopped-session-not-an-error.md | 5 +++ .../backend/app/KiloBackendActivityManager.kt | 9 ++--- .../app/KiloBackendActivityManagerTest.kt | 25 ++++++++++--- .../session/controller/SessionController.kt | 12 +++---- .../client/session/model/SessionState.kt | 2 +- .../client/session/model/TurnOutcome.kt | 12 ++++--- .../session/ui/SessionMessageListPanel.kt | 2 +- .../session/views/SessionOutcomeView.kt | 36 ++++++++++--------- .../client/session/views/base/DialogView.kt | 18 ++++++++-- .../resources/messages/KiloBundle.properties | 3 +- .../messages/KiloBundle_ar.properties | 3 +- .../messages/KiloBundle_bs.properties | 3 +- .../messages/KiloBundle_da.properties | 3 +- .../messages/KiloBundle_de.properties | 3 +- .../messages/KiloBundle_es.properties | 3 +- .../messages/KiloBundle_fr.properties | 3 +- .../messages/KiloBundle_ja.properties | 3 +- .../messages/KiloBundle_ko.properties | 3 +- .../messages/KiloBundle_nl.properties | 3 +- .../messages/KiloBundle_no.properties | 3 +- .../messages/KiloBundle_pl.properties | 3 +- .../messages/KiloBundle_pt_BR.properties | 3 +- .../messages/KiloBundle_ru.properties | 3 +- .../messages/KiloBundle_th.properties | 3 +- .../messages/KiloBundle_tr.properties | 3 +- .../messages/KiloBundle_uk.properties | 3 +- .../messages/KiloBundle_zh_CN.properties | 3 +- .../messages/KiloBundle_zh_TW.properties | 3 +- .../session/controller/TurnLifecycleTest.kt | 5 ++- .../session/ui/SessionMessageListPanelTest.kt | 7 ++-- .../session/views/SessionOutcomeViewTest.kt | 29 +++++++++------ .../session/views/base/DialogViewTest.kt | 27 ++++++++++++++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 8 ++++- 33 files changed, 155 insertions(+), 99 deletions(-) create mode 100644 .changeset/jetbrains-stopped-session-not-an-error.md diff --git a/.changeset/jetbrains-stopped-session-not-an-error.md b/.changeset/jetbrains-stopped-session-not-an-error.md new file mode 100644 index 00000000000..092a7f98953 --- /dev/null +++ b/.changeset/jetbrains-stopped-session-not-an-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Stop treating a manually stopped session as a failure. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot, while real provider failures keep the error card with scrollable details. 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 7cd72053882..a9a88b1e544 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 @@ -86,14 +86,15 @@ class KiloBackendActivityManager( is ChatEventDto.QuestionAsked -> questions.getOrPut(event.sessionID) { mutableMapOf() }[event.request.id] = plan(event) is ChatEventDto.QuestionReplied -> removeMap(questions, event.sessionID, event.requestID) is ChatEventDto.QuestionRejected -> removeMap(questions, event.sessionID, event.requestID) - is ChatEventDto.Error -> event.sessionID?.let { errors.add(it) } + // A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so + // 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) is ChatEventDto.SessionIdle -> clear(event.sessionID) is ChatEventDto.SessionStatusChanged -> when (event.status.type) { "idle" -> clear(event.sessionID) - // Work restarted, so whatever ended the previous turn (a Stop publishes - // MessageAbortedError) is stale. Not every resume path publishes a turn event, so - // busy has to clear the error itself. + // Work restarted, so whatever ended the previous turn is stale. Not every resume + // path publishes a turn event, so busy has to clear the error itself. "busy" -> errors.remove(event.sessionID) else -> Unit } 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 a06aa09b640..57961dff62a 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 @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -55,7 +56,7 @@ class KiloBackendActivityManagerTest { } @Test - fun `permission asked overlays running and reply reverts`() = runBlocking { + fun `permission asked overlays running and reply reverts`() = runBlocking { directories["ses_1"] = "/repo/wt" statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() @@ -69,7 +70,7 @@ class KiloBackendActivityManagerTest { } @Test - fun `question kinds distinguish plain and plan followup`() = runBlocking { + fun `question kinds distinguish plain and plan followup`() = runBlocking { directories["ses_plain"] = "/repo/a" directories["ses_plan"] = "/repo/b" start() @@ -115,12 +116,26 @@ class KiloBackendActivityManagerTest { } @Test - fun `busy outranks a pending error so a resumed session runs`() = runBlocking { + fun `aborted error does not badge the session`() = runBlocking { directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() + await("ses_1", SessionActivityKindDto.RUNNING) - // A Stop leaves the session errored and idle. - events.emit(ChatEventDto.Error("ses_1")) + events.emit(ChatEventDto.Error("ses_1", MessageErrorDto(MessageErrorDto.ABORTED, "aborted"))) + 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 `busy outranks a pending provider error so a resumed session runs`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + events.emit(ChatEventDto.Error("ses_1", MessageErrorDto("APIError", "Provider failed"))) await("ses_1", SessionActivityKindDto.ERROR) // Resumed: busy arrives before anything clears the error. 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 7d42ce94551..91ff6deca16 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 @@ -24,7 +24,6 @@ import ai.kilocode.client.session.ui.mode.agentTitle 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 @@ -126,7 +125,6 @@ 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 @@ -1422,8 +1420,8 @@ class SessionController( 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)) + if (err.aborted) { + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED)) return } model.setState(SessionState.Error(err.message ?: err.type, err.type)) @@ -1510,7 +1508,7 @@ class SessionController( if (current is SessionState.Error && event.reason != "completed") return val ended = TurnOutcome.classify(event.reason) when { - ended != null -> model.setState(SessionState.TurnEnded(ended.first, ended.second)) + ended != null -> model.setState(SessionState.TurnEnded(ended)) event.reason == "completed" -> { capture("Task Completed", sessionProps(event.sessionID)) model.setState(SessionState.Idle) @@ -1522,7 +1520,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - if (event.error?.type != ABORT_ERROR) { + if (event.error?.aborted != true) { capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) } error(event, true) @@ -1645,7 +1643,7 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } - if (event.error?.type == ABORT_ERROR) return + if (event.error?.aborted == true) return val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") model.setState(SessionState.Error(msg, event.error?.type)) } 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 a55a2253579..52237107614 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,7 +22,7 @@ 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 TurnEnded(val outcome: Outcome) : SessionState() data class LoginRequired(val message: String) : SessionState() 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 index 137526d25d2..009cd27834e 100644 --- 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 @@ -2,12 +2,14 @@ package ai.kilocode.client.session.model enum class Outcome { INTERRUPTED, FAILED } -enum class OutcomeTone { WARNING, CRITICAL } - object TurnOutcome { - fun classify(reason: String): Pair? = when (reason) { - "interrupted" -> Outcome.INTERRUPTED to OutcomeTone.WARNING - "error" -> Outcome.FAILED to OutcomeTone.CRITICAL + /** + * Maps a `session.turn.close` reason to the outcome the transcript should show. `completed` and + * `superseded` are normal endings and return null so the session simply falls back to idle. + */ + fun classify(reason: String): Outcome? = when (reason) { + "interrupted" -> Outcome.INTERRUPTED + "error" -> Outcome.FAILED 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 ce710c4751c..ac59440a584 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 @@ -470,7 +470,7 @@ class SessionMessageListPanel( question?.hideView() permission?.hideView() login?.hideView() - outcome?.showOutcome(state.outcome, state.tone) + outcome?.showOutcome(state.outcome) } else -> { setHiddenQuestionTool(null) 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 index 7e92be8a510..55cce35f1a6 100644 --- 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 @@ -2,7 +2,6 @@ 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 @@ -35,8 +34,9 @@ class SessionOutcomeView( @RequiresEdt fun showError(message: String, kind: String?) { + setOutlined(true) setHeaderIcon(AllIcons.General.Error, kind ?: KiloBundle.message("session.error.title")) - setHeader(KiloBundle.message("session.error.title")) + setHeader(KiloBundle.message("session.error.title"), kind) error.text = message setContentPadding(left = false, right = false) setContent(error.scroll) @@ -44,22 +44,26 @@ class SessionOutcomeView( refresh() } + /** + * A user-initiated stop is not a failure: it renders as one muted line with no icon and no card + * outline. Only a model/provider failure gets the error card treatment. + */ @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 + fun showOutcome(outcome: Outcome) { + when (outcome) { + Outcome.INTERRUPTED -> { + setOutlined(false) + setHeaderIcon(null) + setHeader("", KiloBundle.message("session.outcome.interrupted.note")) + } + + Outcome.FAILED -> { + val title = KiloBundle.message("session.outcome.failed.title") + setOutlined(true) + setHeaderIcon(AllIcons.General.Error, title) + setHeader(title, KiloBundle.message("session.outcome.failed.description")) + } } - setHeaderIcon(icon, title) - setHeader(title, desc) setContentPadding() setContent(null) isVisible = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt index 6087a620432..14cd3a8020b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt @@ -87,7 +87,10 @@ open class DialogView( isVisible = false } - private val headerText: JBTextArea = makeText("", SessionUiStyle.Colors.foreground(), bold = true) + // Both rows start blank, so both start hidden; setHeader/setDescription drive visibility from text. + private val headerText: JBTextArea = makeText("", SessionUiStyle.Colors.foreground(), bold = true).apply { + isVisible = false + } private val descriptionText: JBTextArea = makeText("", SessionUiStyle.Text.Secondary.foreground(), bold = false).apply { isVisible = false } @@ -106,6 +109,7 @@ open class DialogView( private var padLeft = true private var padRight = true private var padBottom = true + private var outlined = true // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() @@ -137,6 +141,7 @@ open class DialogView( @RequiresEdt fun setHeader(text: String, description: String? = null) { headerText.text = text + headerText.isVisible = text.isNotBlank() setDescription(description) syncNorth() } @@ -303,6 +308,13 @@ open class DialogView( btn.text = text } + @RequiresEdt + fun setOutlined(value: Boolean) { + if (outlined == value) return + outlined = value + repaint() + } + /** Returns the retained action component for focus management, or this card when absent. */ @RequiresEdt fun preferredActionComponent(id: String): JComponent = actionButtons[id] ?: this @@ -327,7 +339,7 @@ open class DialogView( override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() + override fun outlineColor(): Color? = if (outlined) SessionUiStyle.View.Outline.brightColor() else null // ---- private helpers ---- @@ -340,7 +352,7 @@ open class DialogView( north.repaint() } - private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible + private fun hasHeader() = icon.icon != null || headerText.isVisible || descriptionText.isVisible private fun syncInsets() { val side = UiStyle.Gap.pad() 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 a12c21fb71e..0b3a5447e56 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -214,8 +214,7 @@ 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.outcome.interrupted.note=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/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 3e13dd70f0c..aa1efe62008 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=خطأ غير معروف 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.outcome.interrupted.note=Stopped session.header.tokens=الرموز session.header.tokens.description=الرموز المستخدمة في آخر رد للمساعد: الإدخال، الإخراج، كتابات المخزن المؤقت وقراءاته. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 1127915a57b..708084f8802 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Nepoznata greška 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.outcome.interrupted.note=Stopped session.header.tokens=Tokeni session.header.tokens.description=Tokeni korišteni u posljednjem odgovoru asistenta: ulaz, izlaz, pisanja u keš i čitanja iz keša. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 35de81c384c..01e27a1be91 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Ukendt fejl 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens brugt af det seneste assistentsvar: input, output, cache-skrivninger og cache-læsninger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index c93405d6f0f..5fcd307bab1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Unbekannter Fehler 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.outcome.interrupted.note=Stopped session.header.tokens=Token session.header.tokens.description=Von der letzten Assistentenantwort verwendete Token: Eingabe, Ausgabe, Cache-Schreibvorgänge und Cache-Lesevorgänge. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 987175f6d0e..1dbb668d5c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Error desconocido 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens utilizados por la última respuesta del asistente: entrada, salida, escrituras en caché y lecturas en caché. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index a8d5960d97d..fb8e342b9e6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Erreur inconnue 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens utilisés par la dernière réponse de l'assistant : entrée, sortie, écritures en cache et lectures en cache. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 313fdd9b6a6..67f37670258 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=不明なエラー 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.outcome.interrupted.note=Stopped session.header.tokens=トークン session.header.tokens.description=最新のアシスタントの回答で使用されたトークン:入力、出力、キャッシュ書き込み、キャッシュ読み取り。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 8d131c6cdd7..222a0ad1650 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=알 수 없는 오류 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.outcome.interrupted.note=Stopped session.header.tokens=토큰 session.header.tokens.description=마지막 어시스턴트 응답에서 사용된 토큰: 입력, 출력, 캐시 쓰기, 캐시 읽기. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index da4d33ac6aa..8866d9325cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Onbekende fout 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens gebruikt door de laatste assistent-reactie: invoer, uitvoer, cache-schrijfacties en cache-leesacties. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index cfad2067884..90c0147c566 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Ukjent feil 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens brukt av siste assistentsvar: inndata, utdata, cache-skrivninger og cache-lesninger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 7374c84ee56..444c3da960a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Nieznany błąd 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.outcome.interrupted.note=Stopped session.header.tokens=Tokeny session.header.tokens.description=Tokeny użyte przez ostatnią odpowiedź asystenta: wejściowe, wyjściowe, zapisy do bufora i odczyty z bufora. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 91556b034d2..b3094af3c0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Erro desconhecido 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.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens usados pela última resposta do assistente: entrada, saída, escritas em cache e leituras em cache. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index a3bb483a21d..3781698af25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Неизвестная ошибка 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.outcome.interrupted.note=Stopped session.header.tokens=Токены session.header.tokens.description=Токены, использованные последним ответом ассистента: входные, исходные, запись в кэш и чтение из кэша. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 77fae80689d..c37242ada82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=ข้อผิดพลาดที่ไม่ทราบ 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.outcome.interrupted.note=Stopped session.header.tokens=โทเคน session.header.tokens.description=โทเคนที่ใช้โดยคำตอบล่าสุดของผู้ช่วย: อินพุต เอาต์พุต การเขียนแคช และการอ่านแคช diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 62400e996d9..b459352c7b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Bilinmeyen hata 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.outcome.interrupted.note=Stopped session.header.tokens=Jeton session.header.tokens.description=Son asistan yanıtında kullanılan jetonlar: giriş, çıkış, önbelleğe yazma ve önbellekten okuma. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 8bbd4b8d382..c4a4af08617 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=Невідома помилка 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.outcome.interrupted.note=Stopped session.header.tokens=Токени session.header.tokens.description=Токени, використані останньою відповіддю асистента: вхід, вихід, запис у кеш і читання з кешу. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 4951eacc80d..809ef2d63d0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=未知错误 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.outcome.interrupted.note=Stopped session.header.tokens=令牌 session.header.tokens.description=最近一次助手回复中使用的令牌:输入、输出、缓存写入和缓存读取。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index faf77135c34..cbe07bedbe7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -77,8 +77,7 @@ session.error.title=Request failed session.error.unknown=未知錯誤 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.outcome.interrupted.note=Stopped session.header.tokens=記號 session.header.tokens.description=最新助手回覆使用的記號:輸入、輸出、快取寫入和快取讀取。 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 b199a3f1138..a2fc73d2b66 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 @@ -2,7 +2,6 @@ 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 @@ -274,8 +273,8 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test turn outcome classifier`() { assertNull(TurnOutcome.classify("completed")) assertNull(TurnOutcome.classify("superseded")) - assertEquals(Outcome.INTERRUPTED to OutcomeTone.WARNING, TurnOutcome.classify("interrupted")) - assertEquals(Outcome.FAILED to OutcomeTone.CRITICAL, TurnOutcome.classify("error")) + assertEquals(Outcome.INTERRUPTED, TurnOutcome.classify("interrupted")) + assertEquals(Outcome.FAILED, TurnOutcome.classify("error")) } fun `test TurnClose completed preserves AwaitingQuestion state`() { 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 38c940fb29a..ff7e0cdf471 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 @@ -4,7 +4,6 @@ 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 @@ -1097,20 +1096,20 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test turn ended state makes outcome view visible`() { val item = panelWithPrompts() - model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING)) + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED)) val ov = find(item)!! val comps = item.components.toList() assertTrue(ov.isVisible) - assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.description"))) + assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.note"))) 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.TurnEnded(Outcome.FAILED)) model.setState(SessionState.Idle) val ov = find(item)!! 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 index b4266614f7a..8a6c19b248d 100644 --- 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 @@ -2,7 +2,6 @@ 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.SessionLayout import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -86,27 +85,26 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { } } - fun `test showOutcome renders interrupted copy and warning icon`() { + fun `test showOutcome renders interrupted note without icon`() { edt { val view = SessionOutcomeView() - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + view.showOutcome(Outcome.INTERRUPTED) assertTrue(view.isVisible) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.title"))) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) - assertIcons(view, AllIcons.General.Warning) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) + assertTrue(findAll(view).none { it.icon != null && it.isVisible }) } } fun `test showOutcome updates without stale text`() { edt { val view = SessionOutcomeView() - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) - view.showOutcome(Outcome.FAILED, OutcomeTone.CRITICAL) + view.showOutcome(Outcome.INTERRUPTED) + view.showOutcome(Outcome.FAILED) 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"))) + assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) assertIcons(view, AllIcons.General.Error) } } @@ -115,11 +113,20 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { edt { val view = SessionOutcomeView() view.showError("Provider balance is too low", "APIError") - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + view.showOutcome(Outcome.INTERRUPTED) assertNull(findText(view, "Provider balance is too low")) assertNull(findErrorScroll(view, "Provider balance is too low")) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) + } + } + + fun `test showError surfaces error kind`() { + edt { + val view = SessionOutcomeView() + view.showError("Provider balance is too low", "APIError") + + assertNotNull(findText(view, "APIError")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt index 5f1063cf3c5..04371cd13fe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt @@ -60,6 +60,29 @@ class DialogViewTest : BasePlatformTestCase() { } } + fun `test setHeader with blank title hides header text`() { + edt { + val panel = DialogView() + panel.setHeader("", "Stopped") + val areas = findAll(panel) + + assertTrue("Bold header text area should be hidden", areas.filter { it.font.isBold }.all { !it.isVisible }) + assertNotNull("Description should remain visible", areas.firstOrNull { it.text == "Stopped" && it.isVisible }) + } + } + + fun `test setOutlined toggles outline color`() { + edt { + val panel = InspectDialogView() + + assertNotNull(panel.line()) + panel.setOutlined(false) + assertNull(panel.line()) + panel.setOutlined(true) + assertNotNull(panel.line()) + } + } + fun `test setDescription with blank hides description`() { edt { val panel = DialogView() @@ -530,4 +553,8 @@ class DialogViewTest : BasePlatformTestCase() { } return result } + + private class InspectDialogView : DialogView() { + fun line() = outlineColor() + } } 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 acac40bb3ec..9a0318477f0 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 @@ -49,7 +49,13 @@ data class MessageErrorDto( val responseBody: String? = null, val dataKeys: List = emptyList(), val ref: String? = null, -) +) { + val aborted: Boolean get() = type == ABORTED + + companion object { + const val ABORTED = "MessageAbortedError" + } +} @Serializable data class MessageWithPartsDto( From 648fa0a6a7b33072a631c6802bdc64ee6e94cd61 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 13:16:44 -0400 Subject: [PATCH 2/7] feat(jetbrains): retry failed turns from the error card Adds a Retry action to the error card. Retry reverts to the failed assistant message, which restores the workspace when that turn already edited files, then replays the original user message with the same model. Reusing the user message id means no synthetic message is appended, and SessionRevert.cleanup removes the failed message on the prompt that follows. Retry is offered only for failures, never for a user stop. Also clears a failed assistant tail that produced no visible output when the next prompt arrives, so an empty error placeholder stops lingering in history. Turns that emitted text or ran a tool are kept, since their record explains changes already on disk. --- .changeset/clear-empty-failed-turn.md | 5 + .../jetbrains-stopped-session-not-an-error.md | 4 +- .../ai/kilocode/client/session/SessionUi.kt | 1 + .../session/controller/SessionController.kt | 78 +++++++ .../session/views/SessionOutcomeView.kt | 28 +++ .../resources/messages/KiloBundle.properties | 2 + .../messages/KiloBundle_ar.properties | 2 + .../messages/KiloBundle_bs.properties | 2 + .../messages/KiloBundle_da.properties | 2 + .../messages/KiloBundle_de.properties | 2 + .../messages/KiloBundle_es.properties | 2 + .../messages/KiloBundle_fr.properties | 2 + .../messages/KiloBundle_ja.properties | 2 + .../messages/KiloBundle_ko.properties | 2 + .../messages/KiloBundle_nl.properties | 2 + .../messages/KiloBundle_no.properties | 2 + .../messages/KiloBundle_pl.properties | 2 + .../messages/KiloBundle_pt_BR.properties | 2 + .../messages/KiloBundle_ru.properties | 2 + .../messages/KiloBundle_th.properties | 2 + .../messages/KiloBundle_tr.properties | 2 + .../messages/KiloBundle_uk.properties | 2 + .../messages/KiloBundle_zh_CN.properties | 2 + .../messages/KiloBundle_zh_TW.properties | 2 + .../session/controller/SessionRetryTest.kt | 149 ++++++++++++++ .../session/views/SessionOutcomeViewTest.kt | 67 +++++- .../opencode/src/kilocode/session/prompt.ts | 35 ++++ packages/opencode/src/session/prompt.ts | 2 + .../session/recover-failed-assistant.test.ts | 191 ++++++++++++++++++ 29 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 .changeset/clear-empty-failed-turn.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt create mode 100644 packages/opencode/test/kilocode/session/recover-failed-assistant.test.ts diff --git a/.changeset/clear-empty-failed-turn.md b/.changeset/clear-empty-failed-turn.md new file mode 100644 index 00000000000..ed3daa6b4b1 --- /dev/null +++ b/.changeset/clear-empty-failed-turn.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Clear a failed turn that produced no output from the conversation when the next message is sent, so an "An error occurred" placeholder no longer lingers in history. A turn that wrote text or ran a tool before failing is kept, since its record explains changes already made. diff --git a/.changeset/jetbrains-stopped-session-not-an-error.md b/.changeset/jetbrains-stopped-session-not-an-error.md index 092a7f98953..cb9e8e15bfe 100644 --- a/.changeset/jetbrains-stopped-session-not-an-error.md +++ b/.changeset/jetbrains-stopped-session-not-an-error.md @@ -1,5 +1,5 @@ --- -"@kilocode/kilo-jetbrains": patch +"@kilocode/kilo-jetbrains": minor --- -Stop treating a manually stopped session as a failure. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot, while real provider failures keep the error card with scrollable details. +Stop treating a manually stopped session as a failure, and add a Retry action to failed turns. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot. A turn that fails from a provider error keeps the error badge and card, and can be retried in place: the failed turn is rolled back and the same request re-runs with the same model. 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 8d9a07ff8e3..fc3fd4bc9b5 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 @@ -400,6 +400,7 @@ class SessionUi( outcome = SessionOutcomeView( selection = selection, focus = focus, + retry = if (readonly) null else controller::retry, ) messageBody = SessionMessageListPanel( controller.model, 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 91ff6deca16..958b2e331b3 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 @@ -487,6 +487,84 @@ class SessionController( } } + /** + * Re-runs the last user turn after it failed, discarding the failed assistant turn first. + * + * Reverting to the failed assistant message restores the workspace when that turn already edited + * files (a no-op server-side when it edited nothing), and the prompt that follows is what actually + * removes the message: `SessionRevert.cleanup` drops everything at or after the revert target on the + * next prompt. The replay reuses the original user message id, so no synthetic message is appended. + */ + fun retry() { + assertEdt() + val id = sid ?: return + val target = retryTarget() ?: return + LOG.info("${ChatLogSummary.sid(id)} kind=retry clicked=true message=${target.assistant}") + val op = beginReverting( + KiloBundle.message("session.status.retrying"), + // No rollback marker: the transcript should not paint the failed turn as a revert target, + // it is about to be replaced. SessionMessageListPanel only marks when message != null. + SessionState.Reverting.Kind.ROLLBACK, + message = null, + ) ?: return + revertJob = cs.launch { + try { + sessions.revert(id, directory, target.assistant, null) + capture("Session Retry", sessionProps(id)) + synchronizeFromDisk(id, "retry") + edt { + if (disposed) return@edt + clearReverting(op) + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + sessions.prompt(id, directory, target.prompt) + LOG.info("${ChatLogSummary.sid(id)} kind=retry ok=true") + } catch (e: CancellationException) { + edt { cancelReverting(op) } + } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "retry", "errorClass" to e::class.java.name)) + LOG.warn("${ChatLogSummary.sid(id)} kind=retry dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (disposed) return@edt + // The revert may already have landed. Leave it applied and surface the failure so the + // user can retry again or redo, rather than silently dropping back to idle. + if (revertOp?.key == op.key) failReverting(op, e) + else model.setState(SessionState.Error(e.message ?: KiloBundle.message("session.error.prompt"))) + } + } + } + } + + /** + * The failed tail turn to replay, or null when retry does not apply: no session, an operation already + * in flight, a busy session, or a tail that is not an assistant turn that failed off the last user + * message. + */ + private fun retryTarget(): RetryTarget? { + assertEdt() + if (sid == null) return null + if (revertOp != null) return null + if (model.state.isBusy()) return null + val msgs = model.messages().toList() + val tail = msgs.lastOrNull() ?: return null + if (tail.info.role != "assistant") return null + // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. + val state = model.state + val failed = tail.info.error?.aborted == false || + (tail.info.error == null && + (state is SessionState.Error || + (state is SessionState.TurnEnded && state.outcome == Outcome.FAILED))) + if (!failed) return null + val user = msgs.getOrNull(msgs.size - 2)?.info ?: return null + if (user.role != "user") return null + if (tail.info.parentID != user.id) return null + val prompt = retryPrompt() ?: return null + if (prompt.messageID != user.id) return null + return RetryTarget(tail.info.id, prompt) + } + + private data class RetryTarget(val assistant: String, val prompt: PromptDto) + fun deleteQueuedMessage(message: String) { assertEdt() val id = sid ?: return 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 index 55cce35f1a6..d3340e1e5a1 100644 --- 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 @@ -20,6 +20,7 @@ import javax.swing.ScrollPaneConstants class SessionOutcomeView( selection: SessionSelection? = null, focus: (() -> Unit)? = null, + private val retry: (() -> Unit)? = null, ) : DialogView(selection, focus), SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -40,6 +41,7 @@ class SessionOutcomeView( error.text = message setContentPadding(left = false, right = false) setContent(error.scroll) + syncRetry(true) isVisible = true refresh() } @@ -55,6 +57,7 @@ class SessionOutcomeView( setOutlined(false) setHeaderIcon(null) setHeader("", KiloBundle.message("session.outcome.interrupted.note")) + syncRetry(false) } Outcome.FAILED -> { @@ -62,6 +65,7 @@ class SessionOutcomeView( setOutlined(true) setHeaderIcon(AllIcons.General.Error, title) setHeader(title, KiloBundle.message("session.outcome.failed.description")) + syncRetry(true) } } setContentPadding() @@ -70,6 +74,26 @@ class SessionOutcomeView( refresh() } + /** Retry belongs to failures only; a user-initiated stop stays a plain note with no controls. */ + @RequiresEdt + private fun syncRetry(show: Boolean) { + val run = retry + if (run == null || !show) { + setActions(emptyList()) + return + } + setActions( + listOf( + Action( + id = RETRY_ACTION, + text = KiloBundle.message("session.outcome.retry"), + primary = true, + handler = run, + ), + ), + ) + } + @RequiresEdt fun hideView() { if (!isVisible) return @@ -82,6 +106,10 @@ class SessionOutcomeView( super.applyStyle(style) error.applyStyle(style) } + + private companion object { + const val RETRY_ACTION = "retry" + } } private class ErrorBody { 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 0b3a5447e56..382458d92b2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -60,6 +60,7 @@ revert.banner.filesNotRestored=Snapshots are off - only the conversation was rev revert.banner.openDiff.title=Rolled back changes revert.message.rollback=Rollback to this message session.status.rollingback=Rolling back\u2026 +session.status.retrying=Retrying\u2026 session.status.redoing=Redoing\u2026 session.status.operation.finishing=Waiting for the operation to finish\u2026 session.error.revert.timeout=Operation timed out. Waiting for it to finish before continuing. @@ -214,6 +215,7 @@ 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.retry=Retry session.outcome.interrupted.note=Stopped session.login.required.title=You need to sign in to use this model diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index aa1efe62008..55522110ac7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -56,6 +56,7 @@ session.status.searching.web=جار البحث على الويب… session.status.editing=جاري التحرير… session.status.commands=جاري تنفيذ الأوامر… session.status.rollingback=جار التراجع… +session.status.retrying=Retrying\u2026 session.status.redoing=جار الإعادة… session.status.operation.finishing=في انتظار انتهاء العملية… session.error.revert.timeout=انتهت مهلة العملية. جار انتظار انتهائها قبل المتابعة. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=خطأ غير معروف session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=الرموز diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 708084f8802..301f8c2fec6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -56,6 +56,7 @@ session.status.searching.web=Pretraživanje weba… session.status.editing=Uređivanje… session.status.commands=Pokretanje komandi… session.status.rollingback=Vraćanje unazad… +session.status.retrying=Retrying\u2026 session.status.redoing=Ponovno izvršavanje… session.status.operation.finishing=Čeka se završetak operacije… session.error.revert.timeout=Operacija je istekla. Čeka se da završi prije nastavka. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Nepoznata greška session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokeni diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 01e27a1be91..f10dc7e7b84 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -56,6 +56,7 @@ session.status.searching.web=Søger på nettet… session.status.editing=Foretager redigeringer… session.status.commands=Kører kommandoer… session.status.rollingback=Ruller tilbage… +session.status.retrying=Retrying\u2026 session.status.redoing=Gentager… session.status.operation.finishing=Venter på, at handlingen afsluttes… session.error.revert.timeout=Handlingen fik timeout. Venter på, at den afsluttes, før der fortsættes. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Ukendt fejl session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 5fcd307bab1..7df185f3aed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web durchsuchen… session.status.editing=Änderungen vornehmen… session.status.commands=Befehle ausführen… session.status.rollingback=Rollback wird ausgeführt… +session.status.retrying=Retrying\u2026 session.status.redoing=Wird wiederholt… session.status.operation.finishing=Warten, bis der Vorgang abgeschlossen ist… session.error.revert.timeout=Zeitüberschreitung beim Vorgang. Es wird gewartet, bis er abgeschlossen ist, bevor fortgefahren wird. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Unbekannter Fehler session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Token diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 1dbb668d5c8..8b6d662355f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -56,6 +56,7 @@ session.status.searching.web=Buscando en la web… session.status.editing=Realizando ediciones… session.status.commands=Ejecutando comandos… session.status.rollingback=Revirtiendo… +session.status.retrying=Retrying\u2026 session.status.redoing=Rehaciendo… session.status.operation.finishing=Esperando a que finalice la operación… session.error.revert.timeout=La operación ha agotado el tiempo. Esperando a que finalice antes de continuar. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Error desconocido session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index fb8e342b9e6..40c1aed80c2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -56,6 +56,7 @@ session.status.searching.web=Recherche sur le web… session.status.editing=Modifications en cours… session.status.commands=Exécution des commandes… session.status.rollingback=Retour en arrière… +session.status.retrying=Retrying\u2026 session.status.redoing=Rétablissement… session.status.operation.finishing=En attente de la fin de l’opération… session.error.revert.timeout=L’opération a expiré. Attente de sa fin avant de continuer. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Erreur inconnue session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 67f37670258..d1e39040ee3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -56,6 +56,7 @@ session.status.searching.web=ウェブを検索中… session.status.editing=編集中… session.status.commands=コマンドを実行中… session.status.rollingback=ロールバック中… +session.status.retrying=Retrying\u2026 session.status.redoing=やり直し中… session.status.operation.finishing=操作の完了を待機しています… session.error.revert.timeout=操作がタイムアウトしました。続行する前に完了を待機しています。 @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=不明なエラー session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=トークン diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 222a0ad1650..78c97e633f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -56,6 +56,7 @@ session.status.searching.web=웹 검색 중… session.status.editing=편집 중… session.status.commands=명령 실행 중… session.status.rollingback=롤백 중… +session.status.retrying=Retrying\u2026 session.status.redoing=다시 실행 중… session.status.operation.finishing=작업이 완료될 때까지 기다리는 중… session.error.revert.timeout=작업 시간이 초과되었습니다. 계속하기 전에 완료될 때까지 기다리는 중입니다. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=알 수 없는 오류 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=토큰 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index 8866d9325cd..ea86c878b01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web doorzoeken… session.status.editing=Bewerkingen uitvoeren… session.status.commands=Opdrachten uitvoeren… session.status.rollingback=Terugdraaien… +session.status.retrying=Retrying\u2026 session.status.redoing=Opnieuw uitvoeren… session.status.operation.finishing=Wachten tot de bewerking is voltooid… session.error.revert.timeout=Time-out van bewerking. Wachten tot deze is voltooid voordat wordt doorgegaan. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Onbekende fout session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 90c0147c566..d7d4d01344a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -56,6 +56,7 @@ session.status.searching.web=Søker på nettet… session.status.editing=Gjør redigeringer… session.status.commands=Kjører kommandoer… session.status.rollingback=Ruller tilbake… +session.status.retrying=Retrying\u2026 session.status.redoing=Gjør om… session.status.operation.finishing=Venter på at operasjonen skal fullføres… session.error.revert.timeout=Operasjonen tidsavbrøt. Venter på at den skal fullføres før vi fortsetter. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Ukjent feil session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 444c3da960a..140f89f54bd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -56,6 +56,7 @@ session.status.searching.web=Przeszukiwanie sieci… session.status.editing=Dokonywanie edycji… session.status.commands=Uruchamianie poleceń… session.status.rollingback=Wycofywanie… +session.status.retrying=Retrying\u2026 session.status.redoing=Ponawianie… session.status.operation.finishing=Oczekiwanie na zakończenie operacji… session.error.revert.timeout=Upłynął limit czasu operacji. Oczekiwanie na jej zakończenie przed kontynuacją. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Nieznany błąd session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokeny diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index b3094af3c0f..ed51cb5a9bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -56,6 +56,7 @@ session.status.searching.web=Pesquisando na web… session.status.editing=Realizando edições… session.status.commands=Executando comandos… session.status.rollingback=Revertendo… +session.status.retrying=Retrying\u2026 session.status.redoing=Refazendo… session.status.operation.finishing=Aguardando a operação terminar… session.error.revert.timeout=A operação atingiu o tempo limite. Aguardando sua conclusão antes de continuar. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Erro desconhecido session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Tokens diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 3781698af25..94c8e65c0da 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -56,6 +56,7 @@ session.status.searching.web=Поиск в интернете… session.status.editing=Вношу изменения… session.status.commands=Выполняю команды… session.status.rollingback=Выполняется откат… +session.status.retrying=Retrying\u2026 session.status.redoing=Повторное применение… session.status.operation.finishing=Ожидание завершения операции… session.error.revert.timeout=Время ожидания операции истекло. Ждем ее завершения перед продолжением. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Неизвестная ошибка session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Токены diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index c37242ada82..031d159ed17 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -56,6 +56,7 @@ session.status.searching.web=กำลังค้นหาบนเว็บ… session.status.editing=กำลังแก้ไข… session.status.commands=กำลังเรียกใช้คำสั่ง… session.status.rollingback=กำลังย้อนกลับ… +session.status.retrying=Retrying\u2026 session.status.redoing=กำลังทำซ้ำ… session.status.operation.finishing=กำลังรอให้การดำเนินการเสร็จสิ้น… session.error.revert.timeout=การดำเนินการหมดเวลา กำลังรอให้เสร็จสิ้นก่อนดำเนินการต่อ @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=ข้อผิดพลาดที่ไม่ทราบ session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=โทเคน diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index b459352c7b0..edfdcafd34d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web aranıyor… session.status.editing=Düzenleme yapılıyor… session.status.commands=Komutlar çalıştırılıyor… session.status.rollingback=Geri alınıyor… +session.status.retrying=Retrying\u2026 session.status.redoing=Yeniden uygulanıyor… session.status.operation.finishing=İşlemin tamamlanması bekleniyor… session.error.revert.timeout=İşlem zaman aşımına uğradı. Devam etmeden önce tamamlanması bekleniyor. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Bilinmeyen hata session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Jeton diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index c4a4af08617..51b50da55d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -56,6 +56,7 @@ session.status.searching.web=Шукаю в інтернеті… session.status.editing=Вношу зміни… session.status.commands=Виконую команди… session.status.rollingback=Виконується відкат… +session.status.retrying=Retrying\u2026 session.status.redoing=Повторне застосування… session.status.operation.finishing=Очікування завершення операції… session.error.revert.timeout=Час очікування операції минув. Очікуємо її завершення перед продовженням. @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=Невідома помилка session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=Токени diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 809ef2d63d0..7c792927758 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -56,6 +56,7 @@ session.status.searching.web=搜索网页… session.status.editing=正在编辑… session.status.commands=运行命令… session.status.rollingback=正在回滚… +session.status.retrying=Retrying\u2026 session.status.redoing=正在重做… session.status.operation.finishing=正在等待操作完成… session.error.revert.timeout=操作超时。继续前正在等待其完成。 @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=未知错误 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=令牌 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index cbe07bedbe7..c135ded0646 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -56,6 +56,7 @@ session.status.searching.web=搜尋網頁… session.status.editing=進行編輯… session.status.commands=執行指令… session.status.rollingback=正在復原… +session.status.retrying=Retrying\u2026 session.status.redoing=正在重做… session.status.operation.finishing=正在等待操作完成… session.error.revert.timeout=操作逾時。繼續前正在等待其完成。 @@ -77,6 +78,7 @@ session.error.title=Request failed session.error.unknown=未知錯誤 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed +session.outcome.retry=Retry session.outcome.interrupted.note=Stopped session.header.tokens=記號 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt new file mode 100644 index 00000000000..05fbb4809dc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt @@ -0,0 +1,149 @@ +package ai.kilocode.client.session.controller + +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.SessionStatusDto +import kotlinx.coroutines.CompletableDeferred + +/** + * Retry replays the last user turn after a failure: revert to the failed assistant message (which + * restores files when that turn edited any), then re-prompt reusing the original user message id so no + * synthetic message is appended. The failed message itself is removed server-side by + * `SessionRevert.cleanup` on the prompt that follows. + */ +class SessionRetryTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + rpc.session = rpc.session.copy(id = "ses_test") + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + } + + private fun failed(error: MessageErrorDto? = MessageErrorDto(type = "APIError", message = "provider overloaded")) { + // The model/agent a turn ran with live on the user message, which is what the replay reuses. + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = error), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady() + } + + fun `test retry reverts the failed turn then replays the user message`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertEquals(1, rpc.reverts.size) + val revert = rpc.reverts.single() + assertEquals("ses_test", revert.id) + assertEquals("msg_fail", revert.message) + assertNull("Reverting the whole message, not truncating its parts", revert.part) + + assertEquals(1, rpc.prompts.size) + val prompt = rpc.prompts.single().third + assertEquals("Replays the existing user message, no synthetic one", "msg_user", prompt.messageID) + assertTrue("An empty part list leaves the original user parts intact", prompt.parts.isEmpty()) + assertEquals("kilo", prompt.providerID) + assertEquals("gpt-5", prompt.modelID) + assertEquals("code", prompt.agent) + } + + fun `test retry does not prompt until the revert completes`() { + failed() + val gate = CompletableDeferred() + rpc.revertGate = gate + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("The prompt must not race the workspace restore", rpc.prompts.isEmpty()) + assertTrue(m.model.state is SessionState.Reverting) + + gate.complete(Unit) + flush() + + assertEquals(1, rpc.reverts.size) + assertEquals(1, rpc.prompts.size) + } + + fun `test retry lands on busy not idle`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("Retry must hand off to the running turn", m.model.state is SessionState.Busy) + } + + fun `test retry is unavailable after a user stop`() { + failed(MessageErrorDto(type = MessageErrorDto.ABORTED, message = "aborted")) + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("A stop is not a failure", rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry is unavailable while the session is busy`() { + failed() + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry is unavailable when the tail is not an assistant turn`() { + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry surfaces an error when the revert fails`() { + failed() + rpc.revertThrows = RuntimeException("snapshot unavailable") + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue(rpc.prompts.isEmpty()) + val state = m.model.state + assertTrue("A failed revert must stay visible", state is SessionState.Error) + assertEquals("snapshot unavailable", (state as SessionState.Error).message) + } +} 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 index 8a6c19b248d..683a182b061 100644 --- 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 @@ -17,6 +17,7 @@ import java.awt.Dimension import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import javax.swing.Icon +import javax.swing.JButton import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -130,6 +131,69 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { } } + // ------ retry action ------ + + fun `test error card offers retry`() { + edt { + var clicked = 0 + val view = SessionOutcomeView(retry = { clicked++ }) + view.showError("Provider balance is too low", "APIError") + + val button = retryButton(view) + assertNotNull("Error card should offer Retry", button) + button!!.doClick() + assertEquals(1, clicked) + } + } + + fun `test failed outcome offers retry`() { + edt { + val view = SessionOutcomeView(retry = {}) + view.showOutcome(Outcome.FAILED) + + assertNotNull("Failed outcome should offer Retry", retryButton(view)) + } + } + + fun `test interrupted note offers no retry`() { + edt { + val view = SessionOutcomeView(retry = {}) + view.showOutcome(Outcome.INTERRUPTED) + + assertNull("A user stop is not a failure and must not offer Retry", retryButton(view)) + } + } + + fun `test readonly outcome view offers no retry`() { + edt { + val view = SessionOutcomeView(retry = null) + view.showError("Provider balance is too low", "APIError") + + assertNull("Readonly sessions cannot retry", retryButton(view)) + } + } + + fun `test toggling outcomes does not accumulate retry buttons`() { + edt { + var clicked = 0 + val view = SessionOutcomeView(retry = { clicked++ }) + repeat(3) { + view.showOutcome(Outcome.FAILED) + view.showOutcome(Outcome.INTERRUPTED) + } + assertNull("The note detaches the footer entirely", retryButton(view)) + view.showOutcome(Outcome.FAILED) + + val buttons = findAll(view).filter { it.text == KiloBundle.message("session.outcome.retry") } + assertEquals("Exactly one live Retry button", 1, buttons.size) + buttons.single().doClick() + assertEquals("The live button is wired to the current handler", 1, clicked) + } + } + + private fun retryButton(root: Container) = + findAll(root).firstOrNull { it.text == KiloBundle.message("session.outcome.retry") } + fun `test hideView makes view invisible`() { edt { val view = SessionOutcomeView() @@ -301,8 +365,9 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { private fun findAllCls(root: Container, cls: Class): List { val result = mutableListOf() if (cls.isInstance(root)) result.add(cls.cast(root)) + // Only recurse. Matching a child here as well would double-count any hit that is itself a + // Container (every Swing component is), because the recursive call re-checks it as its own 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 diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 9b088be76b1..455f46de9ec 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -225,6 +225,41 @@ export namespace KiloSessionPrompt { }, ) + /** + * Removes a failed assistant tail that produced nothing the user can see, so the next prompt does not + * append after an "An error occurred" shell. The error itself has already been surfaced to clients via + * `session.error` and the outcome card. + * + * Distinct from [recoverProviderFinishError], which handles a `finish === "error"` tail carrying no + * `info.error`. This one is the inverse: `info.error` is set. + * + * The parts guard is an allowlist of turn scaffolding on purpose. A turn that emitted text or + * reasoning, or ran a tool, keeps its message: that record is what explains file changes which are + * still applied on disk. Any part type not listed here blocks removal, so a new part type fails safe. + */ + export const recoverFailedAssistant = Effect.fn("KiloSessionPrompt.recoverFailedAssistant")(function* (input: { + sessionID: SessionID + status: Pick + sessions: Pick + }) { + const state = yield* input.status.get(input.sessionID) + if (state.type !== "idle") return + + const msgs = yield* input.sessions.messages({ sessionID: input.sessionID, limit: 2 }) + const tail = msgs.at(-1) + if (!tail || tail.info.role !== "assistant") return + if (!tail.info.error) return + // A user Stop is not a failure. Its record is what clients read back to show "Stopped", so it stays. + if (MessageV2.AbortedError.isInstance(tail.info.error)) return + if (!tail.parts.every((part) => part.type === "step-start" || part.type === "step-finish")) return + + const prev = msgs.at(-2) + if (!prev || prev.info.role !== "user") return + if (tail.info.parentID !== prev.info.id) return + + yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id }) + }) + export function guardPermissions(input: { agent: { name: string; permission: Permission.Ruleset } session: Pick diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9c9ceb4106c..979cde0859d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1397,6 +1397,7 @@ export const layer = Layer.effect( // kilocode_change start - recover interrupted Kilo turns before accepting a follow-up yield* KiloSessionPrompt.recoverDanglingAssistant({ sessionID: input.sessionID, status, sessions }) yield* KiloSessionPrompt.recoverProviderFinishError({ sessionID: input.sessionID, status, sessions }) + yield* KiloSessionPrompt.recoverFailedAssistant({ sessionID: input.sessionID, status, sessions }) // kilocode_change end const message = yield* KiloSessionPrompt.intake(input.sessionID, createUserMessage(input)) // kilocode_change yield* sessions.touch(input.sessionID) @@ -1906,6 +1907,7 @@ export const layer = Layer.effect( const session = yield* sessions.get(input.sessionID) yield* KiloSessionPrompt.recoverDanglingAssistant({ sessionID: input.sessionID, status, sessions }) yield* KiloSessionPrompt.recoverProviderFinishError({ sessionID: input.sessionID, status, sessions }) + yield* KiloSessionPrompt.recoverFailedAssistant({ sessionID: input.sessionID, status, sessions }) yield* KiloSession.publishTurnOpen({ sessionID: input.sessionID }) return yield* Effect.onExit( state.ensureRunning( diff --git a/packages/opencode/test/kilocode/session/recover-failed-assistant.test.ts b/packages/opencode/test/kilocode/session/recover-failed-assistant.test.ts new file mode 100644 index 00000000000..c746f5d305f --- /dev/null +++ b/packages/opencode/test/kilocode/session/recover-failed-assistant.test.ts @@ -0,0 +1,191 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { MessageV2 } from "@/session/message-v2" +import { KiloSessionPrompt } from "@/kilocode/session/prompt" +import { MessageID, PartID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { testEffect } from "../../lib/effect" + +const env = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, SessionStatus.node])) +const it = testEffect(env) + +const providerID = ProviderV2.ID.make("test") + +/** + * Builds a session whose tail is an assistant message carrying [error], plus whatever [parts] the turn + * managed to emit before failing. Returns the ids so a test can assert what survived. + */ +const seed = Effect.fnUntraced(function* (input: { + error?: NonNullable + parts?: ("step-start" | "step-finish" | "text" | "tool")[] + finish?: MessageV2.Assistant["finish"] + orphan?: boolean +}) { + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "user", + agent: "default", + model: { providerID, modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: session.id, + type: "text", + text: "do the thing", + }) + + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + // An orphan tail points at nothing, so the seam must leave it alone. + parentID: input.orphan ? MessageID.ascending() : user.id, + sessionID: session.id, + role: "assistant", + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID, + time: { created: Date.now(), completed: Date.now() }, + ...(input.error ? { error: input.error } : {}), + ...(input.finish ? { finish: input.finish } : {}), + }) + + for (const type of input.parts ?? []) { + const base = { id: PartID.ascending(), messageID: assistant.id, sessionID: session.id } + if (type === "step-start") yield* sessions.updatePart({ ...base, type: "step-start" }) + if (type === "step-finish") + yield* sessions.updatePart({ + ...base, + type: "step-finish", + reason: "error", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + if (type === "text") yield* sessions.updatePart({ ...base, type: "text", text: "partial answer" }) + if (type === "tool") + yield* sessions.updatePart({ + ...base, + type: "tool", + tool: "edit", + callID: "call_1", + state: { + status: "completed", + input: {}, + output: "ok", + title: "edit", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }) + } + + return { sessionID: session.id, userID: user.id, assistantID: assistant.id } +}) + +const run = Effect.fnUntraced(function* (sessionID: Session.Info["id"]) { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + yield* KiloSessionPrompt.recoverFailedAssistant({ sessionID, status, sessions }) + const msgs = yield* sessions.messages({ sessionID }) + return msgs.map((m) => m.info.id) +}) + +const apiError = new MessageV2.APIError({ message: "provider overloaded", isRetryable: true }).toObject() + +describe("KiloSessionPrompt.recoverFailedAssistant", () => { + it.instance("removes an errored tail that only emitted turn scaffolding", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError, parts: ["step-start", "step-finish"] }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID]) + }), + ) + + it.instance("removes an errored tail with no parts at all", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID]) + }), + ) + + it.instance("keeps an errored tail that emitted text", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError, parts: ["step-start", "text"] }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) + + it.instance("keeps an errored tail that ran a tool, whose edits may still be on disk", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError, parts: ["step-start", "tool"] }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) + + it.instance("keeps a user-aborted tail, which is a stop rather than a failure", () => + Effect.gen(function* () { + const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() + const seeded = yield* seed({ error: aborted, parts: ["step-start"] }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) + + it.instance("leaves a tail with no error to the other recover seams", () => + Effect.gen(function* () { + const seeded = yield* seed({ finish: "error", parts: ["step-start", "step-finish"] }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) + + it.instance("keeps an errored tail whose parent is not the preceding user message", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError, parts: ["step-start"], orphan: true }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) + + it.instance("keeps an errored tail while the session is still working", () => + Effect.gen(function* () { + const seeded = yield* seed({ error: apiError, parts: ["step-start"] }) + const status = yield* SessionStatus.Service + yield* status.set(seeded.sessionID, { type: "busy" }) + + const remaining = yield* run(seeded.sessionID) + + expect(remaining).toEqual([seeded.userID, seeded.assistantID]) + }), + ) +}) From 13a8c29a7c52ce4469a9cc39f4a7f45cb10e149a Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 14:16:04 -0400 Subject: [PATCH 3/7] fix(jetbrains): retry with the currently selected model and effort Retry replayed the model, agent and effort recorded on the failed turn, so switching away from a broken model and pressing Retry just failed the same way. It now resolves model/agent/effort from the live selection the way a normal send does, falling back to the recorded values when no selection has resolved yet. Login resume keeps using the recorded model: the user authenticated for the model that demanded it, so substituting the current selection there would silently run a different one. --- .../session/controller/SessionController.kt | 26 +++++- .../session/controller/SessionRetryTest.kt | 90 ++++++++++++++++++- 2 files changed, 114 insertions(+), 2 deletions(-) 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 958b2e331b3..eab5eab455a 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 @@ -558,7 +558,7 @@ class SessionController( val user = msgs.getOrNull(msgs.size - 2)?.info ?: return null if (user.role != "user") return null if (tail.info.parentID != user.id) return null - val prompt = retryPrompt() ?: return null + val prompt = retryPromptCurrent() ?: return null if (prompt.messageID != user.id) return null return RetryTarget(tail.info.id, prompt) } @@ -1953,6 +1953,12 @@ class SessionController( } } + /** + * Replays the last user message with the agent/model recorded on it. + * + * Login resume needs exactly this: the user authenticated for the model that demanded it, so + * resuming must use that model rather than whatever is selected now. + */ private fun retryPrompt(): PromptDto? { val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null return PromptDto( @@ -1966,6 +1972,24 @@ class SessionController( ) } + /** + * Like [retryPrompt], but honours the *current* model/agent/effort selection. + * + * A turn usually fails because of the model it ran with — missing credentials, provider overload, + * context limit — so switching model or effort and pressing Retry has to pick that change up. + * Resolution mirrors [promptDto]; the recorded values are only a fallback for when no selection has + * resolved yet. + */ + private fun retryPromptCurrent(): PromptDto? { + val base = retryPrompt() ?: return null + val sel = model.model?.let(::parseModel) + return base.copy( + providerID = sel?.first ?: base.providerID, + modelID = sel?.second ?: base.modelID, + agent = model.agent ?: base.agent, + ) + } + private fun resumeAfterLogin() { assertEdt() val retry = loginRetry diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt index 05fbb4809dc..3302b0b66a5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt @@ -6,6 +6,8 @@ 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.ModelDto +import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.SessionStatusDto import kotlinx.coroutines.CompletableDeferred @@ -23,6 +25,27 @@ class SessionRetryTest : SessionControllerTestBase() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) } + /** Two connected models so a test can switch selection after the failure. */ + private fun providers() = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "gpt-5" to ModelDto( + id = "gpt-5", + name = "GPT-5", + reasoning = true, + variants = listOf("low", "high"), + ), + ), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude-opus-5" to ModelDto(id = "claude-opus-5", name = "Claude Opus 5")), + ), + ) + private fun failed(error: MessageErrorDto? = MessageErrorDto(type = "APIError", message = "provider overloaded")) { // The model/agent a turn ran with live on the user message, which is what the replay reuses. rpc.history.add( @@ -37,7 +60,7 @@ class SessionRetryTest : SessionControllerTestBase() { emptyList(), ), ) - projectRpc.state.value = workspaceReady() + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) } fun `test retry reverts the failed turn then replays the user message`() { @@ -63,6 +86,71 @@ class SessionRetryTest : SessionControllerTestBase() { assertEquals("code", prompt.agent) } + fun `test retry uses the model selected after the failure`() { + failed() + val m = controller("ses_test") + flush() + + // The usual reason a turn fails is the model it ran with, so switching model and hitting Retry + // has to pick the new one up rather than replaying the one that just failed. + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + assertEquals("Still replays the original user message", "msg_user", prompt.messageID) + } + + fun `test retry uses the effort selected after the failure`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.selectVariant("high") } + flush() + edt { m.retry() } + flush() + + assertEquals("high", rpc.prompts.single().third.variant) + } + + /** + * Guards the distinction from login resume, which must keep the model recorded on the failed turn — + * the user authenticated for that model. Only Retry follows the live selection. + */ + fun `test retry follows the live selection even when it differs from the failed turn`() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy( + parentID = "msg_user", + error = MessageErrorDto(type = "APIError", message = "missing credentials"), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) + val m = controller("ses_test") + flush() + + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + } + fun `test retry does not prompt until the revert completes`() { failed() val gate = CompletableDeferred() From 0d6f87db6ff43b9f5aa5c8833cfa4017136966e9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 14:30:43 -0400 Subject: [PATCH 4/7] test(jetbrains): cover retry with a slash-containing auto model id The auto-routing selection is kilo/kilo-auto/free, so the model id itself contains a slash and only the provider may be split off the front. Pins that parseModel keeps the remainder intact on the retry path. --- .../session/controller/SessionRetryTest.kt | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt index 3302b0b66a5..7afc6a1306f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt @@ -151,6 +151,55 @@ class SessionRetryTest : SessionControllerTestBase() { assertEquals("claude-opus-5", prompt.modelID) } + /** The auto-routing model id contains a slash ("kilo-auto/free"), so only the provider may split off. */ + fun `test retry uses an auto routing model whose id contains a slash`() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy( + providerID = "snowflake", + modelID = "cortex", + agent = "code", + ), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy( + parentID = "msg_user", + error = MessageErrorDto(type = "UnknownError", message = "missing credentials"), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("kilo-auto/free" to ModelDto(id = "kilo-auto/free", name = "Auto Free")), + ), + ProviderDto( + id = "snowflake", + name = "Snowflake", + models = mapOf("cortex" to ModelDto(id = "cortex", name = "Cortex")), + ), + ), + connected = listOf("kilo", "snowflake"), + ) + val m = controller("ses_test") + flush() + + edt { m.selectModel("kilo", "kilo-auto/free") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("kilo", prompt.providerID) + assertEquals("kilo-auto/free", prompt.modelID) + } + fun `test retry does not prompt until the revert completes`() { failed() val gate = CompletableDeferred() From 36efe6a1c49d662ad7de50bf98f9c9a72d0f172e Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 15:17:55 -0400 Subject: [PATCH 5/7] fix(jetbrains): retry unanswered failed turns --- .../jetbrains-stopped-session-not-an-error.md | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 1 + .../session/controller/SessionController.kt | 52 ++++--- .../session/views/SessionOutcomeView.kt | 10 +- .../session/controller/SessionRetryTest.kt | 133 +++++++++++++++++- .../session/views/SessionOutcomeViewTest.kt | 33 +++++ 6 files changed, 208 insertions(+), 23 deletions(-) diff --git a/.changeset/jetbrains-stopped-session-not-an-error.md b/.changeset/jetbrains-stopped-session-not-an-error.md index cb9e8e15bfe..f4fc55352aa 100644 --- a/.changeset/jetbrains-stopped-session-not-an-error.md +++ b/.changeset/jetbrains-stopped-session-not-an-error.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": minor --- -Stop treating a manually stopped session as a failure, and add a Retry action to failed turns. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot. A turn that fails from a provider error keeps the error badge and card, and can be retried in place: the failed turn is rolled back and the same request re-runs with the same model. +Stop treating a manually stopped session as a failure, and add a Retry action to failed turns. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot. A failed turn keeps the error badge and card and can be retried in place, using the model and effort selected at that moment — so switching away from an unavailable provider and pressing Retry continues the conversation. This includes failures that never produced a reply, such as missing provider credentials. 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 fc3fd4bc9b5..b340f6c8be6 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 @@ -401,6 +401,7 @@ class SessionUi( selection = selection, focus = focus, retry = if (readonly) null else controller::retry, + retryable = controller::canRetry, ) messageBody = SessionMessageListPanel( controller.model, 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 eab5eab455a..c79e3c282b6 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 @@ -494,12 +494,15 @@ class SessionController( * files (a no-op server-side when it edited nothing), and the prompt that follows is what actually * removes the message: `SessionRevert.cleanup` drops everything at or after the revert target on the * next prompt. The replay reuses the original user message id, so no synthetic message is appended. + * + * A turn that failed before the assistant message existed (model resolution, missing provider + * credentials) has nothing to roll back, so that path skips the revert and only replays. */ fun retry() { assertEdt() val id = sid ?: return val target = retryTarget() ?: return - LOG.info("${ChatLogSummary.sid(id)} kind=retry clicked=true message=${target.assistant}") + LOG.info("${ChatLogSummary.sid(id)} kind=retry clicked=true message=${target.assistant ?: "none"}") val op = beginReverting( KiloBundle.message("session.status.retrying"), // No rollback marker: the transcript should not paint the failed turn as a revert target, @@ -509,9 +512,11 @@ class SessionController( ) ?: return revertJob = cs.launch { try { - sessions.revert(id, directory, target.assistant, null) - capture("Session Retry", sessionProps(id)) - synchronizeFromDisk(id, "retry") + target.assistant?.let { + sessions.revert(id, directory, it, null) + synchronizeFromDisk(id, "retry") + } + capture("Session Retry", sessionProps(id) + mapOf("rolledBack" to (target.assistant != null).toString())) edt { if (disposed) return@edt clearReverting(op) @@ -535,35 +540,44 @@ class SessionController( } } + /** Whether the error card should offer Retry. Gates the action so it is never painted as a no-op. */ + @RequiresEdt + fun canRetry(): Boolean = retryTarget() != null + /** * The failed tail turn to replay, or null when retry does not apply: no session, an operation already - * in flight, a busy session, or a tail that is not an assistant turn that failed off the last user - * message. + * in flight, a busy session, a turn that did not fail, or a tail that is neither the last user message + * nor the assistant that failed answering it. */ private fun retryTarget(): RetryTarget? { assertEdt() if (sid == null) return null if (revertOp != null) return null if (model.state.isBusy()) return null - val msgs = model.messages().toList() - val tail = msgs.lastOrNull() ?: return null - if (tail.info.role != "assistant") return null - // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. + val tail = model.messages().lastOrNull() ?: return null + val err = tail.info.error val state = model.state - val failed = tail.info.error?.aborted == false || - (tail.info.error == null && - (state is SessionState.Error || - (state is SessionState.TurnEnded && state.outcome == Outcome.FAILED))) + val failed = when { + // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. + err != null -> !err.aborted + // A turn that completed cleanly is not retryable even when a session-level error arrives + // afterwards: replaying it would revert work the model actually delivered. + tail.info.role == "assistant" && tail.info.time.completed != null -> false + else -> state is SessionState.Error || + (state is SessionState.TurnEnded && state.outcome == Outcome.FAILED) + } if (!failed) return null - val user = msgs.getOrNull(msgs.size - 2)?.info ?: return null - if (user.role != "user") return null - if (tail.info.parentID != user.id) return null val prompt = retryPromptCurrent() ?: return null - if (prompt.messageID != user.id) return null + // The failure hit before the assistant message existed — model resolution and provider + // credentials are checked ahead of it — so the user turn is the tail and nothing needs rolling + // back. + if (tail.info.id == prompt.messageID) return RetryTarget(null, prompt) + if (tail.info.role != "assistant") return null + if (tail.info.parentID != prompt.messageID) return null return RetryTarget(tail.info.id, prompt) } - private data class RetryTarget(val assistant: String, val prompt: PromptDto) + private data class RetryTarget(val assistant: String?, val prompt: PromptDto) fun deleteQueuedMessage(message: String) { assertEdt() 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 index d3340e1e5a1..46397ac1e82 100644 --- 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 @@ -21,6 +21,7 @@ class SessionOutcomeView( selection: SessionSelection? = null, focus: (() -> Unit)? = null, private val retry: (() -> Unit)? = null, + private val retryable: (() -> Boolean)? = null, ) : DialogView(selection, focus), SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -74,11 +75,16 @@ class SessionOutcomeView( refresh() } - /** Retry belongs to failures only; a user-initiated stop stays a plain note with no controls. */ + /** + * Retry belongs to failures only; a user-initiated stop stays a plain note with no controls. + * + * [retryable] is asked on every show because the answer depends on the transcript tail, not on the + * outcome alone: a session-level error that arrived after a completed turn has nothing to replay. + */ @RequiresEdt private fun syncRetry(show: Boolean) { val run = retry - if (run == null || !show) { + if (run == null || !show || retryable?.invoke() == false) { setActions(emptyList()) return } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt index 7afc6a1306f..eb98b9e1edc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt @@ -1,10 +1,12 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto 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.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto @@ -63,6 +65,27 @@ class SessionRetryTest : SessionControllerTestBase() { projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) } + /** + * A turn that never reached the model has no assistant message at all: the CLI resolves the model + * (and its credentials) before writing one, so the transcript tail is the user message. + */ + private fun unanswered() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "snowflake", modelID = "cortex", agent = "code"), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady( + providers = providers() + ProviderDto( + id = "snowflake", + name = "Snowflake", + models = mapOf("cortex" to ModelDto(id = "cortex", name = "Cortex")), + ), + connected = listOf("kilo", "anthropic", "snowflake"), + ) + } + fun `test retry reverts the failed turn then replays the user message`() { failed() val m = controller("ses_test") @@ -256,19 +279,127 @@ class SessionRetryTest : SessionControllerTestBase() { assertTrue(rpc.prompts.isEmpty()) } - fun `test retry is unavailable when the tail is not an assistant turn`() { + fun `test retry is unavailable when nothing failed`() { rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) projectRpc.state.value = workspaceReady() val m = controller("ses_test") flush() + edt { assertFalse(m.canRetry()) } + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + /** + * Missing provider credentials fail during model resolution, before the assistant message exists, so + * the failure only surfaces as a session error over a user-message tail. There is nothing to roll + * back — Retry must still replay, otherwise the card's only action is dead. + */ + fun `test retry replays a turn that failed before the assistant message existed`() { + unanswered() + val m = controller("ses_test") + flush() + emit( + ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "UnknownError", message = "Snowflake Cortex: missing credentials"), + ), + ) + + edt { assertTrue(m.canRetry()) } + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + assertTrue("Nothing was produced, so there is no message to roll back", rpc.reverts.isEmpty()) + val prompt = rpc.prompts.single().third + assertEquals("Replays the existing user message, no synthetic one", "msg_user", prompt.messageID) + assertTrue(prompt.parts.isEmpty()) + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + assertTrue("Retry must hand off to the running turn", m.model.state is SessionState.Busy) + } + + /** The same failure also arrives as a turn close with reason "error" when no session error follows. */ + fun `test retry replays an unanswered turn reported only by turn close`() { + unanswered() + val m = controller("ses_test") + flush() + emit(ChatEventDto.TurnClose("ses_test", "error")) + + // Switch off the model that could not authenticate, then raise its effort. + edt { m.selectModel("kilo", "gpt-5") } + flush() + edt { m.selectVariant("high") } + flush() edt { m.retry() } flush() assertTrue(rpc.reverts.isEmpty()) + val prompt = rpc.prompts.single().third + assertEquals("msg_user", prompt.messageID) + assertEquals("kilo", prompt.providerID) + assertEquals("gpt-5", prompt.modelID) + assertEquals("Effort switched after the failure has to reach the replay", "high", prompt.variant) + } + + /** + * A session-level error (a bad config, a plugin failure) can land after a turn that delivered its + * answer. Retrying then would revert real work, so the card must not offer it. + */ + fun `test retry is unavailable when the last turn completed`() { + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + rpc.history.add( + MessageWithPartsDto( + msg("msg_ok", "ses_test", "assistant").copy( + parentID = "msg_user", + time = MessageTimeDto(created = 0.0, completed = 1.0), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) + val m = controller("ses_test") + flush() + emit(ChatEventDto.Error(null, MessageErrorDto(type = "UnknownError", message = "invalid kilo.json"))) + + edt { assertFalse(m.canRetry()) } + edt { m.retry() } + flush() + + assertTrue("A completed turn must not be rolled back", rpc.reverts.isEmpty()) assertTrue(rpc.prompts.isEmpty()) } + fun `test retry is unavailable when the session has no user message`() { + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + emit(ChatEventDto.Error(null, MessageErrorDto(type = "UnknownError", message = "invalid kilo.json"))) + + edt { assertFalse("Nothing to replay, so the card must not offer Retry", m.canRetry()) } + } + + fun `test retry is offered for a failed assistant turn`() { + failed() + val m = controller("ses_test") + flush() + + edt { assertTrue(m.canRetry()) } + } + + fun `test retry is not offered after a user stop`() { + failed(MessageErrorDto(type = MessageErrorDto.ABORTED, message = "aborted")) + val m = controller("ses_test") + flush() + + edt { assertFalse(m.canRetry()) } + } + fun `test retry surfaces an error when the revert fails`() { failed() rpc.revertThrows = RuntimeException("snapshot unavailable") 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 index 683a182b061..1b7cfc4cdaf 100644 --- 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 @@ -173,6 +173,39 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { } } + fun `test error card hides retry when the transcript has nothing to replay`() { + edt { + val view = SessionOutcomeView(retry = {}, retryable = { false }) + view.showError("invalid kilo.json", "UnknownError") + + assertNull("A dead Retry must not be painted", retryButton(view)) + } + } + + fun `test failed outcome hides retry when the transcript has nothing to replay`() { + edt { + val view = SessionOutcomeView(retry = {}, retryable = { false }) + view.showOutcome(Outcome.FAILED) + + assertNull(retryButton(view)) + } + } + + fun `test retry appears once the transcript becomes replayable`() { + edt { + var replayable = false + val view = SessionOutcomeView(retry = {}, retryable = { replayable }) + view.showError("Provider balance is too low", "APIError") + assertNull(retryButton(view)) + + replayable = true + view.showError("Provider balance is too low", "APIError") + + val buttons = findAll(view).filter { it.text == KiloBundle.message("session.outcome.retry") } + assertEquals("Exactly one live Retry button", 1, buttons.size) + } + } + fun `test toggling outcomes does not accumulate retry buttons`() { edt { var clicked = 0 From 4e2508649631d5935782daf07b192b51be20497d Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 15:27:15 -0400 Subject: [PATCH 6/7] fix(cli): restore process signal typings --- packages/opencode/src/process.d.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 packages/opencode/src/process.d.ts diff --git a/packages/opencode/src/process.d.ts b/packages/opencode/src/process.d.ts new file mode 100644 index 00000000000..bf3faf17901 --- /dev/null +++ b/packages/opencode/src/process.d.ts @@ -0,0 +1,19 @@ +// kilocode_change - new file + +declare global { + namespace NodeJS { + interface Process { + on(event: Signals, listener: (...args: unknown[]) => void): this + once(event: Signals, listener: (...args: unknown[]) => void): this + off(event: Signals, listener: (...args: unknown[]) => void): this + on(event: "uncaughtException", listener: (err: Error, origin: string) => void): this + once(event: "uncaughtException", listener: (err: Error, origin: string) => void): this + off(event: "uncaughtException", listener: (err: Error, origin: string) => void): this + on(event: "unhandledRejection", listener: (reason: unknown, promise: Promise) => void): this + once(event: "unhandledRejection", listener: (reason: unknown, promise: Promise) => void): this + off(event: "unhandledRejection", listener: (reason: unknown, promise: Promise) => void): this + } + } +} + +export {} From 87c7e946c55e4aa1a386ac7a3892a0a132529b46 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 26 Aug 2026 15:38:44 -0400 Subject: [PATCH 7/7] test(jetbrains): avoid font-sensitive dialog assertion --- .../ai/kilocode/client/session/views/base/DialogViewTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt index 04371cd13fe..a6f4e52cdd7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt @@ -66,7 +66,7 @@ class DialogViewTest : BasePlatformTestCase() { panel.setHeader("", "Stopped") val areas = findAll(panel) - assertTrue("Bold header text area should be hidden", areas.filter { it.font.isBold }.all { !it.isVisible }) + assertTrue("Blank header text area should be hidden", areas.filter { it.text.isBlank() }.all { !it.isVisible }) assertNotNull("Description should remain visible", areas.firstOrNull { it.text == "Stopped" && it.isVisible }) } }