Backend now supports order=latest on GET /api/sessions/{id}/messages
(offset measured back from the newest message, page returned
chronologically). The mobile initial load previously computed its anchor
from a possibly-stale session-list message_count, which landed the first
page at the wrong position (dropped history or a duplicated tail).
- HermesApiService.getSessionMessages: add @Query("order")
- Initial load requests the newest page directly (order=latest, offset=0)
and drops the fetchServerMessageCount offset math; legacy backends
(no pagination echo) fall back to the count-based anchor
- Backward paging advances the from-end offset by a full page; a short
page marks the oldest boundary and stops pagination
- ChatMessage stable keys under latest paging use the server's
AUTOINCREMENT row id (from-end positions shift as the transcript
grows and would collide across hydrations)
- syncCurrentSession refetches the newest page and merges by row id
with a logical-content fallback for re-keyed rows
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ea8fead087
commit
4a034684bb
4 files changed
+385
-25
No files matched your search
@@ -9,10 +9,29 @@ data class SessionMessagesResponse(
|
|||||||
val messages: List<SessionMessage>,
|
val messages: List<SessionMessage>,
|
||||||
val offset: Int? = null,
|
val offset: Int? = null,
|
||||||
val total: Int? = null,
|
val total: Int? = null,
|
||||||
|
val pagination: PaginationInfo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echo of the backend's pagination state for GET /api/sessions/{id}/messages
|
||||||
|
* (hermes_cli/web_routers/sessions.py). Absent on legacy backends that
|
||||||
|
* predate the `order` param — its presence also proves `order=latest` was
|
||||||
|
* honored (issue #859).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PaginationInfo(
|
||||||
|
val limit: Int? = null,
|
||||||
|
val offset: Int? = null,
|
||||||
|
val order: String? = null,
|
||||||
|
val returned: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionMessage(
|
data class SessionMessage(
|
||||||
|
// The gateway's AUTOINCREMENT row id (hermes_state_common.py
|
||||||
|
// messages.id) — stable, unique and never reused. Used as the chat-list
|
||||||
|
// stable key under newest-anchored paging (issue #859).
|
||||||
|
val id: Int? = null,
|
||||||
val role: String? = null,
|
val role: String? = null,
|
||||||
val content: JsonElement? = null,
|
val content: JsonElement? = null,
|
||||||
val timestamp: JsonElement? = null,
|
val timestamp: JsonElement? = null,
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ interface HermesApiService {
|
|||||||
@Path("id", encoded = true) sessionId: String,
|
@Path("id", encoded = true) sessionId: String,
|
||||||
@Query("limit") limit: Int? = null,
|
@Query("limit") limit: Int? = null,
|
||||||
@Query("offset") offset: Int = 0,
|
@Query("offset") offset: Int = 0,
|
||||||
|
// Issue #859: order=latest pages back from the newest message (offset
|
||||||
|
// measured from the end, page returned chronologically). Omitted on
|
||||||
|
// legacy backends that don't know the param — they ignore it.
|
||||||
|
@Query("order") order: String? = null,
|
||||||
@Query("include_compacted") includeCompacted: Boolean? = true,
|
@Query("include_compacted") includeCompacted: Boolean? = true,
|
||||||
): Response<SessionMessagesResponse>
|
): Response<SessionMessagesResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,15 @@ class ChatViewModel(
|
|||||||
/** Show the "session gone" notice once the recovery create lands (create wipes messages). */
|
/** Show the "session gone" notice once the recovery create lands (create wipes messages). */
|
||||||
private var pendingGoneSessionNotice = false
|
private var pendingGoneSessionNotice = false
|
||||||
private var loadedMessageOffset = 0
|
private var loadedMessageOffset = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once the backend honored `order=latest` on the initial page (the
|
||||||
|
* pagination echo came back). Offsets then count BACK from the newest
|
||||||
|
* message and older pages INCREASE the offset; a full page means more
|
||||||
|
* older messages exist. False on legacy backends (no `order` param) —
|
||||||
|
* offsets stay absolute and decrease toward 0 (issue #859).
|
||||||
|
*/
|
||||||
|
private var latestPaging = false
|
||||||
private var isSyncingMessages = false
|
private var isSyncingMessages = false
|
||||||
|
|
||||||
// ── Session resume recovery (desktop parity) ────────────────────────
|
// ── Session resume recovery (desktop parity) ────────────────────────
|
||||||
@@ -1868,16 +1877,36 @@ class ChatViewModel(
|
|||||||
val requestSequence = ++hydrationRequestSequence
|
val requestSequence = ++hydrationRequestSequence
|
||||||
activeHydrationRequestSequence = requestSequence
|
activeHydrationRequestSequence = requestSequence
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// Initial page: ask for the NEWEST page directly (order=latest —
|
||||||
|
// offset is measured back from the newest message, page returned
|
||||||
|
// chronologically; verified in hermes_state.py get_messages).
|
||||||
|
// No count-based anchor, so a stale session-list message_count can
|
||||||
|
// no longer land the page at the wrong position (issue #859).
|
||||||
|
// Legacy backends without the `order` param ignore it and echo no
|
||||||
|
// `pagination` — detect that and fall back to the count-based
|
||||||
|
// anchor so nothing regresses.
|
||||||
|
val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest")
|
||||||
|
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||||
|
val (result, requestedOffset) =
|
||||||
|
if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") {
|
||||||
|
latestPaging = true
|
||||||
|
latestResult to 0
|
||||||
|
} else if (latestResult is NetworkResult.Success) {
|
||||||
|
latestPaging = false
|
||||||
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence)
|
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||||
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||||
val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE)
|
fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset
|
||||||
|
} else {
|
||||||
|
latestPaging = false
|
||||||
|
latestResult to 0
|
||||||
|
}
|
||||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
// REST 200 — the gateway has the row for this session.
|
// REST 200 — the gateway has the row for this session.
|
||||||
sessionHasServerPresence = true
|
sessionHasServerPresence = true
|
||||||
val serverOffset = result.data.offset ?: offset
|
val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset
|
||||||
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset)
|
val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), serverOffset)
|
||||||
loadedMessageOffset = serverOffset
|
loadedMessageOffset = serverOffset
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
@@ -1890,10 +1919,20 @@ class ChatViewModel(
|
|||||||
// drop live WS bubbles (running tool call, streaming
|
// drop live WS bubbles (running tool call, streaming
|
||||||
// answer) the server hasn't persisted yet. Issue #771.
|
// answer) the server hasn't persisted yet. Issue #771.
|
||||||
val merged = mergeTranscriptWithLive(chatMessages, state.messages)
|
val merged = mergeTranscriptWithLive(chatMessages, state.messages)
|
||||||
|
val hasOlder =
|
||||||
|
if (latestPaging) {
|
||||||
|
// Newest-anchored: older messages exist iff the
|
||||||
|
// page came back FULL (a short page means we hit
|
||||||
|
// the oldest boundary).
|
||||||
|
val returned = result.data.pagination?.returned ?: chatMessages.size
|
||||||
|
returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
serverOffset > 0 && chatMessages.isNotEmpty()
|
||||||
|
}
|
||||||
state.copy(
|
state.copy(
|
||||||
messages = merged,
|
messages = merged,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
hasOlderMessages = serverOffset > 0 && chatMessages.isNotEmpty(),
|
hasOlderMessages = hasOlder,
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1959,6 +1998,7 @@ class ChatViewModel(
|
|||||||
runtimeSessionId = null
|
runtimeSessionId = null
|
||||||
ActiveSessionHolder.clear()
|
ActiveSessionHolder.clear()
|
||||||
loadedMessageOffset = 0
|
loadedMessageOffset = 0
|
||||||
|
latestPaging = false
|
||||||
isSyncingMessages = false
|
isSyncingMessages = false
|
||||||
streamingController.resetStreaming()
|
streamingController.resetStreaming()
|
||||||
_streamingState.value = StreamingState()
|
_streamingState.value = StreamingState()
|
||||||
@@ -2197,25 +2237,53 @@ class ChatViewModel(
|
|||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val sessionId = state.currentSessionId ?: return
|
val sessionId = state.currentSessionId ?: return
|
||||||
val generation = sessionGeneration
|
val generation = sessionGeneration
|
||||||
if (!state.hasOlderMessages || state.isLoadingOlder || loadedMessageOffset <= 0) return
|
if (!state.hasOlderMessages || state.isLoadingOlder) return
|
||||||
|
if (!latestPaging && loadedMessageOffset <= 0) return
|
||||||
val oldOffset = loadedMessageOffset
|
val oldOffset = loadedMessageOffset
|
||||||
val newOffset = (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
// latest: offsets count BACK from the newest message, so older pages go
|
||||||
val limit = oldOffset - newOffset
|
// UP; legacy: absolute offsets go DOWN toward 0 (issue #859).
|
||||||
|
val newOffset =
|
||||||
|
if (latestPaging) {
|
||||||
|
oldOffset + MESSAGE_PAGE_SIZE
|
||||||
|
} else {
|
||||||
|
(oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
// Legacy: the final page can be short; requesting a full page at a
|
||||||
|
// clamped offset OVERLAPS already-loaded rows (duplicate stable keys
|
||||||
|
// crash LazyColumn), so size the request to the real gap. Latest:
|
||||||
|
// pages are disjoint from-end ranges, so a full page never overlaps.
|
||||||
|
val limit = if (latestPaging) MESSAGE_PAGE_SIZE else oldOffset - newOffset
|
||||||
_uiState.update { it.copy(isLoadingOlder = true) }
|
_uiState.update { it.copy(isLoadingOlder = true) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = fetchMessagePage(sessionId, newOffset, limit)) {
|
val result =
|
||||||
|
fetchMessagePage(
|
||||||
|
sessionId,
|
||||||
|
newOffset,
|
||||||
|
limit,
|
||||||
|
order = if (latestPaging) "latest" else null,
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||||
val returnedOffset = result.data.offset ?: newOffset
|
val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset
|
||||||
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset)
|
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset)
|
||||||
loadedMessageOffset = returnedOffset
|
loadedMessageOffset = returnedOffset
|
||||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||||
|
val hasOlder =
|
||||||
|
if (latestPaging) {
|
||||||
|
// Full page = more older messages behind it; a
|
||||||
|
// short (or empty) page is the oldest boundary.
|
||||||
|
val returned = result.data.pagination?.returned ?: older.size
|
||||||
|
returned >= limit && older.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0
|
||||||
|
}
|
||||||
current.copy(
|
current.copy(
|
||||||
messages = (older + current.messages).distinctBy { it.id },
|
messages = (older + current.messages).distinctBy { it.id },
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
hasOlderMessages = returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0,
|
hasOlderMessages = hasOlder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2248,15 +2316,31 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val nextOffset =
|
val nextOffset =
|
||||||
|
if (latestPaging) {
|
||||||
|
// Newest-anchored paging can't compute an absolute
|
||||||
|
// "after my last row" offset (from-end offsets shift as the
|
||||||
|
// transcript grows), so refetch the newest page; the logical
|
||||||
|
// merge below keeps existing copies and only adds new rows
|
||||||
|
// (issue #859).
|
||||||
|
0
|
||||||
|
} else {
|
||||||
state.messages
|
state.messages
|
||||||
.mapNotNull { serverMessageIndex(it.id, sessionId) }
|
.mapNotNull { serverMessageIndex(it.id, sessionId) }
|
||||||
.maxOrNull()
|
.maxOrNull()
|
||||||
?.plus(1)
|
?.plus(1)
|
||||||
?: loadedMessageOffset
|
?: loadedMessageOffset
|
||||||
|
}
|
||||||
isSyncingMessages = true
|
isSyncingMessages = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
when (val result = fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE)) {
|
val result =
|
||||||
|
fetchMessagePage(
|
||||||
|
sessionId,
|
||||||
|
nextOffset,
|
||||||
|
MESSAGE_PAGE_SIZE,
|
||||||
|
order = if (latestPaging) "latest" else null,
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||||
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
|
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
|
||||||
@@ -2292,6 +2376,22 @@ class ChatViewModel(
|
|||||||
if (matchIdx != null && unmatchedIncoming[matchIdx] != null) {
|
if (matchIdx != null && unmatchedIncoming[matchIdx] != null) {
|
||||||
mergedList.add(unmatchedIncoming[matchIdx]!!)
|
mergedList.add(unmatchedIncoming[matchIdx]!!)
|
||||||
unmatchedIncoming[matchIdx] = null
|
unmatchedIncoming[matchIdx] = null
|
||||||
|
} else if (latestPaging) {
|
||||||
|
// Newest-anchored paging re-keys rows by
|
||||||
|
// from-end position, so a transcript that
|
||||||
|
// grew since the last fetch shifted ids —
|
||||||
|
// match by logical content and keep the
|
||||||
|
// existing copy (issue #859).
|
||||||
|
val logicalIdx =
|
||||||
|
unmatchedIncoming.indexOfFirst { inc ->
|
||||||
|
inc != null && sameLogicalMessage(inc, existing)
|
||||||
|
}
|
||||||
|
if (logicalIdx >= 0) {
|
||||||
|
mergedList.add(existing)
|
||||||
|
unmatchedIncoming[logicalIdx] = null
|
||||||
|
} else {
|
||||||
|
mergedList.add(existing)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mergedList.add(existing)
|
mergedList.add(existing)
|
||||||
}
|
}
|
||||||
@@ -2516,6 +2616,7 @@ class ChatViewModel(
|
|||||||
sessionId: String,
|
sessionId: String,
|
||||||
offset: Int,
|
offset: Int,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
order: String? = null,
|
||||||
) = withContext(ioDispatcher) {
|
) = withContext(ioDispatcher) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.getSessionMessages(
|
ApiClient.hermesApi.getSessionMessages(
|
||||||
@@ -2523,6 +2624,7 @@ class ChatViewModel(
|
|||||||
limit = limit,
|
limit = limit,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
includeCompacted = true,
|
includeCompacted = true,
|
||||||
|
order = order,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2584,6 +2686,17 @@ class ChatViewModel(
|
|||||||
else -> MessageRole.ASSISTANT
|
else -> MessageRole.ASSISTANT
|
||||||
}
|
}
|
||||||
val globalIndex = offset + index
|
val globalIndex = offset + index
|
||||||
|
// Issue #859: under newest-anchored paging use the server's
|
||||||
|
// AUTOINCREMENT row id as the stable key — from-end positions shift
|
||||||
|
// as the transcript grows and would collide across hydrations
|
||||||
|
// (distinctBy would silently drop the newest copy). Legacy paging
|
||||||
|
// keeps the absolute-position key its count-based sync math needs.
|
||||||
|
val restId =
|
||||||
|
if (latestPaging) {
|
||||||
|
msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex"
|
||||||
|
} else {
|
||||||
|
"rest-$sessionId-$globalIndex"
|
||||||
|
}
|
||||||
val timestamp =
|
val timestamp =
|
||||||
msg.timestampText
|
msg.timestampText
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
@@ -2666,7 +2779,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
mapped.add(
|
mapped.add(
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
id = "rest-$sessionId-$globalIndex",
|
id = restId,
|
||||||
role = role,
|
role = role,
|
||||||
content = finalContent,
|
content = finalContent,
|
||||||
reasoningText = finalReasoning,
|
reasoningText = finalReasoning,
|
||||||
|
|||||||
@@ -1388,8 +1388,24 @@ class ChatViewModelTest {
|
|||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
||||||
)
|
)
|
||||||
coEvery { mockApi.getSessionMessages("session-a", any(), any(), any()) } coAnswers { messagesA.await() }
|
coEvery {
|
||||||
coEvery { mockApi.getSessionMessages("session-b", any(), any(), any()) } coAnswers { messagesB.await() }
|
mockApi.getSessionMessages(
|
||||||
|
"session-a",
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
} coAnswers { messagesA.await() }
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages(
|
||||||
|
"session-b",
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
} coAnswers { messagesB.await() }
|
||||||
|
|
||||||
val (viewModel, _) = createViewModelWithSession()
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
val resumeRequests = mutableMapOf<String, String>()
|
val resumeRequests = mutableMapOf<String, String>()
|
||||||
@@ -1440,8 +1456,24 @@ class ChatViewModelTest {
|
|||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
com.m57.hermescontrol.data.model.SessionListResponse(sessions = emptyList(), total = 0),
|
||||||
)
|
)
|
||||||
coEvery { mockApi.getSessionMessages("session-a", any(), any(), any()) } coAnswers { messagesA.await() }
|
coEvery {
|
||||||
coEvery { mockApi.getSessionMessages("session-b", any(), any(), any()) } coAnswers { messagesB.await() }
|
mockApi.getSessionMessages(
|
||||||
|
"session-a",
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
} coAnswers { messagesA.await() }
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages(
|
||||||
|
"session-b",
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
} coAnswers { messagesB.await() }
|
||||||
|
|
||||||
val (viewModel, _) = createViewModelWithSession()
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
viewModel.switchSession("session-a")
|
viewModel.switchSession("session-a")
|
||||||
@@ -1669,7 +1701,7 @@ class ChatViewModelTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
@@ -1679,7 +1711,7 @@ class ChatViewModelTest {
|
|||||||
} else {
|
} else {
|
||||||
// Relaxed mock returns a non-success response → NetworkResult.Failure.
|
// Relaxed mock returns a non-success response → NetworkResult.Failure.
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.error(
|
retrofit2.Response.error(
|
||||||
500,
|
500,
|
||||||
@@ -1706,7 +1738,7 @@ class ChatViewModelTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
sessionIds.forEach { sessionId ->
|
sessionIds.forEach { sessionId ->
|
||||||
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any()) } returns
|
coEvery { mockApi.getSessionMessages(sessionId, any(), any(), any(), any()) } returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
||||||
)
|
)
|
||||||
@@ -2106,7 +2138,7 @@ class ChatViewModelTest {
|
|||||||
// requested with.
|
// requested with.
|
||||||
val fetchedSessions = mutableListOf<String>()
|
val fetchedSessions = mutableListOf<String>()
|
||||||
coEvery {
|
coEvery {
|
||||||
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any())
|
ApiClient.hermesApi.getSessionMessages(capture(fetchedSessions), any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(messages = emptyList()),
|
||||||
@@ -2150,7 +2182,7 @@ class ChatViewModelTest {
|
|||||||
val mockApi = ApiClient.hermesApi
|
val mockApi = ApiClient.hermesApi
|
||||||
var restCalls = 0
|
var restCalls = 0
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} coAnswers {
|
} coAnswers {
|
||||||
val call = restCalls++
|
val call = restCalls++
|
||||||
if (call < 3) {
|
if (call < 3) {
|
||||||
@@ -2817,7 +2849,7 @@ class ChatViewModelTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
@@ -2863,10 +2895,12 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
var messagesCallCount = 0
|
var messagesCallCount = 0
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} coAnswers {
|
} coAnswers {
|
||||||
messagesCallCount += 1
|
messagesCallCount += 1
|
||||||
if (messagesCallCount == 1) {
|
// Call 1 = the order=latest probe (discarded when the legacy
|
||||||
|
// backend echoes no pagination — issue #859).
|
||||||
|
if (messagesCallCount == 2) {
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
messages =
|
messages =
|
||||||
@@ -2937,7 +2971,7 @@ class ChatViewModelTest {
|
|||||||
)
|
)
|
||||||
var messagesCallCount = 0
|
var messagesCallCount = 0
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} coAnswers {
|
} coAnswers {
|
||||||
messagesCallCount += 1
|
messagesCallCount += 1
|
||||||
if (messagesCallCount == 1) {
|
if (messagesCallCount == 1) {
|
||||||
@@ -3012,7 +3046,7 @@ class ChatViewModelTest {
|
|||||||
put("status", JsonPrimitive("ok"))
|
put("status", JsonPrimitive("ok"))
|
||||||
}
|
}
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApi.getSessionMessages("session-456", any(), any(), any())
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
} returns
|
} returns
|
||||||
retrofit2.Response.success(
|
retrofit2.Response.success(
|
||||||
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
@@ -3036,6 +3070,196 @@ class ChatViewModelTest {
|
|||||||
assertEquals("{\"status\":\"ok\"}", messages[0].content)
|
assertEquals("{\"status\":\"ok\"}", messages[0].content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Newest-anchored paging (issue #859) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInitialLoad_latestOrder_pagesFromNewest() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
|
||||||
|
val mockApi = ApiClient.hermesApi
|
||||||
|
val captured = mutableListOf<Triple<Int, Int, String?>>()
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} coAnswers {
|
||||||
|
captured.add(Triple(arg<Int>(2), arg<Int>(1), arg<String?>(3)))
|
||||||
|
retrofit2.Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
|
messages =
|
||||||
|
(1..150).map { i ->
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessage(
|
||||||
|
id = i,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Msg $i"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pagination =
|
||||||
|
com.m57.hermescontrol.data.model.PaginationInfo(
|
||||||
|
limit = 150,
|
||||||
|
offset = 0,
|
||||||
|
order = "latest",
|
||||||
|
returned = 150,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// One request: the newest page at offset 0 with order=latest —
|
||||||
|
// no count-based anchor, no sessions-list fetch.
|
||||||
|
assertEquals(1, captured.size)
|
||||||
|
assertEquals(Triple(0, 150, "latest"), captured[0])
|
||||||
|
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||||
|
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||||
|
// Stable keys come from the server row id, not the from-end position.
|
||||||
|
assertEquals("rest-session-456-150", viewModel.uiState.value.messages.last().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInitialLoad_latestOrder_shortPage_hasNoOlder() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
|
||||||
|
val mockApi = ApiClient.hermesApi
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} returns
|
||||||
|
retrofit2.Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessage(
|
||||||
|
id = 1,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Only Msg"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pagination =
|
||||||
|
com.m57.hermescontrol.data.model.PaginationInfo(
|
||||||
|
limit = 150,
|
||||||
|
offset = 0,
|
||||||
|
order = "latest",
|
||||||
|
returned = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||||
|
assertEquals(1, viewModel.uiState.value.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLoadOlderMessages_latestOrder_increasesOffset() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
|
||||||
|
val mockApi = ApiClient.hermesApi
|
||||||
|
val captured = mutableListOf<Triple<Int, Int, String?>>()
|
||||||
|
var page = 0
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} coAnswers {
|
||||||
|
captured.add(Triple(arg<Int>(2), arg<Int>(1), arg<String?>(3)))
|
||||||
|
page += 1
|
||||||
|
val (offset, returned) =
|
||||||
|
when (page) {
|
||||||
|
1 -> 0 to 150
|
||||||
|
2 -> 150 to 150
|
||||||
|
else -> 300 to 50
|
||||||
|
}
|
||||||
|
retrofit2.Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
|
messages =
|
||||||
|
(1..returned).map { i ->
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessage(
|
||||||
|
id = offset + i,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Page $page Msg $i"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pagination =
|
||||||
|
com.m57.hermescontrol.data.model.PaginationInfo(
|
||||||
|
limit = 150,
|
||||||
|
offset = offset,
|
||||||
|
order = "latest",
|
||||||
|
returned = returned,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||||
|
|
||||||
|
// Older pages INCREASE the from-end offset, always full-size.
|
||||||
|
viewModel.loadOlderMessages()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Triple(150, 150, "latest"), captured[1])
|
||||||
|
assertTrue(viewModel.uiState.value.hasOlderMessages)
|
||||||
|
assertEquals(300, viewModel.uiState.value.messages.size)
|
||||||
|
|
||||||
|
// Short final page: the oldest boundary — pagination stops.
|
||||||
|
viewModel.loadOlderMessages()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Triple(300, 150, "latest"), captured[2])
|
||||||
|
assertFalse(viewModel.uiState.value.hasOlderMessages)
|
||||||
|
assertEquals(350, viewModel.uiState.value.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSync_latestOrder_grownTranscript_keepsNewestMessage() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
|
||||||
|
val mockApi = ApiClient.hermesApi
|
||||||
|
var page = 0
|
||||||
|
coEvery {
|
||||||
|
mockApi.getSessionMessages("session-456", any(), any(), any(), any())
|
||||||
|
} coAnswers {
|
||||||
|
page += 1
|
||||||
|
// Hydration serves rows 1..150; the transcript then grows by 10
|
||||||
|
// and the sync refetches the newest page (rows 11..160).
|
||||||
|
val (from, to) = if (page == 1) 1 to 150 else 11 to 160
|
||||||
|
retrofit2.Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessagesResponse(
|
||||||
|
messages =
|
||||||
|
(from..to).map { i ->
|
||||||
|
com.m57.hermescontrol.data.model.SessionMessage(
|
||||||
|
id = i,
|
||||||
|
role = "assistant",
|
||||||
|
content = JsonPrimitive("Msg $i"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pagination =
|
||||||
|
com.m57.hermescontrol.data.model.PaginationInfo(
|
||||||
|
limit = 150,
|
||||||
|
offset = 0,
|
||||||
|
order = "latest",
|
||||||
|
returned = 150,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.switchSession("session-456")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(150, viewModel.uiState.value.messages.size)
|
||||||
|
|
||||||
|
viewModel.syncCurrentSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// 160 rows: the 10 new ones appended, the existing 150 kept —
|
||||||
|
// no duplicates, no dropped newest copy (stable row-id keys).
|
||||||
|
assertEquals(160, viewModel.uiState.value.messages.size)
|
||||||
|
assertTrue(viewModel.uiState.value.messages.any { it.id == "rest-session-456-160" })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Attachment open (issue #724) ─────────────────────────────────────
|
// ── Attachment open (issue #724) ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user