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 {