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 2a094592..2e3548e8 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 @@ -12,6 +12,7 @@ object WsMethods { const val SESSION_INTERRUPT = "session.interrupt" const val SESSION_DELETE = "session.delete" const val SESSION_TITLE = "session.title" + const val SESSION_BRANCH = "session.branch" // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" 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 ecfd22ff..5c873600 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 @@ -466,6 +466,25 @@ class ChatViewModel( loadSessions() } + WsMethods.SESSION_BRANCH -> { + val resultMap = result as? Map ?: return + val newId = resultMap["session_id"] as? String ?: return + runtimeSessionId = newId + _uiState.update { + it.copy( + currentSessionId = newId, + isLoading = false, + messages = emptyList(), + chatTitle = (resultMap["title"] as? String)?.takeIf { t -> t.isNotBlank() } ?: "Hermes", + ) + } + ActiveSessionHolder.set(newId) + _streamingState.update { StreamingState() } + addSystemMessage("Session branched", persist = true) + loadSessionMessages(newId) + loadSessions() + } + WsMethods.SESSION_LIST -> { val resultMap = result as? Map ?: return val sessionsList = resultMap["sessions"] as? List> ?: return @@ -790,12 +809,40 @@ class ChatViewModel( createNewSession() } + is SlashResult.SessionBranch -> { + branchSession(command) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } } } + /** + * Fork the active conversation via the session.branch WS RPC (issue #533). + * The backend already supports session.branch; the mobile previously had + * no client surface, so `/fork` fell through to command.dispatch and 4018'd. + * The optional arg becomes the new branch's title. + */ + private fun branchSession(command: String) { + val sessionId = runtimeSessionId + if (sessionId == null) { + addAssistantMessage("No active session. Use `/new` to create one.") + return + } + val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() + val params = mutableMapOf("session_id" to sessionId) + if (arg.isNotBlank()) params["name"] = arg + viewModelScope.launch(Dispatchers.IO) { + wsClient.send( + WsMethods.SESSION_BRANCH, + params, + onSent = { id -> trackRequest(id, WsMethods.SESSION_BRANCH) }, + ) + } + } + private fun dispatchViaRpc(command: String) { val sessionId = runtimeSessionId if (sessionId == null) { 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 c5f5e883..0a0cf508 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 @@ -17,6 +17,7 @@ class SlashCommandDispatcher { return when (cmd) { "/stop", "/interrupt" -> SlashResult.Interrupt "/new" -> SlashResult.NewSession + "/fork", "/branch" -> SlashResult.SessionBranch else -> SlashResult.RpcDispatch } } @@ -34,4 +35,7 @@ sealed class SlashResult { /** Forward to command.dispatch via WebSocket. */ data object RpcDispatch : SlashResult() + + /** Fork the active conversation via the session.branch WebSocket RPC. */ + data object SessionBranch : SlashResult() } 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 8139460e..0f848333 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 @@ -190,6 +190,51 @@ class ChatViewModelTest { verify(atLeast = 1) { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } } + @Test + fun testSlashCommand_fork_sendsSessionBranch() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // /fork with an optional branch title. + viewModel.sendMessage("/fork my-fork") + advanceUntilIdle() + + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } + assertNotNull("session.branch should be dispatched for /fork", branchSent) + assertEquals(sessionId, branchSent!!.second["session_id"]) + assertEquals("my-fork", branchSent.second["name"]) + } + + @Test + fun testSlashCommand_fork_withoutName_omitsNameParam() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + val captured = mutableListOf>>() + every { HermesWsClient.send(any(), any(), any()) } answers { + val id = "req-${captured.size + 1}" + captured.add(arg(0) to (arg>(1))) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + viewModel.sendMessage("/fork") + advanceUntilIdle() + + val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } + assertNotNull("session.branch should be dispatched for /fork", branchSent) + assertEquals(sessionId, branchSent!!.second["session_id"]) + assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) + } + @Test fun testSlashCommand_stop_sendsInterrupt() = runTest { 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 f4e9442b..f9781761 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 @@ -34,6 +34,15 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.NewSession, dispatcher.dispatch("/new")) } + @Test + fun `fork routes to SessionBranch`() { + // /fork (and /branch) fork the active conversation via session.branch — + // the backend's command, not command.dispatch. See issue #533. + assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/fork")) + assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/branch")) + assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/FORK hello")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter.