Merge pull request #13520 from Kilo-Org/snappy-hedgehog

fix(jetbrains): continue failed turns on retry
This commit is contained in:
Kirill Kalishev
2026-08-27 18:23:11 -04:00
committed by GitHub
42 changed files with 1192 additions and 128 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show why a turn failed instead of letting the session stop with no visible reason. The reason is written once, on the turn that failed, and Retry sits below it whenever that turn can be continued. Failures the conversation has already moved past no longer leave cards behind mid-transcript, and failed sessions are flagged in history, worktree rows, and their editor tab the same way an error or a pending question is.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a warning when a provider ends a response without signalling that it finished, instead of quietly returning the session to idle. The warning also appears when reopening the session.
@@ -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 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.
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. Retry continues the failed turn where it stopped, keeping the conversation and any file changes it already made, and runs with the model and effort selected at that moment — so switching away from an unavailable provider and pressing Retry picks the new one up. This includes failures that never produced a reply, such as missing provider credentials.
@@ -90,6 +90,10 @@ class KiloBackendActivityManager(
// it must not badge the session list, worktree rows, or the Agents tab attention dot.
is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) }
is ChatEventDto.TurnOpen -> errors.remove(event.sessionID)
// Not every failure publishes a session error — a turn whose provider ended the response in
// error writes the failure onto the message and only reports it through this close reason. The
// badge has to come from the close too, or such a session rests as if it finished cleanly.
is ChatEventDto.TurnClose -> if (event.reason == "error") errors.add(event.sessionID)
is ChatEventDto.SessionIdle -> clear(event.sessionID)
is ChatEventDto.SessionStatusChanged -> when (event.status.type) {
"idle" -> clear(event.sessionID)
@@ -1141,6 +1141,7 @@ object KiloCliDataParser {
parentID = obj.str("parentID"),
cost = obj.num("cost"),
tokens = tokens?.let(::parseTokens),
finish = obj.str("finish"),
error = error?.let { parseError(it) },
summary = summary,
)
@@ -115,6 +115,54 @@ class KiloBackendActivityManagerTest {
assertFalse("ses_1" in manager.activity.value)
}
/**
* A provider that ends the response in error writes the failure onto the message and reports it
* only through the close reason, so the badge cannot depend on a session error event.
*/
@Test
fun `turn closing in error badges the session without an error event`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
events.emit(ChatEventDto.TurnClose("ses_1", "error"))
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
events.emit(ChatEventDto.SessionIdle("ses_1"))
val snap = await("ses_1", SessionActivityKindDto.ERROR)
assertEquals("/repo/wt", snap["ses_1"]?.directory)
}
@Test
fun `turn closing without a failure leaves the session unbadged`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
for (reason in listOf("completed", "interrupted", "aborted")) {
events.emit(ChatEventDto.TurnClose("ses_1", reason))
}
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
events.emit(ChatEventDto.SessionIdle("ses_1"))
withTimeout(5_000) { manager.activity.first { "ses_1" !in it } }
assertFalse("ses_1" in manager.activity.value)
}
@Test
fun `a turn closed in error clears once the session is retried`() = runBlocking {
directories["ses_1"] = "/repo/wt"
start()
events.emit(ChatEventDto.TurnClose("ses_1", "error"))
await("ses_1", SessionActivityKindDto.ERROR)
events.emit(ChatEventDto.TurnOpen("ses_1"))
withTimeout(5_000) { manager.activity.first { "ses_1" !in it } }
assertFalse("ses_1" in manager.activity.value)
}
@Test
fun `aborted error does not badge the session`() = runBlocking {
directories["ses_1"] = "/repo/wt"
@@ -1610,6 +1610,7 @@ class KiloCliDataParserTest {
"info": {
"id": "m1", "sessionID": "s1", "role": "assistant",
"time": { "created": 1.0, "completed": 2.0 },
"finish": "unknown",
"tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } },
"cost": 0.005
},
@@ -1624,6 +1625,7 @@ class KiloCliDataParserTest {
assertEquals(10L, info.tokens?.reasoning)
assertEquals(20L, info.tokens?.cacheRead)
assertEquals(5L, info.tokens?.cacheWrite)
assertEquals("unknown", info.finish)
assertEquals(0.005, info.cost)
assertEquals(2.0, info.time.completed)
}
@@ -1111,7 +1111,7 @@ class SessionUi(
private fun onStateChanged(state: SessionState) {
if (disposed) return
val busy = state.isBusy()
if (wasBusy && state is SessionState.Idle) {
if (wasBusy && !busy) {
refreshBranchChanges()
refreshBranch()
}
@@ -488,53 +488,50 @@ class SessionController(
}
/**
* Re-runs the last user turn after it failed, discarding the failed assistant turn first.
* Continues a failed turn: re-runs the loop for the last user message, changing nothing else.
*
* 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.
* The prompt reuses the original user message id and sends no parts, so the CLI rewrites that one
* message in place and starts a fresh assistant message under it. Consequences that matter:
* - no message is appended, so no synthetic "continue" turn shows up in the chat, and an empty part
* list leaves the original prompt text in place — the CLI only writes the parts it is given;
* - the model, agent, and effort currently picked are what the continued turn runs with (see
* [retryPromptCurrent]), so switching model and pressing Retry switches model;
* - the failed assistant message stays as history. The CLI removes it itself when it produced
* nothing but turn scaffolding (`KiloSessionPrompt.recoverFailedAssistant`), and keeps it when it
* emitted text or ran tools — that record is what explains file changes still on disk.
*
* 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.
* Deliberately no revert: a revert without a partID widens server-side to the *preceding user
* message*, and `SessionRevert.cleanup` then drops that message and everything after it on the next
* prompt. When the failure hit a session's first turn, that erased the whole transcript and left the
* replay with an empty prompt. Continuing gives up the workspace restore the revert used to do, which
* is the right trade: rolling a whole run's edits back behind a Retry button is both surprising and
* unrecoverable once cleanup clears the revert marker.
*/
fun retry() {
assertEdt()
val id = sid ?: return
val target = retryTarget() ?: return
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,
// it is about to be replaced. SessionMessageListPanel only marks when message != null.
SessionState.Reverting.Kind.ROLLBACK,
message = null,
) ?: return
revertJob = cs.launch {
capture(
"Session Retry",
sessionProps(id) + mapOf("tail" to if (target.assistant != null) "assistant" else "user"),
)
// Hand off to the running turn before the RPC resolves. SessionOutcomeView is bound to the
// session state, so this is also what dismisses the error card, and a busy state is what stops a
// second click from reaching retryTarget.
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
cs.launch {
try {
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)
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) }
throw e
} 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")))
model.setState(SessionState.Error(e.message ?: KiloBundle.message("session.error.prompt")))
}
}
}
@@ -545,9 +542,9 @@ class SessionController(
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, a turn that did not fail, or a tail that is neither the last user message
* nor the assistant that failed answering it.
* The failed tail turn to continue, or null when retry does not apply: no session, an operation
* already 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()
@@ -561,7 +558,7 @@ class SessionController(
// 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.
// afterwards: continuing it would ask the model to redo work it already delivered.
tail.info.role == "assistant" && tail.info.time.completed != null -> false
else -> state is SessionState.Error ||
(state is SessionState.TurnEnded && state.outcome == Outcome.FAILED)
@@ -569,8 +566,8 @@ class SessionController(
if (!failed) return null
val prompt = retryPromptCurrent() ?: 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.
// credentials are checked ahead of it — so the user turn is the tail and there is no failed
// assistant to continue past.
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
@@ -1455,11 +1452,15 @@ class SessionController(
// After auto-approve only skill-shell permissions still need a human card; queue those.
// Otherwise queue the whole pending set so each request is resolved in turn.
val queue = if (autoApprove) permissions.filter { it.metadata["skillShell"] == "true" } else permissions
// An "idle" status is still a status. It means no live work, not "nothing to recover", so it
// must not shadow the transcript: a session reopened after a failed turn is idle on the
// server and would otherwise recover as if it had never failed.
val live = liveStatus(status)
val branch = when {
permissions.isNotEmpty() -> "permission"
questions.isNotEmpty() -> "question"
status != null -> "status"
else -> "idle"
live != null -> "status"
else -> "outcome"
}
LOG.debug {
"${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch"
@@ -1474,8 +1475,8 @@ class SessionController(
promote()
} else if (questions.isNotEmpty()) {
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
seedStatus(status)
} else if (live != null) {
model.setState(live)
} else {
seedOutcome()
}
@@ -1487,14 +1488,16 @@ class SessionController(
}
/**
* Seed initial session state from a snapshot status value.
* The state a snapshot status implies, or null when it reports no live work.
*
* Used only during recovery — does not apply the live-event clobbering guard
* for "busy" because no more-specific state has arrived yet.
* Used only during recovery — does not apply the live-event clobbering guard for "busy" because no
* more-specific state has arrived yet. Returning null for idle/unknown hands the decision to
* [seedOutcome], so a reopened session can still show how its last turn ended.
*/
private fun seedStatus(dto: SessionStatusDto) {
private fun liveStatus(dto: SessionStatusDto?): SessionState? {
if (dto == null) return null
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} evt=session.status ${ChatLogSummary.status(dto)}" }
val state = when (dto.type) {
return when (dto.type) {
"busy" -> SessionState.Busy(KiloBundle.message("session.status.considering"))
"retry" -> SessionState.Retry(
message = dto.message ?: "",
@@ -1505,13 +1508,18 @@ class SessionController(
message = dto.message ?: "",
requestId = dto.requestID ?: "",
)
else -> return // idle or unknown — leave as Idle
else -> null // idle or unknown — the transcript decides
}
model.setState(state)
}
private fun seedOutcome() {
val err = model.messages().lastOrNull { it.info.role == "assistant" }?.info?.error ?: return
val tail = model.messages().lastOrNull { it.info.role == "assistant" } ?: return
val err = tail.info.error
if (err == null) {
val ended = TurnOutcome.incomplete(tail.info.finish) ?: return
model.setState(SessionState.TurnEnded(ended, tail.info.finish))
return
}
if (err.aborted) {
model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED))
return
@@ -1598,11 +1606,14 @@ class SessionController(
if (current is SessionState.AwaitingPermission) return
if (current is SessionState.LoginRequired) return
if (current is SessionState.Error && event.reason != "completed") return
val ended = TurnOutcome.classify(event.reason)
val finish = model.messages().lastOrNull { it.info.role == "assistant" }?.info?.finish
val ended = if (current is SessionState.Error) null else TurnOutcome.classify(event.reason, finish)
if (event.reason == "completed") {
capture("Task Completed", sessionProps(event.sessionID) + mapOf("finish" to (finish ?: "none")))
}
when {
ended != null -> model.setState(SessionState.TurnEnded(ended))
ended != null -> model.setState(SessionState.TurnEnded(ended, finish))
event.reason == "completed" -> {
capture("Task Completed", sessionProps(event.sessionID))
model.setState(SessionState.Idle)
}
current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline -> model.setState(SessionState.Idle)
@@ -22,7 +22,7 @@ sealed class SessionState {
data class Error(val message: String, val kind: String? = null) : SessionState()
data class TurnEnded(val outcome: Outcome) : SessionState()
data class TurnEnded(val outcome: Outcome, val finish: String? = null) : SessionState()
data class LoginRequired(val message: String) : SessionState()
@@ -1,15 +1,26 @@
package ai.kilocode.client.session.model
enum class Outcome { INTERRUPTED, FAILED }
enum class Outcome { INTERRUPTED, FAILED, INCOMPLETE }
object TurnOutcome {
/**
* 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.
* Finish reasons that mean the provider ended the response without signalling completion.
*
* "length" is excluded: the CLI already writes a visible warning text part for it, so an outcome
* card would repeat the same message.
*/
fun classify(reason: String): Outcome? = when (reason) {
private val bad = setOf("unknown", "other")
fun incomplete(finish: String?): Outcome? = if (finish in bad) Outcome.INCOMPLETE else null
/**
* Maps a `session.turn.close` reason plus assistant finish reason to the outcome the transcript
* should show. `superseded` is a normal handoff and returns null so the follow-up turn decides.
*/
fun classify(reason: String, finish: String? = null): Outcome? = when (reason) {
"interrupted" -> Outcome.INTERRUPTED
"error" -> Outcome.FAILED
"completed" -> incomplete(finish)
else -> null
}
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionDiffOpener
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
@@ -18,6 +19,7 @@ import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.failureText
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -189,13 +191,21 @@ class SessionMessageListPanel(
is SessionModelEvent.MessageUpdated -> {
// message.updated fires on every streamed metadata delta (time/tokens/cost). Only
// relayout the transcript when the turn's modified-files card actually changed,
// not on each delta or when this message isn't a turn anchor.
val view = turnViews[event.info.info.id]
// relayout the transcript when something visible changed: the visible failure for
// this turn, or the modified-files card when this message anchors a turn.
val id = event.info.info.id
val turn = msgToTurn[id]
var changed = turn?.let(::syncFailures) == true
if (changed && turn != null) (layout as? SessionLayout)?.forget(turn)
// A failure landing on the tail decides whether the footer prints the reason or only
// offers Retry, so the footer has to be re-evaluated with it.
if (changed && id == tail()) syncActive()
val view = turnViews[id]
if (view?.setDiffs(event.info.info.summary?.diffs.orEmpty()) == true) {
(layout as? SessionLayout)?.forget(view)
refresh()
changed = true
}
if (changed) refresh()
}
is SessionModelEvent.DiffUpdated -> {
@@ -312,6 +322,7 @@ class SessionMessageListPanel(
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncFailures()
syncReverted()
add(tv)
syncSettled()
@@ -341,6 +352,7 @@ class SessionMessageListPanel(
tv.setDiffs(diffsOf(turn))
tv.syncCopyToolbars()
syncQueued(tv)
syncFailures()
syncReverted()
syncSettled()
@@ -352,6 +364,7 @@ class SessionMessageListPanel(
for (msgId in tv.messageIds()) unregister(msgId)
remove(tv)
Disposer.dispose(tv)
syncFailures()
syncSettled()
anchorFooter()
refresh()
@@ -387,6 +400,7 @@ class SessionMessageListPanel(
syncActive(model.state)
syncSettled(model.state)
syncQueued()
syncFailures()
syncReverted()
syncReverting(model.state)
banner?.update()
@@ -395,6 +409,51 @@ class SessionMessageListPanel(
refresh()
}
/** Last message in the transcript, which is the only message the outcome footer can describe. */
private fun tail(): String? = turnViews.values.lastOrNull()?.messageIds()?.lastOrNull()
/** Failure the transcript already explains for the tail message, or null when it explains nothing. */
private fun explained(): String? = tail()?.let { failureText(model.message(it)?.info?.error) }
/** Apply failure visibility policy to every turn. */
private fun syncFailures(): Boolean {
var changed = false
for (view in turnViews.values) {
if (syncFailures(view)) {
(layout as? SessionLayout)?.forget(view)
changed = true
}
}
return changed
}
/**
* Renders the failure the session is currently sitting on, and nothing else.
*
* Only the last turn can show one, and only on its final message:
* - a superseded turn shows nothing. Once the conversation moved past a failure it is history, and a
* red card stranded between two later turns is noise the user cannot act on;
* - within the live turn, only the final attempt speaks. Retry continues a turn by appending another
* assistant message, so each attempt keeps its own errored message and they would otherwise stack;
* - a turn whose final message succeeded says nothing, because a failure it recovered from is not
* that turn's outcome.
*
* This keeps the card and the footer in lockstep: both describe the tail, so wherever the reason is
* visible the Retry action is offered too (when the tail can be continued).
*/
private fun syncFailures(view: TurnView): Boolean {
val live = turnViews.values.lastOrNull() === view
val ids = view.messageIds()
val last = ids.lastOrNull()
var changed = false
for (id in ids) {
val msg = msgToView[id] ?: continue
val error = model.message(id)?.info?.error?.takeIf { live && id == last }
changed = msg.syncError(error) || changed
}
return changed
}
private fun syncReverted() {
for ((id, view) in msgToView) {
view.setReverted(model.isRevertedMessage(id))
@@ -463,14 +522,22 @@ class SessionMessageListPanel(
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.showError(state.message, state.kind)
// The transcript card owns the reason whenever the failed message carries it, so the
// footer keeps only the action. Session-level errors — bad config, or a failure that hit
// before an assistant message existed — have no card, so they still print in full.
// Trimmed: the state message is the raw error text, while the card normalizes it.
if (explained() == state.message.trim()) outcome?.showRetry()
else outcome?.showError(state.message, state.kind)
}
is SessionState.TurnEnded -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.showOutcome(state.outcome)
// A failed turn close carries no message of its own; when the tail message explains
// itself the generic "stopped with an error" line is noise next to that card.
if (state.outcome == Outcome.FAILED && explained() != null) outcome?.showRetry()
else outcome?.showOutcome(state.outcome, state.finish)
}
else -> {
setHiddenQuestionTool(null)
@@ -0,0 +1,138 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.MessageErrorDto
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Dimension
import javax.swing.JPanel
/**
* What a failure reads as in the transcript, or null when there is nothing to show.
*
* Null for a Stop: an aborted turn is a deliberate user action that the footer already reports as
* "Stopped", and the TUI skips it the same way. Shared with the transcript panel so it can compare
* against the text the outcome card is already showing.
*/
internal fun failureText(error: MessageErrorDto?): String? {
if (error == null) return null
if (error.aborted) return null
return error.message?.trim()?.takeIf { it.isNotEmpty() } ?: error.type
}
/**
* The failure the session is currently sitting on, rendered on the message that carries it.
*
* The reason belongs next to the work that failed rather than in a state-driven footer, and it has to
* survive a reload — reopening a session whose last turn failed must still explain itself. The footer
* keeps the Retry action instead of repeating this text.
*
* Only the live turn gets one; see `SessionMessageListPanel.syncFailures` for why a superseded failure
* renders nothing.
*
* An accented block rather than a full card: there is nothing to expand, and the text is the point.
*/
class MessageErrorView : JPanel(BorderLayout()), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private val body = ErrorText()
init {
// Containers stay transparent over the session backdrop; [ErrorText] is the raised surface.
isOpaque = false
add(body, BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
}
/** Returns true when the text changed, so callers only relayout on a real change. */
@RequiresEdt
fun setText(value: String): Boolean {
if (body.text == value) return false
body.text = value
revalidate()
repaint()
return true
}
@RequiresEdt
fun text(): String = body.text
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
body.font = style.transcriptFont
revalidate()
repaint()
}
/** Re-resolved here so the accent survives a Look and Feel switch. */
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.customLineLeft(UiStyle.Colors.errorLabelForeground())
}
}
/**
* Selectable, wrapping error text over the raised editor surface.
*
* Owns its own theme values: assigning them from the parent's `init` would not survive a Look and Feel
* switch, and re-applying them in the parent's `updateUI` would run before this field exists.
*/
private class ErrorText : JBTextArea() {
init {
isEditable = false
isFocusable = false
caret.isVisible = false
caret.isSelectionVisible = true
lineWrap = true
wrapStyleWord = true
}
/**
* Measured at the width it will actually be painted at, so wrapped text reports its real height.
*
* [ai.kilocode.client.session.ui.SessionLayout] sizes [MessageErrorView] and then reads its
* preferred size, but the wrapper's `BorderLayout` forwards that question here without passing the
* width down. A wrapping area would answer with a single unwrapped line, clipping a long provider
* error to a one-line slot. Same approach as the transcript's other text rows
* (`DialogView.makeText`, `QuestionResultView.makeText`).
*/
override fun getPreferredSize(): Dimension {
val width = space()
if (width <= 0) return super.getPreferredSize()
val old = size
setSize(width, Int.MAX_VALUE)
val height = super.getPreferredSize().height
setSize(old)
return Dimension(width, height)
}
/** Width the nearest sized ancestor leaves for this area, falling back to its own. */
private fun space(): Int {
var node = parent
while (node != null) {
if (node.width > 0) {
val ins = node.insets
return (node.width - ins.left - ins.right).coerceAtLeast(0)
}
node = node.parent
}
return width
}
override fun updateUI() {
super.updateUI()
foreground = UiStyle.Colors.errorLabelForeground()
background = SessionUiStyle.Colors.codeBlockBackground()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
)
}
}
@@ -30,6 +30,7 @@ import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.MessageErrorDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
@@ -98,6 +99,7 @@ class MessageView(
private var openDiff: SessionDiffOpener = { _, _, _ -> }
private var sessionId: String? = null
private var reverted = false
private var failure: MessageErrorView? = null
init {
isOpaque = false
@@ -113,6 +115,43 @@ class MessageView(
syncVisibility()
}
/**
* Show, update, or drop the failure this message ended with. Returns true when anything visible
* changed, so the panel only relayouts on a real change — `message.updated` also fires on every
* streamed token/cost delta.
*
* [SessionModel.upsertMessage] replaces the [Message] instance while this view keeps the original,
* so the error has to be passed in rather than read back off [msg].
*/
@RequiresEdt
fun syncError(error: MessageErrorDto?): Boolean {
val text = failureText(error)
val existing = failure
if (text == null) {
if (existing == null) return false
failure = null
remove(existing)
refresh()
return true
}
if (existing != null) {
if (!existing.setText(text)) return false
refresh()
return true
}
val view = MessageErrorView().also {
it.applyStyle(style)
it.setText(text)
}
failure = view
add(view)
refresh()
return true
}
/** Insertion slot that keeps the failure card last, or -1 to append when there is none. */
private fun tail(): Int = failure?.let { components.indexOf(it) } ?: -1
fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) {
this.openDiff = openDiff
this.sessionId = sessionId
@@ -229,7 +268,7 @@ class MessageView(
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
wrapPrompt(view)?.let { add(it) }
wrapPrompt(view)?.let { add(it, tail()) }
}
@RequiresEdt
@@ -241,7 +280,7 @@ class MessageView(
attachments = it
val node = ensurePromptWrap()
promptBox?.add(it, BorderLayout.SOUTH)
if (node.parent == null) add(node)
if (node.parent == null) add(node, tail())
}
view.upsert(content)
parts[content.id] = view
@@ -454,6 +493,7 @@ class MessageView(
this.style = style
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style)
for (view in parts.values) view.applyStyle(style)
failure?.applyStyle(style)
refresh()
}
@@ -464,6 +504,8 @@ class MessageView(
Disposer.dispose(it)
}
wrap?.let { remove(it) }
failure?.let { remove(it) }
failure = null
parts.clear()
aliases.clear()
sources.clear()
@@ -47,12 +47,39 @@ class SessionOutcomeView(
refresh()
}
/**
* Failure footer for a failure the transcript already explains: the action only, no message.
*
* The failed message renders its own card with the provider's reason, so repeating that text here
* would print the same sentence twice. Retry stays in the footer rather than moving onto that card
* because it always continues the session tail, and the card scrolls out of reach.
*
* Hides itself when the tail cannot be continued a header with no reason and no action says
* nothing that the card above has not already said.
*/
@RequiresEdt
fun showRetry() {
if (retry == null || retryable?.invoke() == false) {
hideView()
return
}
val title = KiloBundle.message("session.outcome.failed.title")
setOutlined(true)
setHeaderIcon(AllIcons.General.Error, title)
setHeader(title, null)
setContentPadding()
setContent(null)
syncRetry(true)
isVisible = true
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) {
fun showOutcome(outcome: Outcome, finish: String? = null) {
when (outcome) {
Outcome.INTERRUPTED -> {
setOutlined(false)
@@ -68,6 +95,15 @@ class SessionOutcomeView(
setHeader(title, KiloBundle.message("session.outcome.failed.description"))
syncRetry(true)
}
Outcome.INCOMPLETE -> {
val title = KiloBundle.message("session.outcome.incomplete.title")
val tip = finish?.let { KiloBundle.message("session.outcome.incomplete.reason", it) } ?: title
setOutlined(true)
setHeaderIcon(AllIcons.General.Warning, tip)
setHeader(title, KiloBundle.message("session.outcome.incomplete.description"))
syncRetry(false)
}
}
setContentPadding()
setContent(null)
@@ -78,7 +78,6 @@ 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.
@@ -233,6 +232,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=انتهت مهلة العملية. جار انتظار انتهائها قبل المتابعة.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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 lopération…
session.error.revert.timeout=Lopération a expiré. Attente de sa fin avant de continuer.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=操作がタイムアウトしました。続行する前に完了を待機しています。
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=작업 시간이 초과되었습니다. 계속하기 전에 완료될 때까지 기다리는 중입니다.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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ą.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=Время ожидания операции истекло. Ждем ее завершения перед продолжением.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=การดำเนินการหมดเวลา กำลังรอให้เสร็จสิ้นก่อนดำเนินการต่อ
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=Час очікування операції минув. Очікуємо її завершення перед продовженням.
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=操作超时。继续前正在等待其完成。
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -60,7 +60,6 @@ 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=操作逾時。繼續前正在等待其完成。
@@ -82,6 +81,9 @@ 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.incomplete.title=Response may be incomplete
session.outcome.incomplete.description=The provider ended this response without signalling that it finished.
session.outcome.incomplete.reason=Technical finish reason: {0}
session.outcome.retry=Retry
session.outcome.interrupted.note=Stopped
@@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
@@ -284,6 +285,22 @@ class SessionUiLayoutTest : SessionUiTestBase() {
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/C.kt", 1, 0))
controller().model.setState(SessionState.Busy("running"))
controller().model.setState(SessionState.TurnEnded(Outcome.INCOMPLETE, "unknown"))
settle()
assertEquals(1 to 0, badge.stats())
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/D.kt", 5, 2))
controller().model.setState(SessionState.Busy("running"))
controller().model.setState(SessionState.Error("failed"))
settle()
assertEquals(5 to 2, badge.stats())
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/E.kt", 1, 0))
controller().model.setRevert(SessionRevertDto("msg1", "part1", diff = "patch"))
settle()
@@ -152,6 +152,9 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test busy status is seeded from statuses map`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
// recoverPending() reads the service's status map once, and that map arrives through a flow, so
// the seed has to be observable before the controller loads or recovery races it to Idle.
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "busy" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -173,6 +176,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
attempt = 3,
next = 5000L,
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "retry" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -196,6 +200,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
message = "No network",
requestID = "req_xyz",
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "offline" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -211,6 +216,195 @@ class SessionRecoveryTest : SessionControllerTestBase() {
assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId)
}
/**
* A session reopened after a failed turn is idle on the server, so recovery has to fall back to the
* transcript. Without it the reopened UI shows the failure with no way to act on it, while the UI
* that was open when it failed still offers Retry.
*/
fun `test failed tail recovers into error even when the server reports idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList()))
rpc.history.add(MessageWithPartsDto(
msg("msg2", "ses_test", "assistant").copy(
parentID = "msg1",
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue("Recovery must not drop the failure", state is SessionState.Error)
assertEquals("missing credentials", (state as SessionState.Error).message)
edt { assertTrue("The reopened session must offer Retry too", m.canRetry()) }
}
fun `test aborted tail recovers as interrupted even when the server reports idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
error = MessageErrorDto(type = MessageErrorDto.ABORTED, message = "aborted"),
),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue(state is SessionState.TurnEnded)
assertEquals(
ai.kilocode.client.session.model.Outcome.INTERRUPTED,
(state as SessionState.TurnEnded).outcome,
)
}
fun `test incomplete tail recovers even when the server reports idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(finish = "unknown"),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue(state is SessionState.TurnEnded)
assertEquals(
ai.kilocode.client.session.model.Outcome.INCOMPLETE,
(state as SessionState.TurnEnded).outcome,
)
assertEquals("unknown", state.finish)
}
fun `test normal finish does not recover an outcome`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(finish = "stop"),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertEquals(SessionState.Idle, m.model.state)
}
fun `test tail error wins over incomplete finish during recovery`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
finish = "unknown",
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue(state is SessionState.Error)
assertEquals("missing credentials", (state as SessionState.Error).message)
}
/** An unrecognised status carries no live work either, so the transcript still decides. */
fun `test unknown status falls through to the failed tail`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("something-new"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue(state is SessionState.Error)
assertEquals("missing credentials", (state as SessionState.Error).message)
}
/** A tail that failed does not outrank a question the server is still waiting on. */
fun `test pending question wins over a failed tail`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
rpc.pendingQuestionList.add(
QuestionRequestDto(
id = "q_pending",
sessionID = "ses_test",
questions = listOf(QuestionInfoDto("Proceed?", "Q")),
)
)
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertTrue(m.model.state is SessionState.AwaitingQuestion)
}
fun `test retry status wins over a failed tail`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 2, next = 1000L))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "retry" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
val state = m.model.state
assertTrue("The CLI is still retrying, so that outranks the tail", state is SessionState.Retry)
assertEquals(2, (state as SessionState.Retry).attempt)
}
/** Live work still wins: a busy server must not be overridden by an older failed turn. */
fun `test busy status wins over a failed tail`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(
error = MessageErrorDto(type = "APIError", message = "missing credentials"),
),
emptyList(),
))
assertTrue(waitFor { sessions.statuses.value["ses_test"]?.type == "busy" })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertTrue(m.model.state is SessionState.Busy)
}
fun `test idle status in map leaves controller in Idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
@@ -10,14 +10,14 @@ import ai.kilocode.rpc.dto.MessageTimeDto
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
/**
* 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.
* Retry *continues* a failed turn: it re-prompts the original user message id with no parts and nothing
* else. No revert, no message delete, so the transcript and the workspace are untouched and the CLI
* simply starts a fresh assistant message under the same user turn.
*
* The revert this used to do widened server-side to the preceding user message, and the replay then
* deleted that message and everything after it the whole session when the failure hit the first turn.
*/
class SessionRetryTest : SessionControllerTestBase() {
@@ -86,7 +86,7 @@ class SessionRetryTest : SessionControllerTestBase() {
)
}
fun `test retry reverts the failed turn then replays the user message`() {
fun `test retry continues the failed turn without touching the transcript`() {
failed()
val m = controller("ses_test")
flush()
@@ -94,21 +94,58 @@ class SessionRetryTest : SessionControllerTestBase() {
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)
assertTrue("A revert widens to the user message server-side and deletes it", rpc.reverts.isEmpty())
assertTrue("Nothing is deleted either — the failed turn is history", rpc.messageDeletes.isEmpty())
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("ses_test", rpc.prompts.single().first)
assertEquals("Continues the existing user message, no synthetic one", "msg_user", prompt.messageID)
assertTrue("No parts means the CLI keeps the original prompt text", prompt.parts.isEmpty())
assertEquals("kilo", prompt.providerID)
assertEquals("gpt-5", prompt.modelID)
assertEquals("code", prompt.agent)
}
/** The transcript is the thing the old revert-based retry destroyed, so assert it survives. */
fun `test retry keeps the failed turn in the transcript`() {
failed()
val m = controller("ses_test")
flush()
edt { m.retry() }
flush()
edt {
assertEquals(
"Both messages must still be there",
listOf("msg_user", "msg_fail"),
m.model.messages().map { it.info.id },
)
}
}
/** No "continue" text may reach the CLI: a visible user turn is exactly what this avoids. */
fun `test retry sends no text part`() {
failed()
val m = controller("ses_test")
flush()
edt { m.retry() }
flush()
val prompt = rpc.prompts.single().third
assertTrue(prompt.parts.isEmpty())
assertNull("A continue must not attach fresh editor context either", prompt.editorContext)
edt {
assertEquals(
"No message was appended to the transcript",
listOf("msg_user", "msg_fail"),
m.model.messages().map { it.info.id },
)
}
}
fun `test retry uses the model selected after the failure`() {
failed()
val m = controller("ses_test")
@@ -223,23 +260,34 @@ class SessionRetryTest : SessionControllerTestBase() {
assertEquals("kilo-auto/free", prompt.modelID)
}
fun `test retry does not prompt until the revert completes`() {
/**
* The error card is bound to the session state (`SessionMessageListPanel.syncActive`), so leaving the
* failed state is what dismisses it. That has to happen on the click, not when the RPC returns.
*/
fun `test retry leaves the failed state before the prompt resolves`() {
failed()
val m = controller("ses_test")
flush()
// seedOutcome() puts an errored tail into Error on load, which is what paints the card.
edt { assertTrue("Precondition: the card is showing", m.model.state is SessionState.Error) }
edt { m.retry() }
edt { assertTrue("The card must be gone on click", m.model.state is SessionState.Busy) }
flush()
assertTrue(m.model.state is SessionState.Busy)
}
/** A busy state is also what makes a second click a no-op, so no double prompt can escape. */
fun `test retry clicked twice prompts once`() {
failed()
val gate = CompletableDeferred<Unit>()
rpc.revertGate = gate
val m = controller("ses_test")
flush()
edt { m.retry() }
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)
}
@@ -268,15 +316,18 @@ class SessionRetryTest : SessionControllerTestBase() {
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()
// A live turn is the deterministic way to be busy here: the recovery status map arrives through
// a flow, so seeding rpc.statuses cannot be observed reliably right after the first flush.
emit(ChatEventDto.TurnOpen("ses_test"))
edt { assertTrue("Precondition: the session is busy", m.model.state is SessionState.Busy) }
edt { m.retry() }
flush()
assertTrue(rpc.reverts.isEmpty())
assertTrue(rpc.prompts.isEmpty())
assertTrue("A busy session must not be continued", rpc.prompts.isEmpty())
}
fun `test retry is unavailable when nothing failed`() {
@@ -295,10 +346,10 @@ class SessionRetryTest : SessionControllerTestBase() {
/**
* 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.
* the failure only surfaces as a session error over a user-message tail. Retry must still continue,
* otherwise the card's only action is dead.
*/
fun `test retry replays a turn that failed before the assistant message existed`() {
fun `test retry continues a turn that failed before the assistant message existed`() {
unanswered()
val m = controller("ses_test")
flush()
@@ -315,9 +366,9 @@ class SessionRetryTest : SessionControllerTestBase() {
edt { m.retry() }
flush()
assertTrue("Nothing was produced, so there is no message to roll back", rpc.reverts.isEmpty())
assertTrue(rpc.reverts.isEmpty())
val prompt = rpc.prompts.single().third
assertEquals("Replays the existing user message, no synthetic one", "msg_user", prompt.messageID)
assertEquals("Continues 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)
@@ -325,7 +376,7 @@ class SessionRetryTest : SessionControllerTestBase() {
}
/** 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`() {
fun `test retry continues an unanswered turn reported only by turn close`() {
unanswered()
val m = controller("ses_test")
flush()
@@ -344,12 +395,12 @@ class SessionRetryTest : SessionControllerTestBase() {
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)
assertEquals("Effort switched after the failure has to reach the continue", "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.
* answer. Continuing then would ask the model to redo delivered 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()))
@@ -371,8 +422,8 @@ class SessionRetryTest : SessionControllerTestBase() {
edt { m.retry() }
flush()
assertTrue("A completed turn must not be rolled back", rpc.reverts.isEmpty())
assertTrue(rpc.prompts.isEmpty())
assertTrue(rpc.reverts.isEmpty())
assertTrue("A completed turn must not be re-run", rpc.prompts.isEmpty())
}
fun `test retry is unavailable when the session has no user message`() {
@@ -381,7 +432,7 @@ class SessionRetryTest : SessionControllerTestBase() {
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()) }
edt { assertFalse("Nothing to continue, so the card must not offer Retry", m.canRetry()) }
}
fun `test retry is offered for a failed assistant turn`() {
@@ -400,9 +451,9 @@ class SessionRetryTest : SessionControllerTestBase() {
edt { assertFalse(m.canRetry()) }
}
fun `test retry surfaces an error when the revert fails`() {
fun `test retry surfaces an error when the prompt fails`() {
failed()
rpc.revertThrows = RuntimeException("snapshot unavailable")
rpc.promptThrows = RuntimeException("backend unavailable")
val m = controller("ses_test")
flush()
@@ -411,7 +462,8 @@ class SessionRetryTest : SessionControllerTestBase() {
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)
assertTrue("A rejected continue must not leave a fake busy state", state is SessionState.Error)
assertEquals("backend unavailable", (state as SessionState.Error).message)
edt { assertTrue("The card must offer Retry again", m.canRetry()) }
}
}
@@ -210,6 +210,43 @@ class TurnLifecycleTest : SessionControllerTestBase() {
)
}
fun `test TurnClose completed with unknown finish shows incomplete outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_assistant", "ses_test", "assistant").copy(finish = "unknown")))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
assertSession(
"""
assistant#msg_assistant
[code] [kilo/gpt-5] [incomplete]
""",
m,
)
assertTrue(appRpc.telemetry.any {
it.event == "Task Completed" && it.properties["finish"] == "unknown"
})
}
fun `test TurnClose completed with length finish stays idle`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_assistant", "ses_test", "assistant").copy(finish = "length")))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
assertSession(
"""
assistant#msg_assistant
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test abort error waits for interrupted outcome`() {
val (m, _, _) = prompted()
@@ -244,11 +281,14 @@ class TurnLifecycleTest : SessionControllerTestBase() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_assistant", "ses_test", "assistant").copy(finish = "unknown")))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
// "completed" always wins over error
assertSession(
"""
assistant#msg_assistant
[code] [kilo/gpt-5] [idle]
""",
m,
@@ -271,10 +311,17 @@ class TurnLifecycleTest : SessionControllerTestBase() {
}
fun `test turn outcome classifier`() {
assertEquals(Outcome.INCOMPLETE, TurnOutcome.classify("completed", "unknown"))
assertEquals(Outcome.INCOMPLETE, TurnOutcome.classify("completed", "other"))
assertNull(TurnOutcome.classify("completed", "stop"))
assertNull(TurnOutcome.classify("completed", "length"))
assertNull(TurnOutcome.classify("completed", "tool-calls"))
assertNull(TurnOutcome.classify("completed"))
assertNull(TurnOutcome.classify("superseded"))
assertNull(TurnOutcome.classify("superseded", "unknown"))
assertEquals(Outcome.INTERRUPTED, TurnOutcome.classify("interrupted"))
assertEquals(Outcome.INTERRUPTED, TurnOutcome.classify("interrupted", "unknown"))
assertEquals(Outcome.FAILED, TurnOutcome.classify("error"))
assertEquals(Outcome.FAILED, TurnOutcome.classify("error", "unknown"))
}
fun `test TurnClose completed preserves AwaitingQuestion state`() {
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.views.MessageErrorView
import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PromptAttachmentView
@@ -37,6 +38,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
@@ -140,6 +142,249 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals("1 file", card.countText())
}
// ------ failed turns ------
fun `test failed message renders the provider text in the transcript`() {
model.upsertMessage(msg("a1", "assistant"))
assertTrue(cards("a1").isEmpty())
model.upsertMessage(msg("a1", "assistant").copy(error = failure("The provider ended the response with an error")))
val card = cards("a1").single()
assertEquals("The provider ended the response with an error", card.text())
assertSame("The failure belongs after the content it interrupted", card, panel.findMessage("a1")!!.components.last())
}
fun `test failed message with no text falls back to the error type`() {
model.upsertMessage(msg("a1", "assistant").copy(error = MessageErrorDto("ProviderAuthError")))
assertEquals("ProviderAuthError", cards("a1").single().text())
}
/** A Stop is a deliberate user action the footer already reports as "Stopped", not a failure. */
fun `test stopped message renders no failure card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = MessageErrorDto(MessageErrorDto.ABORTED, "aborted")))
assertTrue(cards("a1").isEmpty())
}
fun `test repeated retry failures collapse to the last failed attempt in the turn`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a2", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a3", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
assertTrue(cards("a1").isEmpty())
assertTrue(cards("a2").isEmpty())
assertEquals("Missing credentials", cards("a3").single().text())
}
fun `test recovered turn hides an earlier failed attempt`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("a2", "assistant").copy(parentID = "u1"))
assertTrue(cards("a1").isEmpty())
assertTrue(cards("a2").isEmpty())
}
/** The transcript keeps the reason; the footer drops its copy and keeps only the action. */
fun `test footer offers retry without repeating the message the card shows`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.Error("Missing credentials"))
val ov = find<SessionOutcomeView>(item)!!
assertEquals("Missing credentials", cards(item, "a1").single().text())
assertTrue(ov.isVisible)
assertNull("The reason must not be printed twice", text(ov, "Missing credentials"))
assertNotNull(button(ov, KiloBundle.message("session.outcome.retry")))
}
/** Nothing left to offer: the card already says it, and the turn cannot be continued. */
fun `test footer hides when the card explains a failure that cannot be retried`() {
val item = panelWithRetry { false }
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.Error("Missing credentials"))
val ov = find<SessionOutcomeView>(item)!!
assertEquals("Missing credentials", cards(item, "a1").single().text())
assertFalse(ov.isVisible)
}
/** A failure with no errored message of its own has no card, so the footer still explains it. */
fun `test footer keeps the message when the transcript cannot explain it`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("u1", "user"))
model.setState(SessionState.Error("Missing provider credentials", "ProviderAuthError"))
val ov = find<SessionOutcomeView>(item)!!
assertTrue(ov.isVisible)
assertNotNull(text(ov, "Missing provider credentials"))
assertNotNull(button(ov, KiloBundle.message("session.outcome.retry")))
}
fun `test generic failed close drops its description when the card explains the turn`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
model.setState(SessionState.TurnEnded(Outcome.FAILED))
val ov = find<SessionOutcomeView>(item)!!
assertEquals("Provider overloaded", cards(item, "a1").single().text())
assertNull(text(ov, KiloBundle.message("session.outcome.failed.description")))
assertNotNull(button(ov, KiloBundle.message("session.outcome.retry")))
}
fun `test incomplete outcome shows footer without message failure card`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("a1", "assistant").copy(finish = "unknown"))
model.setState(SessionState.TurnEnded(Outcome.INCOMPLETE, "unknown"))
val ov = find<SessionOutcomeView>(item)!!
assertTrue(cards(item, "a1").isEmpty())
assertNotNull(text(ov, KiloBundle.message("session.outcome.incomplete.title")))
assertNotNull(text(ov, KiloBundle.message("session.outcome.incomplete.description")))
assertNull(button(ov, KiloBundle.message("session.outcome.retry")))
}
fun `test failure landing after the error state still collapses the footer`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("a1", "assistant"))
model.setState(SessionState.Error("Missing credentials"))
val ov = find<SessionOutcomeView>(item)!!
assertNotNull("Precondition: no card yet, so the footer explains it", text(ov, "Missing credentials"))
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
assertEquals("Missing credentials", cards(item, "a1").single().text())
assertNull("The footer must drop its copy once the card has one", text(ov, "Missing credentials"))
}
/** The card is the durable record, so no session state may take it away. */
fun `test failure card survives every state the session moves through`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
for (state in listOf(
SessionState.Error("Missing credentials"),
SessionState.TurnEnded(Outcome.FAILED),
SessionState.Busy("thinking"),
SessionState.Idle,
)) {
model.setState(state)
assertEquals("card must stay in $state", "Missing credentials", cards("a1").single().text())
}
}
fun `test unrelated session error leaves the message failure alone`() {
val item = panelWithRetry { true }
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Missing credentials")))
model.setState(SessionState.Error("Workspace failed"))
val ov = find<SessionOutcomeView>(item)!!
assertEquals("Missing credentials", cards(item, "a1").single().text())
assertNotNull("A different failure still needs its own text", text(ov, "Workspace failed"))
}
fun `test history load paints a failure that arrived before the panel existed`() {
model.loadHistory(
listOf(
MessageWithPartsDto(
msg("a1", "assistant").copy(error = failure("Context window exceeded")),
emptyList(),
),
),
)
assertEquals("Context window exceeded", cards("a1").single().text())
}
/**
* A superseded failure is history: the user cannot act on it, and a red card stranded between two
* later turns is noise. It also keeps the card in lockstep with the footer, which only ever
* describes the tail.
*/
fun `test failure card is dropped once a later turn supersedes it`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
assertEquals("Provider overloaded", cards("a1").single().text())
model.upsertMessage(msg("u2", "user"))
model.upsertMessage(msg("a2", "assistant"))
assertTrue("Nothing in the middle of the transcript", cards("a1").isEmpty())
assertTrue(cards("a2").isEmpty())
}
fun `test only the newest failed turn shows its reason`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant").copy(parentID = "u1", error = failure("Missing credentials")))
model.upsertMessage(msg("u2", "user"))
model.upsertMessage(msg("a2", "assistant").copy(parentID = "u2", error = failure("Missing credentials")))
assertTrue(cards("a1").isEmpty())
assertEquals("Missing credentials", cards("a2").single().text())
}
fun `test clearing the failure removes its card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
assertEquals(1, cards("a1").size)
model.upsertMessage(msg("a1", "assistant"))
assertTrue(cards("a1").isEmpty())
}
/** message.updated also fires on every token/cost delta, so an unchanged failure must be inert. */
fun `test repeated identical failure update does not refresh panel`() {
val failed = msg("a1", "assistant").copy(error = failure("Provider overloaded"))
model.upsertMessage(failed)
val view = panel.findMessage("a1")!!
val card = cards("a1").single()
val repaint = TrackingRepaintManager(setOf(panel, view, card))
val old = RepaintManager.currentManager(panel)
try {
RepaintManager.setCurrentManager(repaint)
model.upsertMessage(failed)
assertSame("The card must be reused, not rebuilt", card, cards("a1").single())
assertTrue(repaint.dirty.isEmpty())
assertTrue(repaint.invalid.isEmpty())
} finally {
RepaintManager.setCurrentManager(old)
}
}
fun `test streamed content stays above the failure card`() {
model.upsertMessage(msg("a1", "assistant").copy(error = failure("Provider overloaded")))
model.updateContent("a1", part("p1", "a1", "text", "partial answer"))
val view = panel.findMessage("a1")!!
assertTrue(view.components.first() is TextView)
assertSame(cards("a1").single(), view.components.last())
}
/**
* SessionLayout sizes the card and then reads its preferred size, so the text area has to measure
* itself at that width. Reporting an unwrapped single line would clip a long provider error.
*/
fun `test long failure text is measured at the transcript width`() {
model.upsertMessage(
msg("a1", "assistant").copy(error = failure("Snowflake Cortex: missing credentials. ".repeat(20))),
)
panel.setSize(320, 4000)
layout(panel)
val card = cards("a1").single()
val area = components(card).filterIsInstance<JBTextArea>().single()
val line = area.getFontMetrics(area.font).height
val chrome = area.insets.top + area.insets.bottom
assertTrue("the card must wrap, not report one line: ${card.height}", card.height > line * 3 + chrome)
assertEquals("the card must be exactly as tall as the wrapped text", area.preferredSize.height, card.height)
}
fun `test transcript content has symmetric side padding`() {
model.upsertMessage(msg("a1", "assistant"))
@@ -1615,6 +1860,24 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0),
)
private fun failure(message: String) = MessageErrorDto("APIError", message)
private fun cards(msgId: String): List<MessageErrorView> = cards(panel, msgId)
private fun cards(item: SessionMessageListPanel, msgId: String): List<MessageErrorView> {
val view = item.findMessage(msgId) ?: return emptyList()
return components(view).filterIsInstance<MessageErrorView>()
}
/** Panel whose footer can offer Retry, so the split between card text and footer action is testable. */
private fun panelWithRetry(retryable: () -> Boolean): SessionMessageListPanel {
val o = SessionOutcomeView(retry = {}, retryable = retryable)
return SessionMessageListPanel(model, parent, openFile = openFile).also { it.outcome = o }
}
private fun button(root: Container, label: String) =
components(root).filterIsInstance<JButton>().firstOrNull { it.text == label }
private fun summary(path: String) = MessageSummaryDto(
diffs = listOf(DiffFileDto(path, 2, 1, PATCH)),
)
@@ -101,11 +101,13 @@ class SessionOutcomeViewTest : BasePlatformTestCase() {
edt {
val view = SessionOutcomeView()
view.showOutcome(Outcome.INTERRUPTED)
view.showOutcome(Outcome.INCOMPLETE, "unknown")
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.note")))
assertNull(findText(view, KiloBundle.message("session.outcome.incomplete.title")))
assertIcons(view, AllIcons.General.Error)
}
}
@@ -164,6 +166,35 @@ class SessionOutcomeViewTest : BasePlatformTestCase() {
}
}
fun `test incomplete outcome shows warning without retry`() {
edt {
val view = SessionOutcomeView(retry = {})
view.showOutcome(Outcome.INCOMPLETE, "unknown")
assertTrue(view.isVisible)
assertNotNull(findText(view, KiloBundle.message("session.outcome.incomplete.title")))
assertNotNull(findText(view, KiloBundle.message("session.outcome.incomplete.description")))
assertIcons(view, AllIcons.General.Warning)
assertTrue(findAll<JBLabel>(view).any {
it.icon == AllIcons.General.Warning &&
it.toolTipText == KiloBundle.message("session.outcome.incomplete.reason", "unknown")
})
assertNull("An incomplete completed message has no Retry action", retryButton(view))
}
}
fun `test incomplete outcome falls back to title tooltip`() {
edt {
val view = SessionOutcomeView(retry = {})
view.showOutcome(Outcome.INCOMPLETE)
assertTrue(findAll<JBLabel>(view).any {
it.icon == AllIcons.General.Warning &&
it.toolTipText == KiloBundle.message("session.outcome.incomplete.title")
})
}
}
fun `test readonly outcome view offers no retry`() {
edt {
val view = SessionOutcomeView(retry = null)
@@ -227,6 +258,55 @@ class SessionOutcomeViewTest : BasePlatformTestCase() {
private fun retryButton(root: Container) =
findAll<JButton>(root).firstOrNull { it.text == KiloBundle.message("session.outcome.retry") }
// ------ action-only failures (the transcript owns the reason) ------
fun `test showRetry offers the action with no message of its own`() {
edt {
var clicked = 0
val view = SessionOutcomeView(retry = { clicked++ })
view.showRetry()
assertTrue(view.isVisible)
assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.title")))
assertNull(
"The transcript card carries the reason",
findText(view, KiloBundle.message("session.outcome.failed.description")),
)
retryButton(view)!!.doClick()
assertEquals(1, clicked)
}
}
fun `test showRetry hides when there is nothing to replay`() {
edt {
val view = SessionOutcomeView(retry = {}, retryable = { false })
view.showRetry()
assertFalse("A header with no reason and no action says nothing", view.isVisible)
}
}
fun `test showRetry hides in a readonly session`() {
edt {
val view = SessionOutcomeView(retry = null)
view.showRetry()
assertFalse(view.isVisible)
}
}
fun `test showRetry drops stale error content`() {
edt {
val view = SessionOutcomeView(retry = {})
view.showError("Provider balance is too low", "APIError")
view.showRetry()
assertNull(findText(view, "Provider balance is too low"))
assertNull(findErrorScroll(view, "Provider balance is too low"))
assertNotNull(retryButton(view))
}
}
fun `test hideView makes view invisible`() {
edt {
val view = SessionOutcomeView()
@@ -101,6 +101,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var revertThrows: Exception? = null
var unrevertThrows: Exception? = null
var commandThrows: Exception? = null
var promptThrows: Exception? = null
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val commands = mutableListOf<CommandCall>()
val attachmentParts = mutableListOf<AttachmentCall>()
@@ -233,6 +234,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
override suspend fun prompt(id: String, directory: String, prompt: PromptDto) {
assertNotEdt("prompt")
promptThrows?.let { throw it }
prompts.add(Triple(id, directory, prompt))
}
@@ -17,6 +17,7 @@ data class MessageDto(
val parentID: String? = null,
val cost: Double? = null,
val tokens: TokensDto? = null,
val finish: String? = null,
val error: MessageErrorDto? = null,
val summary: MessageSummaryDto? = null,
)