From a68cb8a03610e22895ae478304a8a1201c52778f Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 18:59:44 -0400 Subject: [PATCH 1/2] fix(jetbrains): stop mode picker from cancelling running sessions Switching the chat mode picker wrote default_agent to the CLI's global config. The CLI disposes every instance it holds whenever that file changes, which cancels every running turn in every open worktree -- three unrelated sessions died mid-turn from a single mode switch, with no error shown because a server-initiated cancellation and a user Stop both report the same MessageAbortedError. The mode pick now stays client-side: it rides on PromptDto.agent per turn (as it already did) and is remembered in KiloPluginSettings so new sessions still open in the last-picked mode, matching how VS Code and the TUI already handle this. No CLI config write happens. Since the CLI can still legitimately cancel a turn on its own (a settings/provider change disposing instances), the plugin now tells those apart from a user Stop: an unrequested abort shows the reason and offers Retry instead of a silent "Stopped", raises a notification, and is captured in telemetry. The backend synthesizes a session.interrupted event naming the cause when disposal happens while a session is busy. --- .../jetbrains-mode-switch-cancels-sessions.md | 9 + .../backend/app/KiloBackendActivityManager.kt | 4 + .../backend/app/KiloBackendAppService.kt | 15 +- .../backend/app/KiloBackendChatManager.kt | 36 ++-- .../kilocode/backend/cli/KiloCliDataParser.kt | 26 --- .../backend/rpc/KiloSessionRpcApiImpl.kt | 4 - .../backend/app/KiloBackendAppServiceTest.kt | 45 +++++ .../backend/app/KiloBackendChatManagerTest.kt | 24 +++ .../backend/cli/KiloCliDataParserTest.kt | 30 --- .../kilocode/client/app/KiloSessionService.kt | 6 - .../client/plugin/KiloPluginSettings.kt | 19 ++ .../session/controller/SessionController.kt | 127 ++++++++++-- .../resources/messages/KiloBundle.properties | 3 + .../worktree/NewWorktreeDialogTest.kt | 10 +- .../session/controller/ConfigSelectionTest.kt | 37 +++- .../session/controller/PromptLifecycleTest.kt | 6 +- .../controller/SessionCancellationTest.kt | 188 ++++++++++++++++++ .../controller/SessionControllerTestBase.kt | 10 + .../session/controller/TurnLifecycleTest.kt | 3 + .../client/testing/FakeSessionRpcApi.kt | 7 - .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 7 + .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 4 - .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 28 ++- 23 files changed, 514 insertions(+), 134 deletions(-) create mode 100644 .changeset/jetbrains-mode-switch-cancels-sessions.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt diff --git a/.changeset/jetbrains-mode-switch-cancels-sessions.md b/.changeset/jetbrains-mode-switch-cancels-sessions.md new file mode 100644 index 00000000000..3a2bdf00306 --- /dev/null +++ b/.changeset/jetbrains-mode-switch-cancels-sessions.md @@ -0,0 +1,9 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix switching chat mode cancelling running tasks in every open worktree, and explain any task Kilo stops on its own + +Picking a mode in the chat prompt used to be saved as the CLI's global default, which made the CLI reload and cancel every task that was running anywhere. The mode now stays in the IDE and travels with each message, and it is still remembered for new chats. + +When Kilo does stop a task without being asked — a settings or provider change, for example — the chat now shows why, offers Retry, and raises a notification, instead of quietly reporting "Stopped". diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index 24c1752d3cc..f98dc1c0b47 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -89,6 +89,10 @@ class KiloBackendActivityManager( // 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) } + // A cancellation the user did not ask for is a failure, and the abort that follows it is + // indistinguishable from a Stop. Badge it from the interruption instead, so the session + // list, worktree rows, and the Agents tab dot all report the lost turn. + is ChatEventDto.SessionInterrupted -> errors.add(event.sessionID) 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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 519efd34eb5..03d94c16f92 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -19,6 +19,7 @@ import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.ConfigPatchDto @@ -826,13 +827,13 @@ class KiloBackendAppService private constructor( } } "global.disposed" -> { - logSessionDisposalRisk("global.disposed") + reportDisposal("global.disposed") log.info("SSE global.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() } "server.instance.disposed" -> { - logSessionDisposalRisk("server.instance.disposed") + reportDisposal("server.instance.disposed") log.info("SSE server.instance.disposed — triggering full application reload") val current = _appState.value if (current is KiloAppState.Ready) load() @@ -843,10 +844,18 @@ class KiloBackendAppService private constructor( } } - private fun logSessionDisposalRisk(event: String) { + /** + * Warn, and tell every running session that the CLI is about to cancel it. + * + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only way the UI can + * tell the difference and explain itself instead of quietly reporting "Stopped". + */ + private fun reportDisposal(event: String) { val active = sessions.statuses.value.filterValues { it.type != "idle" } if (active.isEmpty()) return log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD) } private suspend fun clear() { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 4ebd49744e2..43833a6d6f6 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -4,7 +4,6 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -327,25 +326,22 @@ class KiloBackendChatManager( } } - // ------ config update ------ - - fun updateConfig(dir: String, update: ConfigUpdateDto) { - val http = requireClient() - val url = requireBase() - - val partial = KiloCliDataParser.buildConfigPartial(update) - - val request = Request.Builder() - .url("$url/global/config") - .patch(partial.toRequestBody(JSON_TYPE)) - .build() - - http.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - val msg = response.body?.string() ?: "unknown error" - log.warn("config update failed: HTTP ${response.code} — $msg") - } else { - log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}") + // ------ interruption ------ + + /** + * Tell every session in [ids] that the CLI stopped its turn for [reason]. + * + * Synthesized into the same stream the CLI events use so a session's own controller sees it in + * order with the abort it explains. The CLI cannot express this itself: it reports a server-side + * cancellation as the same `MessageAbortedError` a user Stop produces, so the UI would otherwise + * report work nobody stopped as "Stopped". + */ + fun interrupt(ids: Collection, reason: String) { + if (ids.isEmpty()) return + cs.launch { + for (id in ids) { + log.warn("${ChatLogSummary.sid(id)} kind=interrupt route=chat-events reason=$reason") + _events.emit(ChatEventDto.SessionInterrupted(id, reason)) } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 1e401e45b5c..5f82ef59fe8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -20,7 +20,6 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.CommandFileDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto @@ -915,31 +914,6 @@ object KiloCliDataParser { return "{${fields.joinToString(",")}}" } - /** - * Build the partial JSON body for `PATCH /global/config`. - */ - fun buildConfigPartial(update: ConfigUpdateDto): String { - val sb = StringBuilder("{") - var first = true - fun sep() { if (!first) sb.append(","); first = false } - - val model = update.model - if (model != null) { - sep(); sb.append(""""model":${escape(model)}""") - } - val agent = update.agent - if (agent != null) { - sep(); sb.append(""""default_agent":${escape(agent)}""") - } - val temp = update.temperature - if (temp != null) { - val target = agent ?: "ask" - sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""") - } - sb.append("}") - return sb.toString() - } - fun buildConfigPatch(patch: ConfigPatchDto): String { val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant", "default_agent") val obj = buildJsonObject { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 49764a2e2c1..80b21f39f1e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -11,7 +11,6 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -286,9 +285,6 @@ class KiloSessionRpcApiImpl internal constructor( log.warn("${ChatLogSummary.sid(id)} kind=subscription route=rpc-events stop=true failed message=${cause.message}", cause) } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = - ready { chat.updateConfig(directory, config) } - // ------ permission / question resolution ------ override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index d8009e79587..7e6d052561f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -9,13 +9,16 @@ import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -836,6 +839,48 @@ class KiloBackendAppServiceTest { assertNotNull(svc.config) } + /** + * Disposing an instance cancels every runner it owns, and the CLI reports that as the same + * `MessageAbortedError` a user Stop produces. Naming the cause here is the only thing that lets the + * UI explain the lost turn instead of reporting it as "Stopped" — three sessions once died to a + * config reload with no trace the user could see. + */ + @Test + fun `disposal while a session is busy names the reason for that session`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + val event = assertIs(withTimeout(15_000) { received.await() }) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + + @Test + fun `disposal with no busy session names nothing`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + + val received = scope.async(start = CoroutineStart.UNDISPATCHED) { + svc.chat.events.first { it is ChatEventDto.SessionInterrupted } + } + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + + assertNull(withTimeoutOrNull(2_000) { received.await() }) + received.cancel() + } + @Test fun `restart lifecycle transitions correctly`() = runBlocking { val svc = create() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 3225fef1a03..7a5a0b0c19a 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -229,6 +229,30 @@ class KiloBackendChatManagerTest { assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + /** + * A cancellation the CLI starts on its own reaches the UI as the same `MessageAbortedError` a Stop + * produces, so the reason has to travel on its own event through the same stream. + */ + @Test + fun `interrupt emits one reason event per running session`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + // UNDISPATCHED runs the collector up to its first suspension on this thread, so the subscription + // is registered before interrupt() emits — the flow buffers rather than blocks, so a late + // subscriber would simply miss the event. + val received = async(start = CoroutineStart.UNDISPATCHED) { + chat.events.first { it is ChatEventDto.SessionInterrupted } + } + chat.interrupt(listOf("ses_abc"), ChatEventDto.SessionInterrupted.RELOAD) + + val event = withTimeout(5_000) { received.await() } + assertTrue(event is ChatEventDto.SessionInterrupted) + assertEquals("ses_abc", event.sessionID) + assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) + } + @Test fun `global message event type is extracted from payload`() = runBlocking { val port = mock.start() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 892102f64ac..7743b48c240 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -7,7 +7,6 @@ import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -2306,29 +2305,6 @@ class KiloCliDataParserTest { assertEquals("""{"messageID":"m\"\\1","partID":"p\"\\1"}""", result) } - // ---- buildConfigPartial ---- - - @Test - fun `buildConfigPartial - model only`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) - assertEquals("""{"model":"anthropic/claude-4"}""", result) - } - - @Test - fun `buildConfigPartial - agent and temperature`() { - val result = KiloCliDataParser.buildConfigPartial( - ConfigUpdateDto(agent = "code", temperature = 0.7) - ) - assertTrue(result.contains(""""default_agent":"code"""")) - assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) - } - - @Test - fun `buildConfigPartial - empty update`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) - assertEquals("{}", result) - } - @Test fun `buildConfigPatch - top-level model set`() { val patch = ConfigPatchDto(values = linkedMapOf("model" to "anthropic/claude")) @@ -2470,12 +2446,6 @@ class KiloCliDataParserTest { assertEquals("{\"model\":\"kilo/a\\\\b\\\"c\"}", KiloCliDataParser.buildConfigPatch(patch)) } - @Test - fun `buildConfigPartial - temperature without agent defaults to ask`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) - assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) - } - // ---- buildPermissionReplyJson ---- @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 47d723dcac4..2fde1784546 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -8,7 +8,6 @@ import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -317,11 +316,6 @@ class KiloSessionService internal constructor( } } - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { - call { updateConfig(dir, config) } - } - // ------ permission / question resolution ------ /** Reply to a pending permission request. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index ac54c6597fc..6a8564a4898 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -7,6 +7,25 @@ object KiloPluginSettings { private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" + private const val AGENT_KEY = "kilo.session.agent" + + /** + * Mode the prompt picker last selected, or null when the CLI's own default should win. + * + * IDE-local on purpose. This used to be written to the CLI's global config as `default_agent`, + * which made the CLI dispose every instance it held and cancel every running turn in every + * worktree — a mode switch is a UI preference, not a server reconfiguration. The picked mode + * still travels with each prompt, so nothing here reaches the server. + */ + fun getAgent(): String? = PropertiesComponent.getInstance().getValue(AGENT_KEY)?.takeIf { it.isNotBlank() } + + fun setAgent(value: String) { + PropertiesComponent.getInstance().setValue(AGENT_KEY, value) + } + + internal fun unsetAgent() { + PropertiesComponent.getInstance().unsetValue(AGENT_KEY) + } fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0d833949e3..50add913377 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.KiloNotifications import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace @@ -32,15 +33,16 @@ import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.client.util.edtLater as edt +import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ProfileDto @@ -106,6 +108,7 @@ class SessionController( private val loaded: (Boolean) -> Unit = {}, private val openProfileAction: () -> Unit = {}, private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, + private val notify: (String, String) -> Unit = { title, body -> KiloNotifications.error(title, body) }, private val timers: UiTimerSource = UiTimers, private val log: KiloLog = LOG, ) : Disposable { @@ -192,6 +195,12 @@ class SessionController( private var prefVariantKey: String? = null private var prefVariant: String? = null private var modelTime: Double? = null + // A Stop this UI asked for, so the abort it produces reads as "Stopped" rather than a failure. + // Reset on every turn open: the flag describes one cancellation, not the session. + private var stopRequested = false + // Why the CLI cancelled the current turn, when it told us. Races the abort it explains, so the + // reason may land before or after the error and both orders have to end up in the same place. + private var cancelReason: String? = null private val snapshots = mutableMapOf() val ready: Boolean get() = model.isReady() @@ -303,6 +312,10 @@ class SessionController( private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) { assertEdt() if (revertOp != null) return + // New work, so the previous cancellation is settled. Turn open clears this too, but a send that + // never reaches a turn would otherwise leave a stale Stop suppressing the next explanation. + stopRequested = false + cancelReason = null val props = data.props + if (data.kind == "command") slashProps() else emptyMap() capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf( "source" to data.source, @@ -380,6 +393,7 @@ class SessionController( return } val id = sid ?: return + stopRequested = true updateModel { (childIds + id).forEach(::purgePending) } capture("Session Stop Clicked", sessionProps(id)) cs.launch { @@ -499,6 +513,9 @@ class SessionController( SessionState.Reverting.Kind.ROLLBACK, message, ) ?: return + // Marked here rather than beside the abort below: the abort's error event can arrive before a + // hop back onto the EDT would, and an unmarked abort reads as a cancellation we did not ask for. + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -590,7 +607,9 @@ class SessionController( 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 stop the user never asked for is: `error()` promoted it to SessionState.Error, so + // follow the state the transcript is already showing rather than the error name alone. + err != null -> !err.aborted || state is SessionState.Error // A turn that completed cleanly is not retryable even when a session-level error arrives // afterwards: continuing it would ask the model to redo work it already delivered. tail.info.role == "assistant" && tail.info.time.completed != null -> false @@ -686,6 +705,7 @@ class SessionController( SessionState.Reverting.Kind.REDO, message, ) ?: return + if (busy) stopRequested = true revertJob = cs.launch { try { if (busy) { @@ -761,6 +781,15 @@ class SessionController( } } + /** + * Switch this session's mode. + * + * Stays entirely client-side. The pick lives on [SessionModel.agent] for this session and in + * [KiloPluginSettings] as the mode the next new session opens with, and it reaches the CLI only + * as [PromptDto.agent] on each turn. It must never be written to the CLI's global config: the + * CLI disposes every instance it holds when that file changes, which cancels every running turn + * in every worktree. `NewWorktreeDialog` reached the same conclusion for its own picker. + */ fun selectAgent(name: String) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" } @@ -770,13 +799,7 @@ class SessionController( prefAgent = null prefVariantKey = null prefVariant = null - cs.launch { - try { - sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) - } catch (e: Exception) { - LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - } - } + KiloPluginSettings.setAgent(name) fire(SessionControllerEvent.WorkspaceReady) { model.agent = name syncModelSelection() @@ -1152,7 +1175,7 @@ class SessionController( } ?: emptyList() if (this@SessionController.model.agent == null) { - this@SessionController.model.agent = state.agents?.default + this@SessionController.model.agent = seedAgent(state.agents) } syncModelSelection() model.refreshHeader() @@ -1617,6 +1640,8 @@ class SessionController( is ChatEventDto.TurnOpen -> { partType = null tool = null + stopRequested = false + cancelReason = null if (revertOp != null) { revertDeferred = SessionState.Busy(KiloBundle.message("session.status.considering")) return @@ -1657,7 +1682,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - if (event.error?.aborted != true) { + if (event.error?.aborted != true || unrequested(event.error)) { capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) } error(event, true) @@ -1707,6 +1732,7 @@ class SessionController( } is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) + is ChatEventDto.SessionInterrupted -> interrupted(event) } } @@ -1732,6 +1758,7 @@ class SessionController( private fun handleHidden(event: ChatEventDto): Boolean = when (event) { is ChatEventDto.Error, + is ChatEventDto.SessionInterrupted, is ChatEventDto.PermissionAsked, is ChatEventDto.PermissionReplied, is ChatEventDto.QuestionAsked, @@ -1754,6 +1781,7 @@ class SessionController( LOG.debug { ChatLogSummary.event(event) } when (event) { is ChatEventDto.Error -> error(event, false) + is ChatEventDto.SessionInterrupted -> interrupted(event) is ChatEventDto.PermissionAsked -> asked(event) is ChatEventDto.PermissionReplied -> replied(event) is ChatEventDto.QuestionAsked -> asked(event) @@ -1770,7 +1798,8 @@ class SessionController( private fun error(event: ChatEventDto.Error, reveal: Boolean) { partType = null tool = null - if (isPaidModelAuthRequired(event.error)) { + val err = event.error + if (isPaidModelAuthRequired(err)) { loginRetry = retryPrompt() if (reveal) showSession() capture("Account Overlay Shown", sessionProps(event.sessionID) + mapOf( @@ -1780,9 +1809,62 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) 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)) + if (err != null && err.aborted) { + if (stopRequested) return + surfaceCancelled(event.sessionID, err.type) + return + } + val msg = err?.message ?: err?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, err?.type)) + } + + /** + * Whether an abort arrived that this UI never asked for. + * + * The CLI cannot tell us: it reports a Stop and a server-side cancellation with the same + * `MessageAbortedError`, so only the client knows whether it pressed Stop. Everything else — + * a config reload disposing instances, a session deleted elsewhere, another editor aborting the + * same session — lands here and has to be explained rather than passed off as the user's doing. + */ + private fun unrequested(err: MessageErrorDto?): Boolean = err != null && err.aborted && !stopRequested + + /** + * Report a cancellation nobody in this UI asked for. + * + * Uses [SessionState.Error] rather than an interrupted outcome so the transcript prints the reason + * and offers Retry: the turn lost its work, which is a failure however politely the CLI phrased it. + * The balloon is for the case that caused this to exist — a session cancelled in a worktree the + * user is not currently looking at, which the transcript alone can never tell them about. + */ + private fun surfaceCancelled(session: String?, kind: String) { + val reason = cancelReason + val text = cancelledMessage(reason) + LOG.warn("${ChatLogSummary.sid(session ?: sid ?: "?")} kind=cancelled requested=false reason=${reason ?: "unknown"}") + model.setState(SessionState.Error(text, kind)) + notify(KiloBundle.message("session.cancelled.title"), text) + } + + private fun cancelledMessage(reason: String?): String = when (reason) { + ChatEventDto.SessionInterrupted.RELOAD -> KiloBundle.message("session.cancelled.reload") + else -> KiloBundle.message("session.cancelled.unknown") + } + + /** + * Record why the CLI stopped this turn, and re-label an already-visible cancellation. + * + * This races the abort it explains — the CLI publishes the cancellation before it finishes + * disposing, and the disposal event that names the cause can land on either side of it. Handling + * both orders here keeps the reason out of the ordering's hands. + */ + private fun interrupted(event: ChatEventDto.SessionInterrupted) { + if (cancelReason == event.reason) return + cancelReason = event.reason + val current = model.state + // Only a cancellation already on screen needs relabelling, and only [surfaceCancelled] puts the + // abort's name on an error state — a provider failure carries its own reason and keeps it. + if (current !is SessionState.Error) return + if (current.kind != MessageErrorDto.ABORTED) return + model.setState(SessionState.Error(cancelledMessage(event.reason), current.kind)) } private fun asked(event: ChatEventDto.PermissionAsked) { @@ -2187,6 +2269,20 @@ class SessionController( open(SessionRef.Local(session)) } + /** + * Mode a session with no history of its own opens in. + * + * The last mode the picker selected wins, so switching mode still sticks across new sessions and + * IDE restarts without the CLI's global config — the write that used to provide this also tore + * down every instance the CLI held. Falls back to the CLI default when the remembered mode is + * gone (renamed, hidden, or removed from a different config). + */ + private fun seedAgent(agents: AgentsDto?): String? { + val remembered = KiloPluginSettings.getAgent() ?: return agents?.default + val offered = agents?.agents ?: return remembered + return if (offered.any { it.name == remembered }) remembered else agents.default + } + private fun syncHistoryAgent(items: List) { val before = model.prefs() val agent = items @@ -2767,6 +2863,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionInterrupted -> event.sessionID == id is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 41c5ac8b65f..7f8499752c4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -242,6 +242,9 @@ session.outcome.incomplete.description=The provider ended this response without session.outcome.incomplete.reason=Technical finish reason: {0} session.outcome.retry=Retry session.outcome.interrupted.note=Stopped +session.cancelled.title=Kilo stopped a running task +session.cancelled.reload=Kilo reloaded its configuration and stopped this task before it finished. Nothing was lost from the conversation \u2014 use Retry to continue. +session.cancelled.unknown=This task was stopped before it finished, and not from this window. Use Retry to continue. session.login.required.title=You need to sign in to use this model session.login.required.description=Go to User Profile settings to sign in, then continue this session. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 0edfd4b885c..48080bc5199 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker @@ -51,6 +52,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { val ws = FakeWorkspaceRpcApi().apply { models = workspace() } workspaces = KiloWorkspaceService(scope, ws) sessionRpc = FakeSessionRpcApi() + KiloPluginSettings.unsetAgent() } override fun tearDown() { @@ -58,6 +60,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { dialog?.let { d -> edt { Disposer.dispose(d.disposable) } } dialog = null scope.cancel() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -75,7 +78,7 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } - fun `test selecting a mode forwards it with the created prompt and writes no global config`() { + fun `test selecting a mode forwards it with the created prompt only`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -86,9 +89,10 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { flushUntil { edt { prompt().isSendEnabled } } edt { prompt().send() } + // The prompt is the only place the pick travels: writing it to the CLI's global default_agent + // disposed every instance the CLI held and cancelled every running turn in every worktree. assertEquals("plan", submitted().prompt?.agent) - // Picking a mode must no longer mutate the global default_agent config. - assertTrue(sessionRpc.configs.none { it.second.agent != null }) + assertNull(KiloPluginSettings.getAgent()) } fun `test selecting a model persists it for the default agent`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt index 1a986bc6c9a..652665aa2bc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.controller +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto @@ -21,7 +22,6 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectModel("kilo", "gpt-5") } flush() - assertTrue(rpc.configs.isEmpty()) assertEquals("code", appRpc.selections.single().agent) assertEquals("kilo", appRpc.selections.single().providerID) assertEquals("gpt-5", appRpc.selections.single().modelID) @@ -34,7 +34,11 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } - fun `test selectAgent updates SessionModel and calls updateConfig`() { + /** + * A mode switch must stay client-side. Writing it to the CLI's global config made the CLI dispose + * every instance it held, cancelling every running turn in every worktree. + */ + fun `test selectAgent stays local and never patches CLI config`() { val m = controller() collect(m) flush() @@ -42,8 +46,7 @@ class ConfigSelectionTest : SessionControllerTestBase() { edt { m.selectAgent("plan") } flush() - assertEquals(1, rpc.configs.size) - assertEquals("plan", rpc.configs[0].second.agent) + assertEquals("plan", KiloPluginSettings.getAgent()) assertSession( """ [plan] [app: DISCONNECTED] [workspace: PENDING] @@ -53,6 +56,32 @@ class ConfigSelectionTest : SessionControllerTestBase() { ) } + fun `test remembered mode seeds a new session ahead of the CLI default`() { + edt { KiloPluginSettings.setAgent("plan") } + projectRpc.state.value = workspaceReady( + agents = listOf( + AgentDto(name = "code", displayName = "Code", mode = "code"), + AgentDto(name = "plan", displayName = "Plan", mode = "code"), + ), + default = "code", + ) + val m = controller() + collect(m) + flush() + + assertEquals("plan", m.model.agent) + } + + fun `test CLI default wins when the remembered mode no longer exists`() { + edt { KiloPluginSettings.setAgent("removed-mode") } + projectRpc.state.value = workspaceReady(default = "code") + val m = controller() + collect(m) + flush() + + assertEquals("code", m.model.agent) + } + fun `test selectModel fires WorkspaceReady event`() { projectRpc.state.value = workspaceReady() val m = controller() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 768c9e36b39..0e98ab904a7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -502,7 +502,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies) emit(ChatEventDto.MessageUpdated("ses_test", msg("msg_code", "ses_test", "user").copy( @@ -514,7 +514,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertEquals("code", m.model.agent) assertEquals("anthropic/claude", m.model.model) assertFalse(m.model.modelOverride) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertControllerEvents("WorkspaceReady", events) } @@ -533,7 +533,7 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals("plan", m.model.agent) - assertTrue(rpc.configs.none { it.second.agent == "code" }) + assertNull(KiloPluginSettings.getAgent()) assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt new file mode 100644 index 00000000000..9f6a90c44e9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto + +/** + * A cancellation nobody asked for has to explain itself. + * + * The CLI reports a Stop and a server-side cancellation with the same `MessageAbortedError`, and the UI + * used to treat both as a Stop: a muted "Stopped" line, no reason, no Retry, no telemetry. Three + * sessions once died to a config reload leaving no trace anywhere the user could see. Only this client + * knows whether it pressed Stop, so that is what separates the two here. + */ +class SessionCancellationTest : SessionControllerTestBase() { + + private val abort = MessageErrorDto(type = MessageErrorDto.ABORTED, message = "Aborted") + + private fun reloadText() = KiloBundle.message("session.cancelled.reload") + + private fun unknownText() = KiloBundle.message("session.cancelled.unknown") + + private fun cancelled(text: String) = SessionState.Error(text, MessageErrorDto.ABORTED) + + fun `test unrequested abort surfaces an error instead of Stopped`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + fun `test interruption names the reason on the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** + * The CLI publishes the cancellation while it is still disposing, so the event naming the cause can + * land on either side of the abort. Both orders have to end up on the same message. + */ + fun `test interruption arriving after the abort relabels the error`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + assertEquals(cancelled(unknownText()), m.model.state) + + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + + assertEquals(cancelled(reloadText()), m.model.state) + } + + /** The transcript cannot reach a worktree the user is not looking at; the balloon can. */ + fun `test unrequested abort raises a balloon naming the reason`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.SessionInterrupted("ses_test", ChatEventDto.SessionInterrupted.RELOAD)) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(listOf(KiloBundle.message("session.cancelled.title") to reloadText()), notifications) + } + + fun `test unrequested abort is reported to telemetry`() { + prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue( + appRpc.telemetry.any { + it.event == "Session Error" && it.properties["errorClass"] == MessageErrorDto.ABORTED + }, + ) + } + + fun `test unrequested abort offers Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertEquals(cancelled(unknownText()), m.model.state) + edt { assertTrue("A turn the user never stopped lost work, so it must be continuable", m.canRetry()) } + } + + fun `test stopped turn offers no Retry`() { + val m = failedTurn() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + + assertTrue(notifications.isEmpty()) + edt { assertFalse(m.canRetry()) } + } + + /** A revert aborts a busy session on purpose, so its abort is a Stop and not a lost turn. */ + fun `test revert abort counts as requested`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.revert("msg1") } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + + assertTrue(notifications.isEmpty()) + assertFalse(m.model.state is SessionState.Error) + } + + /** The flag describes one cancellation. A later turn must not inherit the earlier Stop. */ + fun `test a new turn stops inheriting the previous Stop`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() + emit(ChatEventDto.Error("ses_test", abort)) + emit(ChatEventDto.TurnClose("ses_test", "interrupted")) + assertTrue(notifications.isEmpty()) + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", abort)) + + assertEquals(cancelled(unknownText()), m.model.state) + } + + /** A reopened session cannot know who stopped its last turn, so history stays a plain "Stopped". */ + fun `test reopened session keeps an aborted tail as Stopped`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + + assertTrue(m.model.state is SessionState.TurnEnded) + assertTrue(notifications.isEmpty()) + } + + /** + * A loaded session whose tail can be continued: Retry needs a user message to re-prompt and an + * assistant message parented to it. + */ + private fun failedTurn(): SessionController { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = abort), + emptyList(), + ), + ) + val m = controller("ses_test") + collect(m) + flush() + return m + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 1424560cf62..b733c27c7d9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.AgentDto @@ -106,12 +107,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected lateinit var scope: CoroutineScope protected lateinit var parent: Disposable + /** Balloons a controller raised, instead of real IDE notifications. */ + protected val notifications = mutableListOf>() + override fun setUp() { super.setUp() rpc = FakeSessionRpcApi() appRpc = FakeAppRpcApi() projectRpc = FakeWorkspaceRpcApi() timers = TestUiTimers() + notifications.clear() + // Application-level and shared across tests in a fixture, and it now seeds a new session's + // mode, so a leftover pick from another test would decide this one's starting agent. + KiloPluginSettings.unsetAgent() coroutines = TestCoroutines() scope = coroutines.scope @@ -127,6 +135,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { try { Disposer.dispose(parent) coroutines.close() + KiloPluginSettings.unsetAgent() } finally { super.tearDown() } @@ -184,6 +193,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) }, + notify = { title, body -> notifications.add(title to body) }, timers = timers, log = log ?: KiloLog.create(SessionController::class.java), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index fdd0b606260..f6f7c30532b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -251,10 +251,13 @@ class TurnLifecycleTest : SessionControllerTestBase() { val (m, _, _) = prompted() emit(ChatEventDto.TurnOpen("ses_test")) + edt { m.abort() } + flush() 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" }) + assertTrue(notifications.isEmpty()) assertSession( """ [code] [kilo/gpt-5] [interrupted] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 2c23423c469..b9f7314525c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -4,7 +4,6 @@ import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -117,7 +116,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val messageDeletes = mutableListOf() var messageDeleteResult = true val unreverts = mutableListOf>() - val configs = mutableListOf>() val permissionReplies = mutableListOf>() val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() @@ -333,11 +331,6 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return eventFlow?.invoke(id, directory) ?: events } - override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) { - assertNotEdt("updateConfig") - configs.add(directory to config) - } - var replyPermissionThrows: Exception? = null override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 6c569ef89a6..1175f8727dd 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -37,6 +37,7 @@ object ChatLogSummary { is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID + is ChatEventDto.SessionInterrupted -> event.sessionID } fun dir(dir: String): String = "dirHash=${hash(dir)}" @@ -246,6 +247,12 @@ object ChatLogSummary { "evt=todo.updated", todos(event.todos), ) + + is ChatEventDto.SessionInterrupted -> join( + sid(event.sessionID), + "evt=session.interrupted", + "reason=${event.reason}", + ) } fun eventBody(event: ChatEventDto): String = event(event).substringAfter("evt=").let { body -> diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c96e4a26c34..125b4a424bf 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -2,7 +2,6 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto -import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -140,9 +139,6 @@ interface KiloSessionRpcApi : RemoteApi { /** Subscribe to streaming chat events for a specific session. */ suspend fun events(id: String, directory: String): Flow - /** Update config (model, agent/mode, temperature). */ - suspend fun updateConfig(directory: String, config: ConfigUpdateDto) - // ------ permission / question resolution ------ /** Reply to a pending permission request (once, always, or reject). */ diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index a48f05bc5ad..4c288ed23aa 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -316,6 +316,25 @@ sealed class ChatEventDto { val sessionID: String, val todos: List = emptyList(), ) : ChatEventDto() + + /** + * The CLI cancelled this session's turn on its own, for [reason]. + * + * Synthesized by the backend, never parsed from a CLI event. The CLI reports a server-side + * cancellation as `MessageAbortedError` — byte-identical to what a user Stop produces — so + * without this signal the UI silently renders "Stopped" for work nobody asked to stop. + */ + @Serializable + @SerialName("session.interrupted") + data class SessionInterrupted( + val sessionID: String, + val reason: String, + ) : ChatEventDto() { + companion object { + /** The CLI disposed its instances — a config, provider, or organization change — and cancelled every running turn. */ + const val RELOAD = "reload" + } + } } // --- Permission DTOs --- @@ -443,12 +462,3 @@ data class DiffFileDto( val before: String? = null, val after: String? = null, ) - -// --- Config Update --- - -@Serializable -data class ConfigUpdateDto( - val model: String? = null, - val agent: String? = null, - val temperature: Double? = null, -) From 9601737524b255f5bd58ccae1e45130492cf571e Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 30 Aug 2026 19:33:14 -0400 Subject: [PATCH 2/2] fix(jetbrains): keep interruption badges across the disposal reload The activity badge for a cancelled turn never appeared. reportDisposal runs immediately before load(), load() calls activity.start(), and start() fully stopped first -- clearing errors, statuses, and the directory resolver. The chat event flow also replays nothing, so the restarted collector could not re-see the SessionInterrupted it had just missed. The badge branch was dead on the only path that emits the event. start() now detaches the collectors in place and keeps what they recorded; a real teardown still clears everything through stop(). The badge is recorded by a direct activity.interrupt() call ordered with the disposal that caused it, rather than racing a flow emission against the reload that swaps collectors, so the event branch is gone. Both new tests fail without their respective halves of this fix. --- .../backend/app/KiloBackendActivityManager.kt | 44 +++++++++++--- .../backend/app/KiloBackendAppService.kt | 4 ++ .../app/KiloBackendActivityManagerTest.kt | 58 +++++++++++++++++++ .../backend/app/KiloBackendAppServiceTest.kt | 30 ++++++++++ 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index f98dc1c0b47..7535438fedd 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -46,7 +46,7 @@ class KiloBackendActivityManager( directory: (String) -> String?, chatEvents: SharedFlow, ) { - if (status?.isActive == true || events?.isActive == true) stop() + if (status?.isActive == true || events?.isActive == true) detach() this.statuses = statuses this.directory = directory status = cs.launch { @@ -64,10 +64,7 @@ class KiloBackendActivityManager( } fun stop() { - status?.cancel() - events?.cancel() - status = null - events = null + detach() statuses = null directory = { null } synchronized(lock) { @@ -79,6 +76,37 @@ class KiloBackendActivityManager( log.info("Activity manager stopped") } + /** + * Cancels the collectors without discarding what they recorded. + * + * [start] runs on every reload, including the one a disposal triggers in the same breath as + * cancelling the running turns. Clearing state there would erase the interruption badges that + * disposal just recorded, so an in-place restart keeps them and lets fresh collectors carry on. + * A real teardown still goes through [stop]. + */ + private fun detach() { + status?.cancel() + events?.cancel() + status = null + events = null + } + + /** + * Badge [ids] as having lost a turn nobody asked to stop. + * + * Called directly instead of being driven from [ChatEventDto.SessionInterrupted]: the disposal that + * cancels those turns reloads the app immediately, the reload restarts the event collector, and the + * chat event flow replays nothing — an emission racing that restart can land in the gap where + * nothing is subscribed and be dropped. A direct call is ordered with the disposal that caused it. + */ + fun interrupt(ids: Collection) { + if (ids.isEmpty()) return + synchronized(lock) { + errors.addAll(ids) + recompute() + } + } + private fun handle(event: ChatEventDto) { when (event) { is ChatEventDto.PermissionAsked -> permissions.getOrPut(event.sessionID) { mutableSetOf() }.add(event.request.id) @@ -89,10 +117,8 @@ class KiloBackendActivityManager( // 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) } - // A cancellation the user did not ask for is a failure, and the abort that follows it is - // indistinguishable from a Stop. Badge it from the interruption instead, so the session - // list, worktree rows, and the Agents tab dot all report the lost turn. - is ChatEventDto.SessionInterrupted -> errors.add(event.sessionID) + // A cancellation nobody asked for is a failure, but the abort reporting it is + // indistinguishable from a Stop, so that badge arrives through [interrupt] instead. 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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 03d94c16f92..f14abf46e97 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -855,6 +855,10 @@ class KiloBackendAppService private constructor( val active = sessions.statuses.value.filterValues { it.type != "idle" } if (active.isEmpty()) return log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}") + // Badged here rather than off the event below, because the reload that follows this restarts the + // activity collector and the event could be dropped in the gap. Both still matter: the badge + // marks the worktree row, the event lets the open session name its own reason. + activity.interrupt(active.keys) chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 19f69b72ef3..c0989a28f78 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -249,6 +249,64 @@ class KiloBackendActivityManagerTest { assertEquals("/repo/new", snap["ses_new"]?.directory) } + @Test + fun `interrupt badges the session as failed`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + /** + * The disposal that cancels a turn reloads the app in the same breath, and that reload calls + * [KiloBackendActivityManager.start] again. Clearing on that in-place restart erased the badge the + * disposal had just recorded, leaving a lost turn resting as if it had finished cleanly. + */ + @Test + fun `interrupt badge survives the reload that follows a disposal`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + + manager.interrupt(listOf("ses_1")) + + // What load() does after a disposal: same flows, fresh collectors. + val reloaded = MutableStateFlow(mapOf("ses_1" to SessionStatusDto("idle"))) + manager.start(reloaded, { directories[it] }, events) + + await("ses_1", SessionActivityKindDto.ERROR) + } + + @Test + fun `resumed work clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.TurnOpen("ses_1")) + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + /** A real teardown is a disconnect, not a restart, so nothing may outlive it. */ + @Test + fun `stop clears an interrupt badge`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + manager.interrupt(listOf("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + manager.stop() + + assertEquals(emptyMap(), manager.activity.value) + } + private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) { manager.activity.first { it[id]?.kind == kind } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 7e6d052561f..07d918864e1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.AgentConfigPatchDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -865,6 +866,35 @@ class KiloBackendAppServiceTest { assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason) } + /** + * The reload a disposal triggers restarts the activity collector, so the badge has to be recorded in + * a way that survives it — otherwise a worktree row rests as if its cancelled turn had finished. + */ + @Test + fun `disposal badges the cancelled session after the reload settles`() = runBlocking { + // A badge needs a resolvable directory, which a status event alone does not carry. + mock.sessions = """[{"id":"ses_abc","slug":"abc","projectID":"prj_test","directory":"/test/project","title":"Work","version":"1.0.0","time":{"created":1000,"updated":1000}}]""" + val svc = create() + svc.connect() + ready(svc) + mock.awaitSseConnection() + svc.sessions.list("/test/project") + + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""") + withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } } + + mock.pushEvent("global.disposed", """{"type":"global.disposed"}""") + // The cancelled turn then reports idle, which is what lets the badge show: live work + // deliberately outranks a past error so a resumed row keeps spinning instead. + mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"idle"}}""") + + val badged = withTimeoutOrNull(20_000) { + svc.activity.activity.first { it["ses_abc"]?.kind == SessionActivityKindDto.ERROR } + } + assertNotNull(badged, "Disposal must leave a badge on the session it cancelled; logs=${log.messages}") + assertEquals("/test/project", badged["ses_abc"]?.directory) + } + @Test fun `disposal with no busy session names nothing`() = runBlocking { val svc = create()