Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clear-empty-failed-turn.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-stopped-session-not-an-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Unit> {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
Expand All @@ -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<Unit> {
directories["ses_plain"] = "/repo/a"
directories["ses_plan"] = "/repo/b"
start()
Expand Down Expand Up @@ -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<Unit> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ class SessionUi(
outcome = SessionOutcomeView(
selection = selection,
focus = focus,
retry = if (readonly) null else controller::retry,
retryable = controller::canRetry,
)
messageBody = SessionMessageListPanel(
controller.model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -489,6 +487,98 @@ 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.
*
* 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 ?: "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 {
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) }
} 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")))
}
}
}
}

/** 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, 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 tail = model.messages().lastOrNull() ?: return null
val err = tail.info.error
val state = model.state
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 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.
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)

fun deleteQueuedMessage(message: String) {
assertEdt()
val id = sid ?: return
Expand Down Expand Up @@ -1422,8 +1512,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))
Expand Down Expand Up @@ -1510,7 +1600,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)
Expand All @@ -1522,7 +1612,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)
Expand Down Expand Up @@ -1645,7 +1735,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))
}
Expand Down Expand Up @@ -1877,6 +1967,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(
Expand All @@ -1890,6 +1986,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Outcome, OutcomeTone>? = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading