feat(#1289): lightweight /fork via session.branch_whole (#1303)

This commit is contained in:
m57 authored and GitHub committed 2026-09-27 16:56:43 +03:00
1 parent 3aedb44bed
commit ef54804b72
5 files changed
+186 -16

No files matched your search

@@ -15,6 +15,7 @@ object WsMethods {
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
const val SESSION_BRANCH_WHOLE = "session.branch_whole"
/** Replay recorded events newer than client's last-seen seq for a session. */
const val SESSION_EVENTS_SINCE = "session.events.since"
@@ -483,7 +483,13 @@ class ChatViewModel(
val originalPending: PendingSend? = null,
)
private data class PendingBranchRequest(
val generation: Long,
val params: Map<String, Any>,
)
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
private val outgoingRequestById = ConcurrentHashMap<String, String>()
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
@@ -1663,6 +1669,7 @@ class ChatViewModel(
) {
val method = idToMethod.remove(id) ?: return
val request = sessionRequestById.remove(id)
branchWholeRequests.remove(id)
if (request != null && isStaleSessionRequest(request)) {
outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
@@ -1749,7 +1756,9 @@ class ChatViewModel(
}
}
WsMethods.SESSION_BRANCH -> {
WsMethods.SESSION_BRANCH,
WsMethods.SESSION_BRANCH_WHOLE,
-> {
val resultMap = result as? Map<String, Any?> ?: return
// The result carries BOTH ids: `session_id` is the runtime
// registry id, `stored_session_id` is the DB key. currentSessionId
@@ -2086,6 +2095,7 @@ class ChatViewModel(
) {
val method = idToMethod.remove(id) ?: return
val request = sessionRequestById.remove(id)
val pendingBranch = branchWholeRequests.remove(id)
if (request != null && isStaleSessionRequest(request)) {
outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) }
hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) }
@@ -2165,6 +2175,26 @@ class ChatViewModel(
}
}
if (method == WsMethods.SESSION_BRANCH_WHOLE) {
val code =
(error as? JsonRpcError)?.code
?: (error as? Map<*, *>)?.get("code") as? Int
if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) {
val params = pendingBranch.params
val generation = pendingBranch.generation
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.SESSION_BRANCH,
params,
onSent = { branchId ->
trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation)
},
)
}
return
}
}
// Surface error in UI (these are server-pushed RpcError for
// fire-and-forget RPCs — awaited RPCs handle their own failure
// via the HermesWsClient.request() deferred).
@@ -3940,9 +3970,8 @@ class ChatViewModel(
}
/**
* 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.
* Fork the active conversation via the lightweight session.branch_whole WS RPC (issue #1289).
* Falls back to session.branch if the gateway answers -32601 (unknown method).
* The optional arg becomes the new branch's title.
*/
private fun branchSession(command: String) {
@@ -3957,9 +3986,12 @@ class ChatViewModel(
val generation = sessionGeneration
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.SESSION_BRANCH,
WsMethods.SESSION_BRANCH_WHOLE,
params,
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_BRANCH, generation) },
onSent = { id ->
branchWholeRequests[id] = PendingBranchRequest(generation, params)
trackSessionRequest(id, WsMethods.SESSION_BRANCH_WHOLE, generation)
},
)
}
}
@@ -6006,6 +6038,7 @@ class ChatViewModel(
private fun forgetRequest(id: String) {
idToMethod.remove(id)
sessionRequestById.remove(id)
branchWholeRequests.remove(id)
}
// ── Search ────────────────────────────────────────────────────────────
@@ -10,7 +10,8 @@ package com.m57.hermescontrol.ui.chat
* `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps
* to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill
* commands and 4018s on everything else). They are real backend commands that
* get their own results: `/fork` goes via the `session.branch` RPC and `/model`
* get their own results: `/fork` goes via the `session.branch_whole` RPC (with
* fallback to `session.branch` on older gateways) and `/model`
* via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the
* TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as
* a normal prompt makes the LLM treat it as text). `/update` is intercepted