mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
fix(jetbrains): show failed turn outcomes in chat
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Show provider errors and interrupted or failed turns in the JetBrains chat instead of returning silently to idle.
|
||||
+10
-2
@@ -50,6 +50,7 @@ import ai.kilocode.client.session.controller.SessionControllerEvent
|
||||
import ai.kilocode.client.session.context.EditorContextGatherer
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.LoginRequiredView
|
||||
import ai.kilocode.client.session.views.SessionOutcomeView
|
||||
import ai.kilocode.client.session.views.permission.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.settings.KiloSettingsConfigurable
|
||||
@@ -188,6 +189,7 @@ class SessionUi(
|
||||
private lateinit var question: QuestionView
|
||||
private lateinit var permission: PermissionView
|
||||
private lateinit var login: LoginRequiredView
|
||||
private lateinit var outcome: SessionOutcomeView
|
||||
private lateinit var connection: ConnectionPanel
|
||||
|
||||
private lateinit var prompt: PromptPanel
|
||||
@@ -260,7 +262,8 @@ class SessionUi(
|
||||
is SessionState.Reverting,
|
||||
is SessionState.Retry,
|
||||
is SessionState.Offline,
|
||||
is SessionState.Error -> null
|
||||
is SessionState.Error,
|
||||
is SessionState.TurnEnded -> null
|
||||
is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED
|
||||
is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION
|
||||
is SessionState.AwaitingQuestion ->
|
||||
@@ -360,6 +363,10 @@ class SessionUi(
|
||||
selection = selection,
|
||||
focus = focus,
|
||||
)
|
||||
outcome = SessionOutcomeView(
|
||||
selection = selection,
|
||||
focus = focus,
|
||||
)
|
||||
messageBody = SessionMessageListPanel(
|
||||
controller.model,
|
||||
this,
|
||||
@@ -377,6 +384,7 @@ class SessionUi(
|
||||
deleteQueued = { id -> controller.deleteQueuedMessage(id) },
|
||||
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
|
||||
).also {
|
||||
it.outcome = outcome
|
||||
it.setDiffOpener(::openInlineDiff, controller.id)
|
||||
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
|
||||
}
|
||||
@@ -952,7 +960,7 @@ class SessionUi(
|
||||
if (wasBusy && state is SessionState.Idle) refreshBranchChanges()
|
||||
wasBusy = busy
|
||||
if (state is SessionState.Reverting) overlay.clear()
|
||||
if (state is SessionState.Error) {
|
||||
if (state is SessionState.Error || state is SessionState.TurnEnded) {
|
||||
pendingRollback = null
|
||||
pendingRedo = null
|
||||
}
|
||||
|
||||
+37
-10
@@ -22,6 +22,9 @@ import ai.kilocode.client.session.model.QuestionOption
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
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
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
@@ -120,6 +123,7 @@ 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
|
||||
@@ -1310,6 +1314,8 @@ class SessionController(
|
||||
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
|
||||
} else if (status != null) {
|
||||
seedStatus(status)
|
||||
} else {
|
||||
seedOutcome()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1342,6 +1348,15 @@ class SessionController(
|
||||
model.setState(state)
|
||||
}
|
||||
|
||||
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))
|
||||
return
|
||||
}
|
||||
model.setState(SessionState.Error(err.message ?: err.type, err.type))
|
||||
}
|
||||
|
||||
private fun handle(event: ChatEventDto) {
|
||||
LOG.debug { ChatLogSummary.event(event) }
|
||||
when (event) {
|
||||
@@ -1419,20 +1434,25 @@ class SessionController(
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingQuestion) return
|
||||
if (current is SessionState.AwaitingPermission) return
|
||||
val clobberOk = event.reason == "completed"
|
||||
|| current is SessionState.Busy
|
||||
|| current is SessionState.Retry
|
||||
|| current is SessionState.Offline
|
||||
if (clobberOk) {
|
||||
if (event.reason == "completed") capture("Task Completed", sessionProps(event.sessionID))
|
||||
model.setState(SessionState.Idle)
|
||||
if (current is SessionState.LoginRequired) return
|
||||
if (current is SessionState.Error && event.reason != "completed") return
|
||||
val ended = TurnOutcome.classify(event.reason, current is SessionState.Error)
|
||||
when {
|
||||
ended != null -> model.setState(SessionState.TurnEnded(ended.first, ended.second))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
is ChatEventDto.SessionCreated -> adoptFollowup(event.info)
|
||||
|
||||
is ChatEventDto.Error -> {
|
||||
capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown")))
|
||||
if (event.error?.type != ABORT_ERROR) {
|
||||
capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown")))
|
||||
}
|
||||
error(event, true)
|
||||
}
|
||||
|
||||
@@ -1553,6 +1573,7 @@ class SessionController(
|
||||
model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description")))
|
||||
return
|
||||
}
|
||||
if (event.error?.type == ABORT_ERROR) return
|
||||
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
|
||||
model.setState(SessionState.Error(msg, event.error?.type))
|
||||
}
|
||||
@@ -1657,7 +1678,11 @@ class SessionController(
|
||||
val state = when (dto.type) {
|
||||
"idle" -> {
|
||||
val current = model.state
|
||||
if (current is SessionState.LoginRequired || current is SessionState.Reverting) return
|
||||
if (current is SessionState.Error
|
||||
|| current is SessionState.TurnEnded
|
||||
|| current is SessionState.LoginRequired
|
||||
|| current is SessionState.Reverting
|
||||
) return
|
||||
purgePending(sid)
|
||||
// purgePending may promote a still-queued permission from another (unpurged) child
|
||||
// session; mirror idle() and leave that card in place rather than clobbering it with Idle.
|
||||
@@ -1666,7 +1691,7 @@ class SessionController(
|
||||
}
|
||||
"busy" -> {
|
||||
val current = model.state
|
||||
if (current is SessionState.Idle || current is SessionState.Error)
|
||||
if (current is SessionState.Idle || current is SessionState.Error || current is SessionState.TurnEnded)
|
||||
SessionState.Busy(KiloBundle.message("session.status.considering"))
|
||||
else return // already in a more specific phase
|
||||
}
|
||||
@@ -1770,6 +1795,7 @@ class SessionController(
|
||||
// Only apply if we're not in a more specific non-terminal state.
|
||||
val current = model.state
|
||||
if (current !is SessionState.Error
|
||||
&& current !is SessionState.TurnEnded
|
||||
&& current !is SessionState.AwaitingPermission
|
||||
&& current !is SessionState.AwaitingQuestion
|
||||
&& current !is SessionState.LoginRequired
|
||||
@@ -2430,6 +2456,7 @@ class SessionController(
|
||||
out.add("[error]")
|
||||
out.add("[${state.message}]")
|
||||
}
|
||||
is SessionState.TurnEnded -> out.add("[${state.outcome.name.lowercase()}]")
|
||||
is SessionState.LoginRequired -> {
|
||||
out.add("[login-required]")
|
||||
out.add("[${state.message}]")
|
||||
|
||||
+3
-1
@@ -22,10 +22,12 @@ 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 LoginRequired(val message: String) : SessionState()
|
||||
|
||||
fun isBusy(): Boolean = when (this) {
|
||||
is Idle, is Loading, is Error, is LoginRequired -> false
|
||||
is Idle, is Loading, is Error, is TurnEnded, is LoginRequired -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ai.kilocode.client.session.model
|
||||
|
||||
enum class Outcome { INTERRUPTED, FAILED }
|
||||
|
||||
enum class OutcomeTone { WARNING, CRITICAL }
|
||||
|
||||
object TurnOutcome {
|
||||
fun classify(reason: String, provider: Boolean): Pair<Outcome, OutcomeTone>? = when {
|
||||
provider -> null
|
||||
reason == "interrupted" -> Outcome.INTERRUPTED to OutcomeTone.WARNING
|
||||
reason == "error" -> Outcome.FAILED to OutcomeTone.CRITICAL
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
+33
@@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.LoginRequiredView
|
||||
import ai.kilocode.client.session.views.MessageView
|
||||
import ai.kilocode.client.session.views.SessionOutcomeView
|
||||
import ai.kilocode.client.session.views.permission.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.session.views.TurnView
|
||||
@@ -91,6 +92,16 @@ class SessionMessageListPanel(
|
||||
|
||||
var onHover: ((PartView, Boolean) -> Unit)? = null
|
||||
var onReflow: ((Boolean) -> Unit)? = null
|
||||
var outcome: SessionOutcomeView? = null
|
||||
set(value) {
|
||||
if (field === value) return
|
||||
field?.let { remove(it) }
|
||||
field = value
|
||||
value?.applyStyle(style)
|
||||
syncActive(model.state)
|
||||
anchorFooter()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Progress footer — always the last child inside the scroll. */
|
||||
val progress = ProgressPanel(model, parent)
|
||||
@@ -424,25 +435,43 @@ class SessionMessageListPanel(
|
||||
setHiddenQuestionTool(state.question.tool)
|
||||
permission?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.hideView()
|
||||
question?.show(state.question)
|
||||
}
|
||||
is SessionState.AwaitingPermission -> {
|
||||
setHiddenQuestionTool(null)
|
||||
question?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.hideView()
|
||||
permission?.show(state.permission)
|
||||
}
|
||||
is SessionState.LoginRequired -> {
|
||||
setHiddenQuestionTool(null)
|
||||
question?.hideView()
|
||||
permission?.hideView()
|
||||
outcome?.hideView()
|
||||
login?.show(state.message)
|
||||
}
|
||||
is SessionState.Error -> {
|
||||
setHiddenQuestionTool(null)
|
||||
question?.hideView()
|
||||
permission?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.showError(state.message, state.kind)
|
||||
}
|
||||
is SessionState.TurnEnded -> {
|
||||
setHiddenQuestionTool(null)
|
||||
question?.hideView()
|
||||
permission?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.showOutcome(state.outcome, state.tone)
|
||||
}
|
||||
else -> {
|
||||
setHiddenQuestionTool(null)
|
||||
question?.hideView()
|
||||
permission?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.hideView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,11 +523,13 @@ class SessionMessageListPanel(
|
||||
if (question != null) remove(question)
|
||||
if (permission != null) remove(permission)
|
||||
if (login != null) remove(login)
|
||||
if (outcome != null) remove(outcome)
|
||||
if (banner != null) remove(banner)
|
||||
remove(progress)
|
||||
if (question != null) add(question)
|
||||
if (permission != null) add(permission)
|
||||
if (login != null) add(login)
|
||||
if (outcome != null) add(outcome)
|
||||
if (banner != null) add(banner)
|
||||
add(progress)
|
||||
}
|
||||
@@ -616,6 +647,7 @@ class SessionMessageListPanel(
|
||||
question?.applyStyle(style)
|
||||
permission?.applyStyle(style)
|
||||
login?.applyStyle(style)
|
||||
outcome?.applyStyle(style)
|
||||
banner?.applyStyle(style)
|
||||
progress.applyStyle(style)
|
||||
reflow()
|
||||
@@ -630,6 +662,7 @@ class SessionMessageListPanel(
|
||||
question?.hideView()
|
||||
permission?.hideView()
|
||||
login?.hideView()
|
||||
outcome?.hideView()
|
||||
turnViews.values.forEach {
|
||||
remove(it)
|
||||
Disposer.dispose(it)
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Outcome
|
||||
import ai.kilocode.client.session.model.OutcomeTone
|
||||
import ai.kilocode.client.session.ui.SessionView
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
|
||||
class SessionOutcomeView(
|
||||
selection: SessionSelection? = null,
|
||||
focus: (() -> Unit)? = null,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
|
||||
|
||||
override val sessionViewKind = SessionView.Kind.Default
|
||||
|
||||
private val card = BaseQuestionView(selection, focus)
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
isVisible = false
|
||||
card.setActions(emptyList())
|
||||
addToCenter(card)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun showError(message: String, kind: String?) {
|
||||
card.setHeaderIcon(AllIcons.General.Error, kind ?: KiloBundle.message("session.error.title"))
|
||||
card.setHeader(KiloBundle.message("session.error.title"), message)
|
||||
isVisible = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun showOutcome(outcome: Outcome, tone: OutcomeTone) {
|
||||
val title = when (outcome) {
|
||||
Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.title")
|
||||
Outcome.FAILED -> KiloBundle.message("session.outcome.failed.title")
|
||||
}
|
||||
val desc = when (outcome) {
|
||||
Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.description")
|
||||
Outcome.FAILED -> KiloBundle.message("session.outcome.failed.description")
|
||||
}
|
||||
val icon = when (tone) {
|
||||
OutcomeTone.WARNING -> AllIcons.General.Warning
|
||||
OutcomeTone.CRITICAL -> AllIcons.General.Error
|
||||
}
|
||||
card.setHeaderIcon(icon, title)
|
||||
card.setHeader(title, desc)
|
||||
isVisible = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun hideView() {
|
||||
if (!isVisible) return
|
||||
isVisible = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
card.applyStyle(style)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
parent?.revalidate()
|
||||
parent?.repaint()
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,12 @@ session.part.todo.hidden.later.many={0} later to-dos hidden
|
||||
|
||||
session.error.prompt=Prompt failed
|
||||
session.error.compact=Session compact failed
|
||||
session.error.title=Request failed
|
||||
session.error.unknown=Unknown error
|
||||
session.outcome.failed.description=The model stopped this turn with an error.
|
||||
session.outcome.failed.title=Response failed
|
||||
session.outcome.interrupted.description=This turn was interrupted before it finished.
|
||||
session.outcome.interrupted.title=Response stopped
|
||||
|
||||
session.login.required.title=You need to sign in to use this model
|
||||
session.login.required.description=Go to User Profile settings to sign in, then continue this session.
|
||||
|
||||
+41
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.MessageErrorDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
@@ -82,6 +83,46 @@ class SessionRecoveryTest : SessionControllerTestBase() {
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
}
|
||||
|
||||
fun `test provider error is recovered from assistant history`() {
|
||||
rpc.history.add(MessageWithPartsDto(
|
||||
msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low")),
|
||||
emptyList(),
|
||||
))
|
||||
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test")
|
||||
flush()
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
assistant#msg1
|
||||
|
||||
[code] [kilo/gpt-5] [error] [OpenRouter balance is too low]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test aborted assistant history recovers interrupted outcome`() {
|
||||
rpc.history.add(MessageWithPartsDto(
|
||||
msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "MessageAbortedError", message = "aborted")),
|
||||
emptyList(),
|
||||
))
|
||||
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test")
|
||||
flush()
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
assistant#msg1
|
||||
|
||||
[code] [kilo/gpt-5] [interrupted]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test permission takes priority over question in recovery`() {
|
||||
rpc.pendingPermissionList.add(
|
||||
PermissionRequestDto(
|
||||
|
||||
+130
@@ -1,7 +1,10 @@
|
||||
package ai.kilocode.client.session.controller
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Outcome
|
||||
import ai.kilocode.client.session.model.OutcomeTone
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.model.TurnOutcome
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
@@ -180,6 +183,64 @@ class TurnLifecycleTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test TurnClose interrupted shows interrupted outcome`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [interrupted]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test TurnClose error without provider error shows failed outcome`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "error"))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [failed]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test abort error waits for interrupted outcome`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "MessageAbortedError", message = "aborted")))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
|
||||
assertTrue(appRpc.telemetry.none { it.event == "Session Error" && it.properties["errorClass"] == "MessageAbortedError" })
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [interrupted]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test provider error wins over interrupted close`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low")))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [error] [OpenRouter balance is too low]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test TurnClose completed clobbers Error state`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
@@ -195,6 +256,30 @@ class TurnLifecycleTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test TurnClose completed clobbers outcome state`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "completed"))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [idle]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test turn outcome classifier`() {
|
||||
assertNull(TurnOutcome.classify("completed", false))
|
||||
assertNull(TurnOutcome.classify("superseded", false))
|
||||
assertEquals(Outcome.INTERRUPTED to OutcomeTone.WARNING, TurnOutcome.classify("interrupted", false))
|
||||
assertEquals(Outcome.FAILED to OutcomeTone.CRITICAL, TurnOutcome.classify("error", false))
|
||||
assertNull(TurnOutcome.classify("error", true))
|
||||
assertNull(TurnOutcome.classify("interrupted", true))
|
||||
}
|
||||
|
||||
fun `test TurnClose completed preserves AwaitingQuestion state`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
@@ -291,6 +376,51 @@ class TurnLifecycleTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test SessionStatus idle does not clobber Error state`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
|
||||
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [error] [Timed out]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test idle signals do not clobber turn outcome`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
emit(ChatEventDto.SessionIdle("ses_test"))
|
||||
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [interrupted]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test busy status clears turn outcome`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
|
||||
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy")))
|
||||
|
||||
assertSession(
|
||||
"""
|
||||
[code] [kilo/gpt-5] [busy] [considering next steps]
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test paid model auth error enters login required state`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
|
||||
+49
-1
@@ -3,6 +3,8 @@ package ai.kilocode.client.session.ui
|
||||
import ai.kilocode.client.session.SessionFileOpener
|
||||
import ai.kilocode.client.session.model.Permission
|
||||
import ai.kilocode.client.session.model.PermissionMeta
|
||||
import ai.kilocode.client.session.model.Outcome
|
||||
import ai.kilocode.client.session.model.OutcomeTone
|
||||
import ai.kilocode.client.session.model.Question
|
||||
import ai.kilocode.client.session.model.QuestionItem
|
||||
import ai.kilocode.client.session.model.QuestionOption
|
||||
@@ -14,6 +16,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.LoginRequiredView
|
||||
import ai.kilocode.client.session.views.PlanExitView
|
||||
import ai.kilocode.client.session.views.SessionOutcomeView
|
||||
import ai.kilocode.client.session.views.base.BaseQuestionView
|
||||
import ai.kilocode.client.session.views.base.PartHeader
|
||||
import ai.kilocode.client.session.views.permission.PermissionView
|
||||
@@ -52,6 +55,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Color
|
||||
@@ -1074,6 +1078,47 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
assertSame(item.progress, item.components.last())
|
||||
}
|
||||
|
||||
fun `test error state makes outcome view visible and hides others`() {
|
||||
val item = panelWithPrompts()
|
||||
model.setState(SessionState.Error("OpenRouter balance is too low", "APIError"))
|
||||
|
||||
val ov = find<SessionOutcomeView>(item)!!
|
||||
val qv = find<QuestionView>(item)!!
|
||||
val pv = find<PermissionView>(item)!!
|
||||
val lv = find<LoginRequiredView>(item)!!
|
||||
|
||||
assertTrue(ov.isVisible)
|
||||
assertFalse(qv.isVisible)
|
||||
assertFalse(pv.isVisible)
|
||||
assertFalse(lv.isVisible)
|
||||
assertNotNull(text(item, "OpenRouter balance is too low"))
|
||||
assertSame(item.progress, item.components.last())
|
||||
}
|
||||
|
||||
fun `test turn ended state makes outcome view visible`() {
|
||||
val item = panelWithPrompts()
|
||||
model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING))
|
||||
|
||||
val ov = find<SessionOutcomeView>(item)!!
|
||||
val comps = item.components.toList()
|
||||
|
||||
assertTrue(ov.isVisible)
|
||||
assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.description")))
|
||||
assertTrue(comps.indexOf(ov) < comps.indexOf(item.progress))
|
||||
assertSame(item.progress, comps.last())
|
||||
}
|
||||
|
||||
fun `test returning to idle hides outcome view`() {
|
||||
val item = panelWithPrompts()
|
||||
model.setState(SessionState.TurnEnded(Outcome.FAILED, OutcomeTone.CRITICAL))
|
||||
model.setState(SessionState.Idle)
|
||||
|
||||
val ov = find<SessionOutcomeView>(item)!!
|
||||
|
||||
assertFalse(ov.isVisible)
|
||||
assertSame(item.progress, item.components.last())
|
||||
}
|
||||
|
||||
fun `test login required button invokes openProfile callback`() {
|
||||
var called = false
|
||||
val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {})
|
||||
@@ -1524,11 +1569,14 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
reply = { _, _, _ -> },
|
||||
)
|
||||
val l = LoginRequiredView(openProfile = {}, dismiss = {})
|
||||
return SessionMessageListPanel(model, parent, q, p, l, openFile)
|
||||
val o = SessionOutcomeView()
|
||||
return SessionMessageListPanel(model, parent, q, p, l, openFile).also { it.outcome = o }
|
||||
}
|
||||
|
||||
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
|
||||
|
||||
private fun text(root: Container, value: String) = components(root).filterIsInstance<JBTextArea>().firstOrNull { it.text == value }
|
||||
|
||||
private fun <T> findCls(root: Container, cls: Class<T>): T? {
|
||||
if (cls.isInstance(root)) return cls.cast(root)
|
||||
for (child in root.components) {
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Outcome
|
||||
import ai.kilocode.client.session.model.OutcomeTone
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import java.awt.Container
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class SessionOutcomeViewTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test view is initially hidden`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
assertFalse(view.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test showError renders title and message`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
view.showError("OpenRouter balance is too low", "APIError")
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
assertNotNull(findText(view, KiloBundle.message("session.error.title")))
|
||||
assertNotNull(findText(view, "OpenRouter balance is too low"))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test showOutcome renders interrupted copy and warning icon`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING)
|
||||
|
||||
assertTrue(view.isVisible)
|
||||
assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.title")))
|
||||
assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description")))
|
||||
assertTrue(findAll<JBLabel>(view).any { it.icon != null })
|
||||
}
|
||||
}
|
||||
|
||||
fun `test showOutcome updates without stale text`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING)
|
||||
view.showOutcome(Outcome.FAILED, OutcomeTone.CRITICAL)
|
||||
|
||||
assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.title")))
|
||||
assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.description")))
|
||||
assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.description")))
|
||||
}
|
||||
}
|
||||
|
||||
fun `test hideView makes view invisible`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
view.showError("Request failed", "APIError")
|
||||
view.hideView()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test description uses secondary font not editor font family`() {
|
||||
edt {
|
||||
val view = SessionOutcomeView()
|
||||
view.showError("Provider balance is too low", "APIError")
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
|
||||
view.applyStyle(style)
|
||||
|
||||
val desc = findText(view, "Provider balance is too low")
|
||||
assertNotNull(desc)
|
||||
assertFalse(desc!!.font.name == "Courier New")
|
||||
assertEquals(SessionUiStyle.Text.Secondary.font(style), desc.font)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findText(root: Container, text: String) = findAll<JBTextArea>(root).firstOrNull { it.text == text }
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
|
||||
|
||||
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
if (cls.isInstance(root)) result.add(cls.cast(root))
|
||||
for (child in root.components) {
|
||||
if (cls.isInstance(child)) result.add(cls.cast(child))
|
||||
if (child is Container) result.addAll(findAllCls(child, cls))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user