release: promote dev to main for v1.30.0 (#1271)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-25 23:23:12 +03:00
1 parent 7386c3035b
commit e4004decb4
85 files changed
+9284 -465

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.local
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
import org.junit.Assert.assertEquals
@@ -187,6 +188,32 @@ class ChatMessageMapperTest {
assertEquals(MessageRole.USER, roundTripped.role)
}
@Test
fun roundTripPreservesLocalPendingProvenance() {
val roundTripped =
ChatMessage(
id = "pending",
role = MessageRole.USER,
content = "not submitted",
messageProvenance = MessageProvenance.LOCAL_PENDING,
).toEntity("s").toUiModel()
assertEquals(MessageProvenance.LOCAL_PENDING, roundTripped.messageProvenance)
}
@Test
fun unknownPersistedProvenanceDoesNotInventDeliveryEvidence() {
val entity =
ChatMessageEntity(
id = "legacy",
sessionId = "s",
role = "USER",
content = "ambiguous",
timestamp = 1L,
messageProvenance = "future-value",
)
assertEquals(MessageProvenance.UNKNOWN, entity.toUiModel().messageProvenance)
}
@Test
fun roundTripWithoutDisplayKindStaysNull() {
val ui =
@@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest {
)
}
}
@Test
fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() =
runBlocking {
val connection = mockk<SQLiteConnection>(relaxed = true)
HermesDatabase.MIGRATION_9_10.migrate(connection)
verify(exactly = 1) {
connection.execSQL(
"ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'",
)
}
}
}
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
val builder = Request.Builder().url(server.url(path))
when (method) {
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
"DELETE" -> builder.delete()
else -> builder.get()
}
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/status/health", url.encodedPath)
}
@Test
fun transcribeRoute_getsTheActiveNonDefaultProfile() {
// Review (PR #1250): /api/audio/transcribe resolves STT through the
// request's profile on the backend — the desktop sends this route
// `...profileScoped()`. Unscoped, a multi-profile host would
// transcribe under the launch profile instead of the user's pick.
val client = clientFor("work")
val url = requestedUrl(client, "api/audio/transcribe", method = "POST")
assertEquals("work", url.queryParameter("profile"))
assertEquals("/api/audio/transcribe", url.encodedPath)
}
@Test
fun lookalikeAudioPath_notScoped() {
val client = clientFor("work")
val url = requestedUrl(client, "api/audio-extras")
assertNull(url.queryParameter("profile"))
}
}
@@ -0,0 +1,133 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
import java.io.File
import java.net.SocketTimeoutException
class VoiceNoteRepositoryTest {
private val api = mockk<HermesApiService>()
private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File =
File.createTempFile("voice-note-test", ".m4a").apply {
writeBytes(bytes)
deleteOnExit()
}
@Test
fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() =
runTest {
val file = tempVoiceFile(byteArrayOf(1, 2, 3))
val bodySlot = slot<AudioTranscriptionRequest>()
coEvery { api.transcribeAudio(capture(bodySlot)) } returns
Response.success(
AudioTranscriptionResponse(ok = true, transcript = " hello world "),
)
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
val success = result as NetworkResult.Success
assertEquals("hello world", success.data)
assertEquals("audio/mp4", bodySlot.captured.mimeType)
assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl)
}
@Test
fun transcribe_propagatesServerFailureWithoutRetrying() =
runTest {
val file = tempVoiceFile()
coEvery { api.transcribeAudio(any()) } returns
Response.error(422, "no speech".toResponseBody("text/plain".toMediaType()))
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
assertTrue(result is NetworkResult.Failure)
coVerify(exactly = 1) { api.transcribeAudio(any()) }
}
@Test
fun transcribe_doesNotRetryThePostAfterASocketTimeout() =
runTest {
// safeApiCall's default policy retries socket timeouts and 5xx; a
// blind re-POST would repeat provider STT work after the request
// may already have reached the server (review, PR #1250).
val file = tempVoiceFile()
coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout")
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
assertTrue(result is NetworkResult.Failure)
coVerify(exactly = 1) { api.transcribeAudio(any()) }
}
@Test
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
runTest {
val firstApi = mockk<HermesApiService>()
val secondApi = mockk<HermesApiService>()
coEvery { firstApi.transcribeAudio(any()) } returns
Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server"))
coEvery { secondApi.transcribeAudio(any()) } returns
Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server"))
var currentApi = firstApi
val repository = VoiceNoteRepository { _ -> currentApi }
val before = repository.transcribe(tempVoiceFile())
// Simulate ApiClient.rebuild() after a connection/profile switch:
// the next call must use the new service, not the captured one.
currentApi = secondApi
val after = repository.transcribe(tempVoiceFile())
assertEquals("old server", (before as NetworkResult.Success).data)
assertEquals("new server", (after as NetworkResult.Success).data)
coVerify(exactly = 1) { firstApi.transcribeAudio(any()) }
coVerify(exactly = 1) { secondApi.transcribeAudio(any()) }
}
@Test
fun transcribe_requestsTheDesktopParitySttTimeout() =
runTest {
val requestedTimeouts = mutableListOf<Long>()
coEvery { api.transcribeAudio(any()) } returns
Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok"))
VoiceNoteRepository { timeout ->
requestedTimeouts += timeout
api
}.transcribe(tempVoiceFile())
// A tiny clip must still get the 180s floor, not the shared 30s client.
assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts)
}
@Test
fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() {
assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID"))
assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000)))
assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000)))
}
@Test
fun transcribe_unreadableFileReturnsFailureWithoutUploading() =
runTest {
val missing = tempVoiceFile().apply { delete() }
val result = VoiceNoteRepository { _ -> api }.transcribe(missing)
assertTrue(result is NetworkResult.Failure)
coVerify(exactly = 0) { api.transcribeAudio(any()) }
}
}
@@ -1,9 +1,13 @@
package com.m57.hermescontrol.notification
import android.content.Context
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
atomicBoolean.get(),
)
}
@Test
fun `terminal reply error uses generic failure notification without success affordances`() {
val rawDiagnostic = "Authorization: Bearer secret-token"
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = rawDiagnostic,
sessionId = "runtime-1",
storedSessionId = "stored-1",
completionId = "completion-1",
rawPayload =
mapOf(
"status" to "error",
"error" to rawDiagnostic,
),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Hermes couldn't finish this reply", plan.text)
assertFalse(plan.text.contains(rawDiagnostic))
assertEquals("stored-1", plan.sessionId)
assertFalse(plan.isReplyMessage)
assertFalse(plan.allowInlineReply)
assertNull(plan.completionId)
assertNull(plan.correlationText)
}
@Test
fun `successful completion keeps reply correlation and inline reply`() {
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = "Answer\ncontinued",
sessionId = "runtime-1",
completionId = "completion-1",
rawPayload = mapOf("status" to "ok"),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Answer continued", plan.text)
assertEquals("stored-1", plan.sessionId)
assertTrue(plan.isReplyMessage)
assertTrue(plan.allowInlineReply)
assertEquals("completion-1", plan.completionId)
assertEquals("Answer\ncontinued", plan.correlationText)
}
}
@@ -0,0 +1,187 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatCachedHistoryTest {
private val historicalTool =
ChatMessage(
id = "cached-tool",
role = MessageRole.TOOL,
content = "{}",
toolName = "terminal",
toolCallId = "call-old",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
)
@Test
fun exactToolIdPromotesOnlyItsHistoricalIdentity() {
val other = historicalTool.copy(id = "other", toolCallId = "call-other")
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool, other))
val events =
listOf(
WsEvent.ToolProgress(name = "terminal", preview = "working", sessionId = "s", toolId = "call-old"),
WsEvent.ToolGenerating(name = "terminal", sessionId = "s", toolId = "call-old"),
WsEvent.ToolComplete(name = "terminal", data = mapOf("tool_id" to "call-old"), sessionId = "s"),
)
events.forEach { event ->
val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s")
assertFalse(
"$event must supersede historical provenance",
result.state.messages
.first()
.isHistoricalCache,
)
assertTrue(
result.state.messages
.last()
.isHistoricalCache,
)
}
}
@Test
fun toolProgressWithNonmatchingExplicitIdDoesNotPromoteHistoricalSameNameCall() {
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
val result =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.ToolProgress(name = "terminal", preview = "new call", sessionId = "s", toolId = "call-new"),
"s",
)
assertEquals(listOf(historicalTool), result.state.messages)
}
@Test
fun toolCompleteWithNonmatchingExplicitIdDoesNotOverwriteHistoricalSameNameCall() {
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
val result =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.ToolComplete(
name = "terminal",
data = mapOf("tool_id" to "call-new", "output" to "new call"),
sessionId = "s",
),
"s",
)
assertEquals(listOf(historicalTool), result.state.messages)
assertTrue(result.effects.isEmpty())
}
@Test
fun legacyIdlessToolEventsStillPromoteHistoricalSameNameCall() {
val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool))
val events =
listOf(
WsEvent.ToolProgress(name = "terminal", preview = "legacy", sessionId = "s"),
WsEvent.ToolComplete(name = "terminal", data = mapOf("output" to "legacy"), sessionId = "s"),
)
events.forEach { event ->
val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s")
assertFalse(
"$event must retain the legacy name fallback",
result.state.messages
.single()
.isHistoricalCache,
)
}
}
@Test
fun lateCacheCannotDemoteAnObservedLiveIdentity() {
val live = historicalTool.copy(isHistoricalCache = false, progressPreview = "new progress")
val merged = mergeCachedTranscriptPage(listOf(historicalTool), listOf(live))
assertEquals(listOf(live), merged)
}
@Test
fun cachedSessionStartMarkerStaysFirstBeforeHistoricalUuidAndCanonicalRestWindow() {
val start =
ChatMessage(
id = "cached-session-start",
role = MessageRole.SYSTEM,
content = "Session created",
isHistoricalCache = true,
)
val historicalUser =
ChatMessage(
id = "5df43b6f-98ea-46db-9fc4-d1e4cf11d43d",
role = MessageRole.USER,
content = "older cached prompt",
isHistoricalCache = true,
)
val canonical = ChatMessage(id = "rest-s-0", role = MessageRole.ASSISTANT, content = "canonical reply")
val merged = mergeTranscriptWithLive(listOf(canonical), listOf(start, historicalUser))
assertEquals(listOf(start.id, historicalUser.id, canonical.id), merged.map { it.id })
}
@Test
fun restoredRepeatedUsersKeepBothOccurrencesUntilEachCanonicalPageConfirmsOne() {
val first =
ChatMessage(
id = "restored-first",
role = MessageRole.USER,
content = "continue",
localOrder = 1,
isRestoredUnconfirmed = true,
)
val second = first.copy(id = "restored-second", localOrder = 2)
val latest = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "Latest answer")
val restored = mergeCachedTranscriptPage(listOf(first, second), listOf(latest))
assertEquals(listOf(first.id, second.id, latest.id), restored.map { it.id })
assertTrue(restored.take(2).all { it.canonicalRestId == null })
val older = ChatMessage(id = "rest-s-20", role = MessageRole.USER, content = "continue")
val once = mergeTranscriptWithLive(listOf(older), restored, chronological = false)
assertEquals(2, once.count { it.role == MessageRole.USER })
assertEquals(1, once.count { it.canonicalRestId == older.id })
val confirmed = once.single { it.canonicalRestId == older.id }
assertFalse(confirmed.isRestoredUnconfirmed)
val remaining = once.single { it.role == MessageRole.USER && it.canonicalRestId == null }
assertTrue(remaining.isRestoredUnconfirmed)
assertEquals(MessageProvenance.UNKNOWN, remaining.messageProvenance)
val twice = mergeTranscriptWithLive(listOf(older.copy(id = "rest-s-21")), once, chronological = false)
assertEquals(listOf(first.id, second.id, latest.id), twice.map { it.id })
assertEquals(listOf("rest-s-20", "rest-s-21"), twice.take(2).map { it.canonicalRestId })
assertTrue(twice.none { it.isRestoredUnconfirmed })
}
@Test
fun historicalRowsPrecedeCanonicalWindowWithoutChangingLiveTailOrAttachments() {
val attachment = Attachment("content://test/file", "file.txt", "text/plain")
val old =
ChatMessage(
id = "cached-user",
role = MessageRole.USER,
content = "old",
timestamp = Long.MAX_VALUE,
attachments = listOf(attachment),
isHistoricalCache = true,
)
val pending = old.copy(id = "pending", content = "pending", isHistoricalCache = false)
val recent = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "recent", timestamp = 1)
val live = historicalTool.copy(id = "live-tool", toolCallId = "call-live", isHistoricalCache = false)
val resume = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed")
val merged = mergeTranscriptWithLive(listOf(recent), listOf(old, historicalTool, pending, live, resume))
assertEquals(listOf(old.id, historicalTool.id, recent.id, pending.id, live.id, resume.id), merged.map { it.id })
assertEquals(listOf(attachment), merged.first().attachments)
assertEquals(listOf(attachment), merged.single { it.id == pending.id }.attachments)
assertEquals(merged, mergeTranscriptWithLive(listOf(recent), merged))
}
}
@@ -12,6 +12,63 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ChatPagingMergeTest {
@Test
fun lateCachedRunningToolCannotReplaceCanonicalServerResult() {
val canonical =
ChatMessage(
id = "rest-s-42",
role = MessageRole.TOOL,
content = """{"output":"server result","exit_code":0}""",
toolCallId = "call-42",
toolStatus = ToolStatus.COMPLETED,
)
val cached =
ChatMessage(
id = "cached-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
tokenCount = 7,
tps = 2.5,
finishTimestamp = 123L,
)
val merged = mergeCachedTranscriptPage(listOf(cached), listOf(canonical)).single()
assertEquals(canonical.id, merged.id)
assertEquals(canonical.content, merged.content)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertEquals("terminal", merged.toolName)
assertEquals("call-42", merged.toolCallId)
assertEquals(cached.tokenCount, merged.tokenCount)
assertEquals(cached.tps, merged.tps)
assertEquals(cached.finishTimestamp, merged.finishTimestamp)
}
@Test
fun canonicalResultSettlesHistoricalRunningToolWithServerOutput() {
val cached =
ChatMessage(
id = "old-tool",
role = MessageRole.TOOL,
content = """{"name":"terminal","args":{"command":"pwd"}}""",
toolName = "terminal",
toolCallId = "call-42",
toolStatus = ToolStatus.RUNNING,
isHistoricalCache = true,
)
val page = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(cached))
val merged = mergeTranscriptWithLive(page, listOf(cached), preserveLiveIds = true).single()
assertEquals(cached.id, merged.id)
assertEquals("rest-s-42", merged.canonicalRestId)
assertEquals(ToolStatus.COMPLETED, merged.toolStatus)
assertTrue(merged.content.contains("exit_code"))
assertTrue(!merged.isHistoricalCache)
}
@Test
fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) {
@@ -598,6 +655,50 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical, live))
}
@Test
fun verifierFooterReplyMatchesCanonicalCounterpartWithoutFooter() {
val footer =
"""
⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed.
• /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
""".trimIndent()
val body = "I completed the audit. Here are the findings."
val liveAssistant =
ChatMessage(
id = "uuid-123",
role = MessageRole.ASSISTANT,
content = "$body\n\n$footer",
completionId = "comp-1",
)
val serverRow =
SessionMessage(
id = 42,
role = "assistant",
content = JsonPrimitive(body),
)
// 1. mapServerMessages correctly matches liveAssistant and acquires comp-1 and rich footer content
val mapped = mapServerMessages("session", listOf(serverRow), 0, true, listOf(liveAssistant))
assertEquals(1, mapped.size)
val canonical = mapped.single()
assertEquals("rest-session-42", canonical.id)
assertEquals("comp-1", canonical.completionId)
assertTrue("Canonical mapped row should retain verifier footer", canonical.content.contains(footer))
// 2. mergeTranscriptWithLive merges into a single message preserving footer and acquiring restId
val merged =
mergeTranscriptWithLive(
restMessages = mapped,
currentMessages = listOf(liveAssistant),
preserveLiveIds = true,
)
assertEquals(1, merged.size)
val single = merged.single()
assertEquals("uuid-123", single.id)
assertEquals("rest-session-42", single.restId)
assertTrue("Merged message must keep the verifier warning footer", single.content.contains(footer))
}
@Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback =
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp)
}
@Test
fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() =
runTest {
repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s")
repository.persistMessage(
ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"),
"s",
)
repository.deleteMessage("local")
repository.deleteMessage("confirmed")
val rows = repository.loadMessages("s")
assertFalse(rows.any { it.id == "local" })
assertEquals("confirmed", rows.single().id)
}
@Test
fun persistMessages_upsertsAllToDao() =
runTest {
@@ -0,0 +1,48 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatQueuePresentationTest {
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
private fun pending(state: PendingSendState) =
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
@Test
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
val messages = listOf(message)
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
val receipts = listOf(pending(state))
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
assertEquals(listOf(message), messages)
assertEquals(state, receipts.single().state)
}
}
@Test
fun queuedMessageAppearsOnceWhenSendingStarts() {
val messages = listOf(message)
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
}
}
@Test
fun ordinarySendAndOtherMessagesAreUnchanged() {
val other = ChatMessage("other", MessageRole.USER, "Send now")
val messages = listOf(other, message)
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
@Test
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
val messages = listOf(message.copy(restId = "rest-session-12"))
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
}
@@ -0,0 +1,315 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatReplyErrorTest {
@Test
fun failedCompletionKeepsPartialReplyAndStopsThinking() {
val partial =
ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "Partial reply", isStreaming = true)
val state =
ChatUiState(
currentSessionId = "runtime",
isAgentTyping = true,
isThinking = true,
streamingMessage = partial,
)
val streaming = StreamingState(streamingMessage = partial, isThinking = true)
val event =
WsEvent.MessageComplete(
text = "Partial reply",
sessionId = "runtime",
completionId = "completion-failed-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
)
val result = ChatWsEventReducer.reduce(state, streaming, event, "runtime")
assertFalse(result.state.isThinking)
assertEquals(
"Partial reply",
result.state.messages
.single { it.id == "partial" }
.content,
)
assertNotNull(result.state.replyFailure)
assertFalse(result.state.isAgentTyping)
assertNull(result.streamingState.streamingMessage)
assertEquals(
"completion-failed-1",
result.state.messages
.single { it.id == "partial" }
.completionId,
)
assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
}
@Test
fun terminalErrorIsNeverPersistedAsAssistantProse() {
val event =
WsEvent.MessageComplete(
text = "Provider refused API_KEY=private-value",
sessionId = "runtime",
rawPayload = mapOf("status" to "error"),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event, "runtime")
assertNotNull(result.state.replyFailure)
assertFalse(
result.state.replyFailure!!
.details
.contains("private-value"),
)
assertTrue(result.state.messages.isEmpty())
assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
}
@Test
fun errorWithoutPartialFlagStillKeepsAlreadyStreamedProse() {
val streaming =
StreamingState(streamingMessage = ChatMessage(role = MessageRole.ASSISTANT, content = "Visible"))
val event = WsEvent.MessageComplete("Error!", null, rawPayload = mapOf("status" to "error"))
val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
assertEquals(
"Visible",
result.state.messages
.single()
.content,
)
assertNotNull(result.state.replyFailure)
}
@Test
fun otherSessionCannotShowFailureOrChangeStreaming() {
val state = ChatUiState(isAgentTyping = true)
val streaming = StreamingState(isThinking = true)
val event = WsEvent.MessageComplete("Error", "other", rawPayload = mapOf("status" to "error"))
val result = ChatWsEventReducer.reduce(state, streaming, event, "current")
assertEquals(state, result.state)
assertEquals(streaming, result.streamingState)
assertTrue(result.effects.isEmpty())
}
@Test
fun missingPartialTextPreservesAlreadyVisibleOutput() {
for (text in listOf("", " ")) {
val streaming =
StreamingState(
streamingMessage =
ChatMessage(
id = "visible",
role = MessageRole.ASSISTANT,
content = "Visible output",
),
)
val event =
WsEvent.MessageComplete(
text,
null,
rawPayload = mapOf("status" to "error", "partial" to true),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
assertEquals(
"Visible output",
result.state.messages
.single()
.content,
)
}
}
@Test
fun partialConversationIsNeverUsedAsDiagnosticFallback() {
val event =
WsEvent.MessageComplete(
"Private conversational content",
null,
rawPayload = mapOf("status" to "error", "partial" to true),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
assertEquals(
"Private conversational content",
result.state.messages
.single()
.content,
)
assertFalse(
result.state.replyFailure!!
.details
.contains("Private conversational content"),
)
assertTrue(
result.state.replyFailure!!
.details
.isNotBlank(),
)
}
@Test
fun normalReplyAndGatewayErrorAreNotTerminalReplyFailures() {
for (event in listOf(WsEvent.MessageComplete("Success", null), WsEvent.GatewayError("Connection failed"))) {
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
assertNull(result.state.replyFailure)
}
}
@Test
fun nextTurnClearsFailureButDoneDoesNot() {
val failure = ReplyFailure("Failed")
val failedPartial =
ChatMessage(id = "failed-partial", role = MessageRole.ASSISTANT, content = "Partial")
val state =
ChatUiState(
messages = listOf(ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep"), failedPartial),
replyFailure = failure,
replyFailureProjection = ReplyFailureProjection("failed-partial", failure.id),
)
val done = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageDone(null))
assertEquals(failure, done.state.replyFailure)
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart(null))
assertNull(start.state.replyFailure)
assertNull(start.state.replyFailureProjection)
assertEquals(listOf("normal"), start.state.messages.map { it.id })
}
@Test
fun retainedFailureWithEmptyAssistantNeverInheritsUnrelatedStream() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val stale =
ChatMessage(
id = "unrelated-stream",
role = MessageRole.ASSISTANT,
content = "Stale text",
reasoningText = "Stale reasoning",
isStreaming = true,
)
val state =
ChatUiState(
messages = listOf(normal),
isAgentTyping = true,
isThinking = true,
thinkingText = "Stale thinking",
streamingMessage = stale,
)
val result =
ChatWsEventReducer.reduceRetainedReplyFailure(
state = state,
inflight = mapOf("assistant" to "", "status" to "error", "error" to "Provider failed"),
currentSessionId = "runtime",
)
assertEquals(listOf(normal), result.state.messages)
assertFalse(result.state.isAgentTyping)
assertFalse(result.state.isThinking)
assertEquals("", result.state.thinkingText)
assertNull(result.state.streamingMessage)
assertEquals(StreamingState(), result.streamingState)
assertNotNull(result.state.replyFailure)
assertNull(result.state.replyFailureProjection?.messageId)
}
@Test
fun duplicateRetainedFailureKeepsOneStableProjection() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val inflight =
mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed")
val first =
ChatWsEventReducer.reduceRetainedReplyFailure(
ChatUiState(messages = listOf(normal)),
inflight,
"runtime",
)
val second = ChatWsEventReducer.reduceRetainedReplyFailure(first.state, inflight, "runtime")
assertEquals(2, second.state.messages.size)
assertEquals(listOf("Keep", "Retained partial"), second.state.messages.map { it.content })
assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
}
@Test
fun duplicateLiveFailureKeepsOneStableProjection() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val partial =
ChatMessage(id = "live-partial", role = MessageRole.ASSISTANT, content = "Partial", isStreaming = true)
val event =
WsEvent.MessageComplete(
text = "Partial",
sessionId = "runtime",
completionId = "completion-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
)
val first =
ChatWsEventReducer.reduce(
ChatUiState(messages = listOf(normal), currentSessionId = "runtime"),
StreamingState(streamingMessage = partial),
event,
"runtime",
)
val second = ChatWsEventReducer.reduce(first.state, first.streamingState, event, "runtime")
assertEquals(listOf("normal", "live-partial"), second.state.messages.map { it.id })
assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
}
@Test
fun liveFailureThenResumeUpdatesSameProjection() {
val partial =
ChatMessage(
id = "live-partial",
role = MessageRole.ASSISTANT,
content = "Live partial",
isStreaming = true,
)
val live =
ChatWsEventReducer.reduce(
ChatUiState(currentSessionId = "stored"),
StreamingState(streamingMessage = partial),
WsEvent.MessageComplete(
text = "Live partial",
sessionId = "runtime",
completionId = "completion-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
),
"runtime",
)
val resumed =
ChatWsEventReducer.reduceRetainedReplyFailure(
live.state,
mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed"),
"runtime",
)
assertEquals(1, resumed.state.messages.size)
assertEquals(
"live-partial",
resumed.state.messages
.single()
.id,
)
assertEquals(
"Retained partial",
resumed.state.messages
.single()
.content,
)
assertEquals(
"completion-1",
resumed.state.messages
.single()
.completionId,
)
assertEquals(live.state.replyFailureProjection, resumed.state.replyFailureProjection)
assertEquals(live.state.replyFailure?.id, resumed.state.replyFailure?.id)
}
}
@@ -0,0 +1,207 @@
package com.m57.hermescontrol.ui.chat
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendStoreTest {
private fun pending(
id: String,
text: String,
state: PendingSendState = PendingSendState.ACCEPTED,
) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state)
private fun mappedHistoryAliases(
incoming: List<ChatMessage>,
existing: List<ChatMessage>,
): List<ChatMessage> =
incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match ->
match?.copy(restId = rest.canonicalRestId) ?: rest
}
@Test
fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() {
val local = ChatMessage("send-1", MessageRole.USER, "See attachment")
val rest =
ChatMessage(
"rest-session-10",
MessageRole.USER,
"""
[OUT-OF-BAND USER MESSAGE — direct steering]
@file:files/agent-vault/hermes/attachments/note.txt
See attachment
[/OUT-OF-BAND USER MESSAGE]
""".trimIndent(),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
mappedHistoryAliases(listOf(rest), listOf(local)),
listOf(pending("send-1", "See attachment")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
val existing =
listOf(
ChatMessage("send-1", MessageRole.USER, "repeat"),
ChatMessage("send-2", MessageRole.USER, "repeat"),
)
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
existing,
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() {
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
listOf(ChatMessage("send-1", MessageRole.USER, "repeat")),
)
val first =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first }
assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty())
}
@Test
fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() {
val aliases =
listOf(
ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"),
ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(
pending("queued", "same", PendingSendState.QUEUED),
pending("parked", "same", PendingSendState.PARKED),
),
)
assertTrue(confirmed.isEmpty())
}
@Test
fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() {
val store = ChatSendStore()
store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE))
store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE))
store.promote("second")
assertEquals(listOf("second", "first"), store.all().map { it.id })
}
@Test
fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
val first = ChatSendStore(prefs)
first.put(
PendingSend(
"queued",
"connection-a",
"session-a",
"later",
attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)),
mode = BusySendMode.QUEUE,
),
)
first.put(
PendingSend(
"sending",
"connection-b",
"session-b",
"maybe",
mode = BusySendMode.CORRECT,
state = PendingSendState.SENDING,
),
)
first.park("connection-a", "session-a")
val restored = ChatSendStore(prefs)
assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state)
assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state)
assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope)
assertEquals(
"file:///private/photo",
restored
.all()
.single { it.id == "queued" }
.attachments
.single()
.uri,
)
assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId)
restored.remove("queued")
assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" })
assertEquals(1, ChatSendStore(prefs).all().size)
}
@Test
fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
ChatSendStore(prefs).put(
PendingSend(
"accepted",
"connection-a",
"session-a",
"possibly accepted",
mode = BusySendMode.QUEUE,
state = PendingSendState.ACCEPTED,
),
)
val restored = ChatSendStore(prefs).all().single()
assertEquals(PendingSendState.UNKNOWN, restored.state)
assertEquals(0, restored.attempts)
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.remote.ApiClient
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
every { android.util.Log.e(any(), any(), any()) } returns 0
mockkObject(AuthManager)
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
// when the catalog load succeeds; without this stub the spy falls
// through to the real AuthManager and throws "not initialized".
every { AuthManager.getPinnedModels() } returns emptyList()
// The delegate and the shared catalog store both collect this flow;
// park them on a never-emitting state so a later real-AuthManager
// emission cannot resume a stale Main-dispatched collector.
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -87,6 +96,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +161,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +206,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown
@@ -224,7 +253,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate settles on success when the action exits cleanly`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +289,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody())
@@ -0,0 +1,90 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatVerifierFooterTest {
private val sampleFooter =
"""
⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed.
• /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
""".trimIndent()
private val backtickedFooter =
"""
⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
• `/tmp/skill_lang_audit.py` — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix.
""".trimIndent()
@Test
fun `split extracts body and footer for exact reporter format`() {
val body = "I completed the audit. Here are the findings."
val full = "$body\n\n$sampleFooter"
val result = ChatVerifierFooter.split(full)
assertNotNull(result)
assertEquals(body, result?.body)
assertEquals(sampleFooter, result?.footer?.trim())
}
@Test
fun `split extracts body and footer for backend backticked format`() {
val body = "Audit summary:\n- item 1\n- item 2"
val full = "$body\n\n$backtickedFooter"
val result = ChatVerifierFooter.split(full)
assertNotNull(result)
assertEquals(body, result?.body)
assertEquals(backtickedFooter, result?.footer?.trim())
}
@Test
fun `split handles multiple bullets and overflow note`() {
val footer =
"""
⚠️ File-mutation verifier: 3 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed.
• `/a.py` — [patch] failed
• `/b.py` — [patch] failed
• … and 1 more
""".trimIndent()
val body = "Done"
val result = ChatVerifierFooter.split("$body\n\n$footer")
assertNotNull(result)
assertEquals("Done", result?.body)
}
@Test
fun `split rejects footer when body is empty`() {
val result = ChatVerifierFooter.split(sampleFooter)
assertNull(result)
}
@Test
fun `split rejects footer when inside a line without newline before it`() {
val text = "Look at this: ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording..."
val result = ChatVerifierFooter.split(text)
assertNull(result)
}
@Test
fun `split rejects text with unrelated trailing prose`() {
val full = "Body text\n\n$sampleFooter\nAnd here is more stuff after the footer."
val result = ChatVerifierFooter.split(full)
assertNull(result)
}
@Test
fun `matchesBase compares body ignoring verifier footer`() {
val body = "Here is the response without errors."
val withFooter = "$body\n\n$sampleFooter"
assertTrue(ChatVerifierFooter.matchesBase(body, withFooter))
assertTrue(ChatVerifierFooter.matchesBase(withFooter, body))
assertTrue(ChatVerifierFooter.matchesBase(withFooter, withFooter))
assertFalse(ChatVerifierFooter.matchesBase("Different body", withFooter))
}
}
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,21 @@ class FullBleedTurnsTest {
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
@Test
fun historicalRunningToolDoesNotClaimLiveActivity() {
val messages =
listOf(
msg("user", MessageRole.USER),
msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING)
.copy(toolName = "terminal", isHistoricalCache = true),
)
assertEquals(AgentStatus.Typing, deriveAgentStatus(true, StreamingState(), messages))
assertEquals(
AgentStatus.Tool("terminal"),
deriveAgentStatus(true, StreamingState(), messages.map { it.copy(isHistoricalCache = false) }),
)
}
@Test
fun `empty list produces no turns`() {
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
assertEquals("Run val x = 1 here", parsed.toString())
}
@Test
fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() {
val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("bold code and italic", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
val codeStart = parsed.indexOf("code")
assertTrue(
parsed.spanStyles.any {
it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
assertTrue(
parsed.spanStyles.any {
it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
}
@Test
fun testLinkLabel_nestedEmphasisAndCode() {
val parsed =
parseInline(
"[**bold `code`**](https://example.com)",
Color.Black,
"",
false,
Color.Blue,
DEFAULT_HIGHLIGHTS,
)
assertEquals("bold code", parsed.toString())
assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test
fun testCodeSpan_doesNotParseInnerEmphasis() {
val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("**literal**", parsed.toString())
assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
}
@Test
fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() {
val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("a ` tick and `unfinished", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
@@ -0,0 +1,61 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReplyFailureDetailsTest {
@Test(timeout = 2_000)
fun longNonMatchingDiagnosticDoesNotBlockUi() {
assertEquals("x".repeat(8_192), sanitizeReplyErrorDetails("x".repeat(100_000)))
}
@Test
fun redactsCredentialsAndUrlsBeforeDisplayOrExport() {
val raw =
"""
Provider failed with HTTP 429
Authorization: Bearer private-bearer
Cookie: sid=private-cookie; csrf=private-csrf
{"api_key":"private-key", "password": "private password", "access_token": "private-access"}
https://user:private-pass@example.test/path?token=private-query
OPENAI_API_KEY=private-env
-----BEGIN PRIVATE KEY-----
private-key-material
-----END PRIVATE KEY-----
""".trimIndent()
val details = sanitizeReplyErrorDetails(raw)
assertTrue(details.contains("HTTP 429"))
assertTrue(details.contains("[REDACTED]"))
assertFalse(details.contains("private-"))
assertFalse(details.contains("private password"))
}
@Test
fun limitsDetailsAndHandlesMissingMessage() {
assertTrue(sanitizeReplyErrorDetails("x".repeat(100_000)).length <= 8_192)
assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(null))
assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(" "))
}
@Test
fun structuredSurfaceExportsOnlyAllowedMetadataAndSanitizedError() {
val failure =
replyFailureFromPayload(
mapOf(
"status" to "error",
"error" to "HTTP 429",
"error_surface" to
mapOf(
"provider" to "example",
"code" to "rate_limit",
"unknown_field" to "private-information",
"message" to "private-conversation",
),
),
"fallback",
)
assertEquals("code: rate_limit\nprovider: example\nHTTP 429", failure?.details)
}
}
@@ -2,7 +2,10 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.model.PaginationInfo
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.ConnectionStatus
@@ -11,6 +14,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@@ -74,6 +78,14 @@ class SlashCommandDispatchRpcTest {
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
// the slash-dispatch send path is synchronous (no IO hop).
mockkObject(AuthManager)
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
// when the catalog load succeeds; without this stub the spy falls
// through to the real AuthManager and throws "not initialized".
every { AuthManager.getPinnedModels() } returns emptyList()
// The delegate and the shared catalog store both collect this flow;
// park them on a never-emitting state so a later real-AuthManager
// emission cannot resume a stale Main-dispatched collector.
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
mockkObject(HermesWsClient)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -94,6 +106,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +168,15 @@ class SlashCommandDispatchRpcTest {
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -444,7 +468,11 @@ class SlashCommandDispatchRpcTest {
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val m = methodCalls.last()
// Read the method from THIS invocation, not from the shared
// capture list: a concurrent request() landing between capture
// and answer can leave .last() pointing at another call (CI:
// "Value not yet captured" for the submitted prompt).
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
if (m == WsMethods.COMMAND_DISPATCH) {
// Backend rejects /status with the registry-miss 4018 (issue #576).
@@ -714,7 +742,9 @@ class SlashCommandDispatchRpcTest {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
} answers {
val d = CompletableDeferred<Any?>()
if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) {
// Read the method from THIS invocation, not from the shared
// capture list (see the 4018 test above).
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
// Backend /init returns type:"send" with the AGENTS.md prompt.
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
} else {
@@ -730,6 +760,15 @@ class SlashCommandDispatchRpcTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
coEvery {
ApiClient.hermesApi.getSessionMessages("session-xyz", any(), any(), any(), any())
} returns
retrofit2.Response.success(
SessionMessagesResponse(
messages = emptyList(),
pagination = PaginationInfo(order = "latest"),
),
)
vm.sendMessage("/init")
advanceUntilIdle()
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId }
}
override suspend fun deleteUnconfirmedMessage(id: String) {
if (messages[id]?.restId == null) messages.remove(id)
}
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id]
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm
}
@Test
fun busySendDefaultLoadsAndSaves() {
every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE
val viewModel = createViewModel()
assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
viewModel.onBusySendModeChange(BusySendMode.QUEUE)
assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode)
verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) }
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.setBusySendMode(any()) } returns Unit
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false