* 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_INTERRUPT = "session.interrupt"
|
||||||
const val SESSION_DELETE = "session.delete"
|
const val SESSION_DELETE = "session.delete"
|
||||||
const val SESSION_TITLE = "session.title"
|
const val SESSION_TITLE = "session.title"
|
||||||
|
const val SESSION_BRANCH = "session.branch"
|
||||||
|
|
||||||
// ── Interaction ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
|
|||||||
@@ -466,6 +466,25 @@ class ChatViewModel(
|
|||||||
loadSessions()
|
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 -> {
|
WsMethods.SESSION_LIST -> {
|
||||||
val resultMap = result as? Map<String, Any?> ?: return
|
val resultMap = result as? Map<String, Any?> ?: return
|
||||||
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
|
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
|
||||||
@@ -790,12 +809,40 @@ class ChatViewModel(
|
|||||||
createNewSession()
|
createNewSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SlashResult.SessionBranch -> {
|
||||||
|
branchSession(command)
|
||||||
|
}
|
||||||
|
|
||||||
is SlashResult.RpcDispatch -> {
|
is SlashResult.RpcDispatch -> {
|
||||||
dispatchViaRpc(command)
|
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) {
|
private fun dispatchViaRpc(command: String) {
|
||||||
val sessionId = runtimeSessionId
|
val sessionId = runtimeSessionId
|
||||||
if (sessionId == null) {
|
if (sessionId == null) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class SlashCommandDispatcher {
|
|||||||
return when (cmd) {
|
return when (cmd) {
|
||||||
"/stop", "/interrupt" -> SlashResult.Interrupt
|
"/stop", "/interrupt" -> SlashResult.Interrupt
|
||||||
"/new" -> SlashResult.NewSession
|
"/new" -> SlashResult.NewSession
|
||||||
|
"/fork", "/branch" -> SlashResult.SessionBranch
|
||||||
else -> SlashResult.RpcDispatch
|
else -> SlashResult.RpcDispatch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,4 +35,7 @@ sealed class SlashResult {
|
|||||||
|
|
||||||
/** Forward to command.dispatch via WebSocket. */
|
/** Forward to command.dispatch via WebSocket. */
|
||||||
data object RpcDispatch : SlashResult()
|
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()) }
|
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
|
@Test
|
||||||
fun testSlashCommand_stop_sendsInterrupt() =
|
fun testSlashCommand_stop_sendsInterrupt() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ class SlashCommandDispatcherTest {
|
|||||||
assertEquals(SlashResult.NewSession, dispatcher.dispatch("/new"))
|
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
|
@Test
|
||||||
fun `NEW uppercase still routes to NewSession`() {
|
fun `NEW uppercase still routes to NewSession`() {
|
||||||
// Dispatcher lower-cases before matching, so case must not matter.
|
// Dispatcher lower-cases before matching, so case must not matter.
|
||||||
|
|||||||
Reference in new issue
Block a user