feat(#530): server-backed session search (FTS5) (#555)

* feat(#530): add SessionSearch models + searchSessions endpoint + contract tests

* feat(#530): wire server-backed search into Sessions screen (debounce + render)

* style(#530): ktlint import ordering on HermesApiService

* fix(#530): ktlint-clean overlap wrap + map search snippet to title

* fix(#530): map search snippet to title so results show + highlight

* fix(#530): render search hits as honest result cards instead of faking title

Backend SessionSearchResult carries no session name (only snippet + metadata),
so mapping snippet->title made the card show raw excerpt as the title. Instead,
render a dedicated SearchResultCard: 'Match' label + session id, highlighted
snippet as body, and source/model/played-at chips. Added SessionInfo.model and
a friendly relative-time formatter. ktlint clean, compileDebugKotlin + unit
tests pass.

* fix(#530): strip JSON from search snippet + tighten result card padding

* fix(#530): strip FTS highlight markers from search snippet on client

agy's backend change re-wraps matched terms in >>>term<<< markers inside the
clean snippet, but the mobile highlights by raw query term and never consumed
those markers — so literal '>>>'/'<<<' would render in result text. Strip the
markers before passing the snippet to highlightText so the query highlight
still works on clean text.

* fix(#530): integrate tree-flattening hierarchy with search modes in SessionsScreen

* fix(#530): fix test assertion and mock ApiClient in SessionsViewModelTest

* fix(#530): resolve compose PaddingValues compile error and fix slash-preservation test path

* fix(#530): clean search snippet from JSON payload and add unit test

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-14 12:47:59 +03:00
1 parent 71c1bdded9
commit c543eb6470
8 files changed
+914 -252

No files matched your search

@@ -307,6 +307,87 @@ class HermesApiServiceMockWebServerTest {
assertNull(body.messages[0].timestamp)
}
@Test
fun searchSessions_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"results": [
{
"session_id": "sess-abc",
"snippet": "…matched text…",
"role": "user",
"source": "telegram",
"model": "gpt-4o",
"session_started": 1718000000
}
]
}
""".trimIndent(),
),
)
val response = api.searchSessions(q = "matched", profile = null)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(1, body!!.results.size)
val first = body.results[0]
assertEquals("sess-abc", first.session_id)
assertEquals("…matched text…", first.snippet)
assertEquals("telegram", first.source)
assertEquals("gpt-4o", first.model)
assertEquals(1718000000.0, first.session_started!!, 0.0)
}
@Test
fun searchSessions_encodesQueryAndProfileParams() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "results": [] }"""),
)
api.searchSessions(q = "hello world", profile = "work")
val request = mockServer.takeRequest()
assertTrue(
"q must be URL-encoded",
request.path!!.contains("q=hello%20world"),
)
assertTrue(
"profile param must be sent",
request.path!!.contains("profile=work"),
)
}
@Test
fun getSessionMessages_preservesSessionIdSlashes() =
runBlocking {
val sessionId = "session/with/slashes"
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "messages": [] }"""),
)
api.getSessionMessages(sessionId)
val request = mockServer.takeRequest()
assertEquals(
"path should contain the session ID with slashes preserved",
"/api/sessions/session/with/slashes/messages?offset=0",
request.path,
)
}
@Test
fun getOAuthProviders_parsesList() =
runBlocking {
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SessionsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private fun createViewModel(): SessionsViewModel {
val vm = SessionsViewModel()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `blank query resets search mode`() {
val vm = createViewModel()
vm.setSearchQuery("something")
vm.setSearchQuery("")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("", vm.uiState.value.searchQuery)
assertFalse(vm.uiState.value.isSearchMode)
assertEquals(0, vm.uiState.value.searchResults.size)
assertFalse(vm.uiState.value.isSearching)
}
@Test
fun `non-blank query enters search mode and resolves`() {
val vm = createViewModel()
vm.setSearchQuery("hello")
// state is set synchronously
assertEquals("hello", vm.uiState.value.searchQuery)
assertTrue(vm.uiState.value.isSearchMode)
// advance past debounce + (failing, offline) network call
testDispatcher.scheduler.advanceTimeBy(500)
testDispatcher.scheduler.advanceUntilIdle()
// Either way the spinner must stop and the query persists.
assertFalse(vm.uiState.value.isSearching)
assertEquals("hello", vm.uiState.value.searchQuery)
}
@Test
fun `select all uses the IDs shown in the current view`() {
val vm = createViewModel()
vm.selectAll(setOf("search-session-1", "search-session-2"))
assertEquals(
setOf("search-session-1", "search-session-2"),
vm.uiState.value.selectedIds,
)
}
@Test
fun `clean search snippet extracts text from JSON payload`() {
assertEquals(
"Find the deployment logs",
cleanSearchSnippet("{\"role\":\"user\",\"content\":\">>>Find<<< the deployment logs\"}"),
)
}
}