mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(jetbrains): warn when a provider ends a response unfinished
A turn whose provider ended the stream without a terminal stop reason closed as "completed" with no message error and no session error, so the session simply went idle and the truncated answer had no explanation. Plumb the assistant finish reason through to the frontend and render a warning outcome card for "unknown" and "other", with the raw reason on the icon tooltip. The notice is seeded from history so it survives reopening the session. "length" is excluded because the CLI already writes a visible warning text part for it. Also broaden the branch/changes refresh from Idle to any non-busy state, so a turn that edited files still refreshes the git indicators when it ends incomplete, failed, interrupted, or in error.
This commit is contained in:
@@ -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.
|
||||
+1
@@ -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,
|
||||
)
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1099,7 +1099,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()
|
||||
}
|
||||
|
||||
+13
-4
@@ -1513,7 +1513,13 @@ class SessionController(
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1600,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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+15
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -537,7 +537,7 @@ class SessionMessageListPanel(
|
||||
// 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)
|
||||
else outcome?.showOutcome(state.outcome, state.finish)
|
||||
}
|
||||
else -> {
|
||||
setHiddenQuestionTool(null)
|
||||
|
||||
+10
-1
@@ -79,7 +79,7 @@ class SessionOutcomeView(
|
||||
* 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)
|
||||
@@ -95,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)
|
||||
|
||||
@@ -214,6 +214,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
+3
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
@@ -77,6 +77,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
|
||||
|
||||
|
||||
+3
@@ -77,6 +77,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
|
||||
|
||||
|
||||
+3
@@ -77,6 +77,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
|
||||
|
||||
|
||||
+17
@@ -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()
|
||||
|
||||
|
||||
+56
@@ -265,6 +265,62 @@ class SessionRecoveryTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
+48
-1
@@ -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`() {
|
||||
|
||||
+12
@@ -235,6 +235,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
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"))
|
||||
|
||||
+31
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user