feat(chat): /stop also kills background processes like desktop (#1409)
This commit is contained in:
8 files changed
+133
-4
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -363,6 +363,13 @@ object RpcMethods {
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val PROCESS_STOP: RpcMethod<ProcessStopParams, ProcessStopResult> =
|
||||
RpcMethod(
|
||||
WsMethods.PROCESS_STOP,
|
||||
ProcessStopParams.serializer(),
|
||||
ProcessStopResult.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PLUGINS_MANAGE: RpcMethod<PluginsManageParams, JsonElement> =
|
||||
RpcMethod(
|
||||
@@ -514,6 +521,7 @@ object RpcMethods {
|
||||
SUBAGENT_TAIL,
|
||||
PROCESS_LIST,
|
||||
PROCESS_KILL,
|
||||
PROCESS_STOP,
|
||||
PLUGINS_MANAGE,
|
||||
PROJECTS_LIST,
|
||||
PROFILES_LIST,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Any?>(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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user