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:
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
|
||||
|
||||
Reference in new issue
Block a user