* feat(#533): add session.branch (fork a conversation) to mobile The gateway already supports session.branch (tui_gateway/server.py), but the mobile had no client surface. This adds: - WsMethods.SESSION_BRANCH = "session.branch" - A Branch (CallSplit) action on each session card that forks it via the session.branch WS RPC with the source session_id (and optional name). - branchedSessionId state -> SessionsScreen navigates to the freshly forked session on success; RpcError -> toast; spinner while branching. - SessionsViewModelTest (4 tests): forwards session_id, forwards name, sets branchedSessionId on success, toast+no-branch on RpcError, and consumeBranchedSession clears the pending id. Verified against the live gateway contract: session.branch expects {session_id, name?} and returns {session_id, title, parent}. Local testDebugUnitTest: 4/4 pass, ktlint clean. * refactor(#533): make /fork work via chat instead of a session-menu button Per review feedback: the session.branch surface belongs in the chat composer, not the session list menu. - SlashCommandDispatcher: /fork and /branch -> new SlashResult.SessionBranch. - ChatViewModel: handles SessionBranch by sending session.branch with the active runtime session id (optional arg = branch title), and on the RPC result switches the current session to the new forked one (mirrors the SESSION_CREATE handler). This fixes /fork, which previously fell through to command.dispatch and 4018'd (backend only knows session.branch). - Removed the Branch (CallSplit) button + navigation LaunchedEffect from SessionsScreen/SessionsViewModel (reverted those two files to main) and dropped the now-unused sessions_action_branch string. - Tests: deleted SessionsViewModelTest (branch-only); added /fork -> SessionBranch to SlashCommandDispatcherTest and two ChatViewModel tests asserting /fork sends session.branch with the session id + optional name. Local testDebugUnitTest: SlashCommandDispatcherTest 8/8, ChatViewModelTest 50/50, 0 failures. ktlint clean. --------- Co-authored-by: ronia <ronia@m57>
This commit is contained in:
1 parent
07764955d3
commit
ad87cd1054
5 files changed
+106
No files matched your search
@@ -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"
|
||||
|
||||
@@ -466,6 +466,25 @@ class ChatViewModel(
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
WsMethods.SESSION_BRANCH -> {
|
||||
val resultMap = result as? Map<String, Any?> ?: 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<String, Any?> ?: return
|
||||
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: 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<String, Any>("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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(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<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user