From 0e39a71591daf474ab1df28809b021069d6fbabc Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:33:07 +0300 Subject: [PATCH] feat(chat): /stop also kills background processes like desktop (#1409) --- .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../data/ws/contract/ProcessStop.kt | 15 +++++ .../data/ws/contract/RpcMethod.kt | 8 +++ .../hermescontrol/ui/chat/ChatViewModel.kt | 27 ++++++++ .../ui/chat/SlashCommandDispatcher.kt | 9 ++- .../data/ws/contract/ProcessTypesTest.kt | 10 +++ .../ui/chat/ChatViewModelTest.kt | 61 ++++++++++++++++++- .../ui/chat/SlashCommandDispatcherTest.kt | 4 +- 8 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index f2e07a5a..0957f86a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -94,6 +94,9 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + /** Kill every background process in the gateway registry (`/stop`, global). */ + const val PROCESS_STOP = "process.stop" + // ── Subagents (issue #1089) ─────────────────────────────────────────── /** List active subagents and delegations for a session. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt new file mode 100644 index 00000000..6e0a2f8f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** + * `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop + * sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent. + */ +@Serializable +data object ProcessStopParams + +@Serializable +data class ProcessStopResult( + val killed: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt index f611d435..be4bb3ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -363,6 +363,13 @@ object RpcMethods { JsonElement.serializer(), ) + val PROCESS_STOP: RpcMethod = + RpcMethod( + WsMethods.PROCESS_STOP, + ProcessStopParams.serializer(), + ProcessStopResult.serializer(), + ) + // Passthrough JsonElement results: callers keep their existing legacy decoders. val PLUGINS_MANAGE: RpcMethod = RpcMethod( @@ -514,6 +521,7 @@ object RpcMethods { SUBAGENT_TAIL, PROCESS_LIST, PROCESS_KILL, + PROCESS_STOP, PLUGINS_MANAGE, PROJECTS_LIST, PROFILES_LIST, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a0c31cbe..c2d46f96 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -43,6 +43,7 @@ import com.m57.hermescontrol.data.ws.contract.ConfigGetParams import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import com.m57.hermescontrol.data.ws.contract.FileAttachParams import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams import com.m57.hermescontrol.data.ws.contract.PromptBtwParams import com.m57.hermescontrol.data.ws.contract.RpcMethod import com.m57.hermescontrol.data.ws.contract.RpcMethods @@ -3716,6 +3717,10 @@ class ChatViewModel( interruptSession() } + is SlashResult.Stop -> { + stopSessionAndProcesses() + } + is SlashResult.NewSession -> { val currentTitle = _uiState.value.chatTitle if (currentTitle.equals("Bot Chat", ignoreCase = true)) { @@ -4344,6 +4349,28 @@ class ChatViewModel( } } + /** + * `/stop`, matching the desktop app: interrupt the active turn (same path as the composer Stop button), then + * kill every background process via `process.stop`. The button stays interrupt-only. + */ + private fun stopSessionAndProcesses() { + interruptSession() + viewModelScope.launch(ioDispatcher) { + val message = + try { + val killed = wsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams).killed ?: 0 + when { + killed > 0 -> "Stopped $killed background process${if (killed == 1) "" else "es"}." + else -> "No background processes to stop." + } + } catch (e: Exception) { + if (e is CancellationException) throw e + "Could not stop background processes: ${e.message ?: e.javaClass.simpleName}" + } + addSystemMessage(message) + } + } + /** * Send course-correction guidance to a live subagent child session (issue #1030). */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index c1aba996..65221a8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -27,7 +27,11 @@ class SlashCommandDispatcher { val cmd = parts[0].lowercase() return when (cmd) { - "/stop", "/interrupt" -> { + "/stop" -> { + SlashResult.Stop + } + + "/interrupt" -> { SlashResult.Interrupt } @@ -96,6 +100,9 @@ sealed class SlashResult { /** Interrupt the active session (client-side immediate). */ data object Interrupt : SlashResult() + /** Desktop `/stop`: interrupt the active turn, then kill background processes (`process.stop`). */ + data object Stop : SlashResult() + /** Create a new session (client-side immediate). */ data object NewSession : SlashResult() diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt index 02544cad..443aae32 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt @@ -24,8 +24,18 @@ class ProcessTypesTest { ) } + @Test + fun stopEncodesAnEmptyObjectAndDecodesKilledCount() { + val encoded = OkHttpProvider.json.encodeToJsonElement(ProcessStopParams.serializer(), ProcessStopParams) + assertEquals(buildJsonObject {}, encoded) + val result = OkHttpProvider.json.decodeFromString(ProcessStopResult.serializer(), """{"killed":3}""") + assertEquals(ProcessStopResult(killed = 3), result) + } + @Test fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROCESS_STOP in RpcMethods.all) + assertEquals("process.stop", RpcMethods.PROCESS_STOP.name) assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all) assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all) assertEquals("process.list", RpcMethods.PROCESS_LIST.name) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 2dbe0d6b..7a35896f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -36,6 +36,8 @@ import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopResult import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository @@ -283,6 +285,9 @@ class ChatViewModelTest { HermesWsClient.send(arg(0), arg(1)) {} CompletableDeferred(Unit) } + // /stop also fires process.stop; default to "nothing to kill" so unrelated /stop tests never hit the socket. + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 0) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -966,6 +971,52 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_stop_alsoKillsBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 2) + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 1) { HermesWsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams, any(), any()) } + val contents = + viewModel.uiState.value.messages + .map { it.content } + assertTrue(contents.contains("Stopped 2 background processes.")) + } + + @Test + fun testSlashCommand_interrupt_doesNotKillBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + viewModel.sendMessage("/interrupt") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } + } + + @Test + fun testSlashCommand_stop_reportsProcessStopFailure() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } throws + IllegalStateException("boom") + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Could not stop background processes: boom" }, + ) + } + @Test fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() = runTest { @@ -1041,7 +1092,15 @@ class ChatViewModelTest { val contents = merged.map { it.content } assertEquals( - listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"), + listOf( + "start task", + "running...", + "/stop", + "No background processes to stop.", + "Session interrupted", + "next prompt", + "next reply", + ), contents, ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 272ad6ea..da0f7f8c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -20,8 +20,8 @@ class SlashCommandDispatcherTest { private val dispatcher = SlashCommandDispatcher() @Test - fun `stop routes to Interrupt`() { - assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop")) + fun `stop routes to Stop`() { + assertEquals(SlashResult.Stop, dispatcher.dispatch("/stop")) } @Test