chore: promote dev to main (#1455)

Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
m57andAlexDev_ authored and GitHub committed 2026-10-03 00:51:11 +03:00
1 parent 62fcf07e1b
commit 944390bec1
56 files changed
+3914 -304

No files matched your search

@@ -0,0 +1,464 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Regression coverage for issue #1451:
* Transcript ordering when REST confirms a newer prompt while older live rows
* (observed live user/tool + review notices) remain unmatched/unconfirmed.
*/
class ChatLiveOrderingRegressionTest {
@Test
fun unconfirmedLiveUserAndToolWithReviewNoticesStayBeforeConfirmedNewerPrompt() {
// Given an observed live history:
// 1. Live user prompt (unconfirmed by REST)
// 2. Live tool call / result (unconfirmed by REST)
// 3. First system review notice
// 4. Second system review notice
// 5. Later live user prompt
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "first task",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":{"output":"task done","exit_code":0}}""",
toolName = "terminal",
toolCallId = "call_task_1",
timestamp = 1_100L,
)
val systemReview1 =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: skill updated",
timestamp = 1_200L,
)
val systemReview2 =
ChatMessage(
id = "sys-review-2",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: playbook refreshed",
timestamp = 1_300L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_400L,
)
val currentMessages =
listOf(
liveUserOld,
liveTool,
systemReview1,
systemReview2,
liveUserNew,
)
// REST sync only returns/confirms the second prompt (e.g. rest-s-5),
// while the older live user/tool and review notices remain unmatched in REST.
val restConfirmedNew =
ChatMessage(
id = "rest-s-5",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_450L,
)
val restMessages = listOf(restConfirmedNew)
// When merged with chronological = true, preserveLiveIds = false
val merged =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Then:
// 1. All rows must be preserved.
assertEquals(5, merged.size)
// 2. Ordering contract: the older live user, tool, and two system review notices
// MUST remain before the newer confirmed prompt.
val expectedIds =
listOf(
"live-user-old",
"live-tool-1",
"sys-review-1",
"sys-review-2",
"live-user-new", // Merged with rest-s-5; when role=USER, match's ID is preserved, restId recorded
)
assertEquals(expectedIds, merged.map { it.id })
// 3. The confirmed prompt acquires its canonical REST identity
val confirmedRow = merged.last()
assertEquals("rest-s-5", confirmedRow.canonicalRestId)
assertEquals(MessageRole.USER, confirmedRow.role)
assertEquals("second prompt", confirmedRow.content)
// 4. Older unconfirmed rows must NOT invent or acquire rest identities
val unconfirmedRows = merged.take(4)
unconfirmedRows.forEach { row ->
assertNotEquals("rest-s-5", row.restId)
assertEquals(null, row.canonicalRestId)
}
// 5. Repeated merge must be idempotent
val repeatedMerge =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = merged,
chronological = true,
preserveLiveIds = false,
)
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
assertEquals(merged, repeatedMerge)
}
@Test
fun controlPlainAppendAndNonConfirmationMaintainsObservedLiveOrder() {
// Control scenario:
// Current live transcript contains an old user prompt, tool call, two review notices,
// and a later user prompt.
// REST returns no confirmations (emptyList) or an unmatching server message.
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "first task",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":{"output":"task done","exit_code":0}}""",
toolName = "terminal",
toolCallId = "call_task_1",
timestamp = 1_100L,
)
val systemReview1 =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note 1",
timestamp = 1_200L,
)
val systemReview2 =
ChatMessage(
id = "sys-review-2",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note 2",
timestamp = 1_300L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "second prompt",
timestamp = 1_400L,
)
val currentMessages =
listOf(
liveUserOld,
liveTool,
systemReview1,
systemReview2,
liveUserNew,
)
// Case A: REST sync is empty
val mergedEmpty =
mergeTranscriptWithLive(
restMessages = emptyList(),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
assertEquals(
listOf(
"live-user-old",
"live-tool-1",
"sys-review-1",
"sys-review-2",
"live-user-new",
),
mergedEmpty.map {
it.id
},
)
// Case B: REST returns an unrelated / earlier server message (e.g. session start rest-s-0)
val canonicalPrior =
ChatMessage(
id = "rest-s-0",
role = MessageRole.USER,
content = "very first conversation starter",
timestamp = 500L,
)
val mergedWithPrior =
mergeTranscriptWithLive(
restMessages = listOf(canonicalPrior),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Canonical server rows maintain server order, while observed live unconfirmed block stays intact
assertEquals(
listOf("rest-s-0", "live-user-old", "live-tool-1", "sys-review-1", "sys-review-2", "live-user-new"),
mergedWithPrior.map { it.id },
)
// Idempotence check on control
val idempotentMergedWithPrior =
mergeTranscriptWithLive(
restMessages = listOf(canonicalPrior),
currentMessages = mergedWithPrior,
chronological = true,
preserveLiveIds = false,
)
assertEquals(mergedWithPrior.map { it.id }, idempotentMergedWithPrior.map { it.id })
}
@Test
fun sessionStartMarkerRemainsBeforeOldUnresolvedUserToolReviewBlockWhenNewerUserConfirmsAsRestS0() {
val sessionStart =
ChatMessage(
id = "system-session-start",
role = MessageRole.SYSTEM,
content = "Session created",
timestamp = 100L,
)
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "unconfirmed first prompt",
timestamp = 200L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"done"}""",
toolName = "terminal",
toolCallId = "call_1",
timestamp = 300L,
)
val sysReview =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: note",
timestamp = 400L,
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "confirmed second prompt",
timestamp = 500L,
)
val currentMessages =
listOf(
sessionStart,
liveUserOld,
liveTool,
sysReview,
liveUserNew,
)
// Newer observed USER confirms as canonical index 0 (rest-s-0)
val restConfirmedNew =
ChatMessage(
id = "rest-s-0",
role = MessageRole.USER,
content = "confirmed second prompt",
timestamp = 550L,
)
val merged =
mergeTranscriptWithLive(
restMessages = listOf(restConfirmedNew),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
val expectedIds =
listOf(
"system-session-start",
"live-user-old",
"live-tool-1",
"sys-review-1",
"live-user-new",
)
assertEquals(expectedIds, merged.map { it.id })
assertEquals("rest-s-0", merged.last().canonicalRestId)
}
@Test
fun canonicalRowsRemainServerOrderedAcrossGapsWithOldLiveBlockBeforeNextConfirmedUserAcrossSkewedTimestamps() {
// Skewed/inverted timestamps across rows
val canonicalPrior =
ChatMessage(
id = "rest-s-2",
role = MessageRole.USER,
content = "prior prompt",
timestamp = 8_000L, // skewed higher timestamp than later turns
)
val liveUserOld =
ChatMessage(
id = "live-user-old",
role = MessageRole.USER,
content = "unresolved old prompt",
timestamp = 2_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"ok"}""",
toolName = "terminal",
toolCallId = "call_skew",
timestamp = 2_000L, // equal timestamp
)
val sysReview =
ChatMessage(
id = "sys-review-1",
role = MessageRole.SYSTEM,
content = "💾 Self-improvement review: updated",
timestamp = 1_000L, // earlier timestamp
)
val liveUserNew =
ChatMessage(
id = "live-user-new",
role = MessageRole.USER,
content = "next user prompt",
timestamp = 3_000L,
)
val currentMessages =
listOf(
canonicalPrior,
liveUserOld,
liveTool,
sysReview,
liveUserNew,
)
// Gap in canonical sequence: rest-s-2 then rest-s-9 (gap 3..8 missing/unloaded)
val canonicalFollow =
ChatMessage(
id = "rest-s-9",
role = MessageRole.USER,
content = "next user prompt",
timestamp = 2_500L, // skewed timestamp compared to rest-s-2
)
val restMessages = listOf(canonicalPrior, canonicalFollow)
val merged =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
val expectedIds =
listOf(
"rest-s-2",
"live-user-old",
"live-tool-1",
"sys-review-1",
"live-user-new",
)
assertEquals(expectedIds, merged.map { it.id })
// Repeated merge must remain identical
val repeatedMerge =
mergeTranscriptWithLive(
restMessages = restMessages,
currentMessages = merged,
chronological = true,
preserveLiveIds = false,
)
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
assertEquals(merged, repeatedMerge)
}
@Test
fun assistantConfirmationDoesNotPullUnresolvedUserOutOfPendingTail() {
val unresolvedUser =
ChatMessage(
id = "live-user-unresolved",
role = MessageRole.USER,
content = "prompt not matching rest",
timestamp = 1_000L,
)
val liveTool =
ChatMessage(
id = "live-tool-1",
role = MessageRole.TOOL,
content = """{"result":"in progress"}""",
toolName = "terminal",
toolCallId = "call_assistant_test",
timestamp = 1_100L,
)
val liveAssistant =
ChatMessage(
id = "live-assistant-1",
role = MessageRole.ASSISTANT,
content = "Here is the response",
timestamp = 1_200L,
)
val currentMessages =
listOf(
unresolvedUser,
liveTool,
liveAssistant,
)
// REST confirms only the ASSISTANT message as rest-s-4 (e.g. user was dropped or mismatched)
val restConfirmedAssistant =
ChatMessage(
id = "rest-s-4",
role = MessageRole.ASSISTANT,
content = "Here is the response",
timestamp = 1_200L,
)
val merged =
mergeTranscriptWithLive(
restMessages = listOf(restConfirmedAssistant),
currentMessages = currentMessages,
chronological = true,
preserveLiveIds = false,
)
// Assistant confirmation alone must not anchor/pull the unresolved user or tool
// before rest-s-4. The confirmed assistant appears first, unresolved user/tool remain in pending tail.
val expectedIds =
listOf(
"rest-s-4",
"live-user-unresolved",
"live-tool-1",
)
assertEquals(expectedIds, merged.map { it.id })
}
}
@@ -0,0 +1,253 @@
package com.m57.hermescontrol.ui.chat
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.util.UUID
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class ChatPastedImageStoreTest {
@get:Rule val temp = TemporaryFolder()
@Test
fun startupSweepDeletesOnlyPastedImagesOlderThanSevenDays() {
val cacheDir = temp.newFolder("cache")
val directory = cacheDir.resolve("pasted_images").apply { mkdir() }
val now = 2_000_000_000_000L
val cutoff = now - TimeUnit.DAYS.toMillis(7)
val stale = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), directory, "png")!!
assertTrue(stale.setLastModified(cutoff - 1000))
val boundary = directory.resolve("boundary.png").apply { writeText("keep") }
assertTrue(boundary.setLastModified(cutoff))
val recent = directory.resolve("recent.png").apply { writeText("keep") }
assertTrue(recent.setLastModified(now))
val future = directory.resolve("future.png").apply { writeText("keep") }
assertTrue(future.setLastModified(now + 1000))
val unrelated = cacheDir.resolve("unrelated.png").apply { writeText("keep") }
assertTrue(unrelated.setLastModified(cutoff - 1000))
val nested =
directory
.resolve("nested")
.apply { mkdir() }
.resolve("old.png")
.apply { writeText("keep") }
assertTrue(nested.setLastModified(cutoff - 1000))
cleanStalePastedImages(cacheDir, now)
cleanStalePastedImages(cacheDir, now)
assertFalse(stale.exists())
listOf(boundary, recent, future, unrelated, nested).forEach { assertEquals("keep", it.readText()) }
}
@Test
fun startupSweepDoesNotCreateAMissingCacheDirectory() {
val cacheDir = temp.newFolder("empty-cache")
cleanStalePastedImages(cacheDir)
assertTrue(cacheDir.listFiles()!!.isEmpty())
}
@Test
fun startupSweepPreservesAnImportCreatedAfterTheStartupSnapshot() {
val cacheDir = temp.newFolder("live-cache")
val directory = cacheDir.resolve("pasted_images")
val startupTime = System.currentTimeMillis()
val imported = stagePastedImage(ByteArrayInputStream(byteArrayOf(1, 2)), directory, "png")!!
cleanStalePastedImages(cacheDir, startupTime)
assertArrayEquals(byteArrayOf(1, 2), imported.readBytes())
}
@Test
fun exactLimitIsAcceptedWithItsActualByteCount() {
val dir = temp.newFolder("exact")
val bytes = byteArrayOf(1, 2, 3, 4)
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "png", maxBytes = 4)
assertArrayEquals(bytes, file!!.readBytes())
assertEquals(4L, file.length())
assertTrue(file.name.endsWith(".png"))
assertEquals(listOf(file), dir.listFiles()!!.toList())
}
@Test
fun callerLimitAbovePerFileCapStillRejectsAfterTenMiBAndCleansUp() {
val dir = temp.newFolder("per-file-cap")
val input = ByteArrayInputStream(ByteArray(10 * 1024 * 1024 + 1))
try {
stagePastedImage(input, dir, "png", maxBytes = 20L * 1024 * 1024)
fail("Expected the per-file limit")
} catch (_: PastedImageTooLargeException) {
assertTrue(dir.listFiles()!!.isEmpty())
}
}
@Test
fun unknownSizeIsBoundedByTheActualStreamAndReadsAtMostLimitPlusOne() {
val dir = temp.newFolder("oversize")
val input =
object : ByteArrayInputStream(ByteArray(100)) {
var consumed = 0
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int = super.read(b, off, len).also { if (it > 0) consumed += it }
}
try {
stagePastedImage(input, dir, "png", maxBytes = 4)
fail("Expected size limit rejection")
} catch (_: PastedImageTooLargeException) {
// The exact limit is accepted by the preceding test; only the extra byte rejects.
}
assertEquals(5, input.consumed)
assertTrue(dir.listFiles()!!.isEmpty())
}
@Test
fun emptyStreamIsRejectedAndLeavesNoCacheFile() {
val dir = temp.newFolder("empty")
assertNull(stagePastedImage(ByteArrayInputStream(byteArrayOf()), dir, "webp", maxBytes = 4))
assertTrue(dir.listFiles()!!.isEmpty())
}
@Test
fun streamFailureDeletesPartialFileAndClosesSource() {
val dir = temp.newFolder("broken")
val input =
object : InputStream() {
var reads = 0
var closed = false
override fun read(): Int = if (reads++ == 0) 7 else throw IOException("provider failed")
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int =
if (reads++ == 0) {
b[off] = 7
1
} else {
throw IOException("provider failed")
}
override fun close() {
closed = true
}
}
try {
stagePastedImage(input, dir, "png", maxBytes = 4)
fail("Expected stream failure")
} catch (_: IOException) {
assertTrue(input.closed)
assertTrue(dir.listFiles()!!.isEmpty())
}
}
@Test
fun existingNameCollisionNeverTruncatesOrDeletesExistingFile() {
val dir = temp.newFolder("collision")
val id = UUID.randomUUID().toString()
val sentinel = dir.resolve("$id.png")
sentinel.writeText("keep me")
try {
stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png", newId = { id })
fail("Expected exclusive-create failure")
} catch (_: IOException) {
assertEquals("keep me", sentinel.readText())
assertEquals(listOf(sentinel), dir.listFiles()!!.toList())
}
}
@Test
fun simultaneousImportsUseDistinctFilesAndKeepTheirOwnBytes() {
val dir = temp.newFolder("concurrent")
val executor = Executors.newFixedThreadPool(8)
try {
val tasks =
(1..16).map { n ->
Callable {
val bytes = ByteArray(n) { n.toByte() }
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "jpeg", maxBytes = 64)!!
file to bytes
}
}
val results = executor.invokeAll(tasks).map { it.get(10, TimeUnit.SECONDS) }
assertEquals(16, results.map { it.first.name }.toSet().size)
results.forEach { (file, bytes) -> assertArrayEquals(bytes, file.readBytes()) }
assertEquals(16, dir.listFiles()!!.size)
} finally {
executor.shutdownNow()
}
}
@Test
fun cancellationCheckDeletesPartialFileAndPropagatesCancellation() {
val dir = temp.newFolder("cancel")
var checks = 0
val input = ByteArrayInputStream(ByteArray(100))
try {
stagePastedImage(
input,
dir,
"png",
maxBytes = 100,
checkActive = {
if (++checks == 2) throw CancellationException("cancelled")
},
)
fail("Expected cancellation")
} catch (_: CancellationException) {
assertTrue(dir.listFiles()!!.isEmpty())
assertTrue(checks >= 2)
}
}
@Test
fun aDiscardedResultDeletesOnlyItsOwnedFile() {
val dir = temp.newFolder("discard")
val file = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png")!!
val unrelated = dir.resolve("unrelated.png").apply { writeText("keep") }
val result =
PastedImage(
attachment =
com.m57.hermescontrol.data.model.Attachment(
uri = "content://example/file",
name = file.name,
mimeType = "image/png",
size = 1,
),
file = file,
)
result.discard()
assertFalse(file.exists())
assertTrue(unrelated.exists())
assertEquals("keep", unrelated.readText())
}
}
@@ -49,6 +49,29 @@ class ChatSendStoreTest {
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun rowIdOnHistoryPageConfirmsAcceptedReceiptWithoutMergeMatch() {
val receipts =
listOf(
pending("send-1", "A").copy(userRowId = 334755L),
pending("send-2", "B").copy(userRowId = 999L),
pending("send-3", "C"),
)
val confirmed = pendingSendIdsConfirmedByRowIds(setOf(334755L), receipts)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun rowIdReceiptIsNotDowngradedButIdlessReceiptIs() {
val withRow = pending("send-1", "A").copy(userRowId = 334755L)
val idless = pending("send-2", "B")
assertTrue(!canDemoteAcceptedReceipt(withRow))
assertTrue(canDemoteAcceptedReceipt(idless))
}
@Test
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
val existing =
@@ -360,6 +360,14 @@ class ChatViewModelTest {
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
// Typed redirects bypass send(); keep this fixture off the real singleton transport.
every { HermesWsClient.sendRedirect(any(), any(), any()) } answers {
reqCount++
val id = "req-redirect-$reqCount"
sentRequestMethods += WsMethods.SESSION_REDIRECT to id
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
@@ -396,6 +404,73 @@ class ChatViewModelTest {
unmockkAll()
}
@Test
fun pastedImages_areAddedOnlyToCapturedReadySessionWithoutSubmitting() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
val messagesBefore = viewModel.uiState.value.messages
assertTrue(viewModel.addPastedAttachments(target, listOf(image)))
runCurrent()
assertEquals(listOf(image), viewModel.uiState.value.pendingAttachments)
assertEquals(messagesBefore, viewModel.uiState.value.messages)
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
}
@Test
fun pastedImages_rejectLateCompletionAfterSwitchAwayAndBack() =
runTest(testDispatcher) {
val (viewModel, sessionId) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
viewModel.switchSession("other-paste-test-session")
viewModel.switchSession(sessionId)
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
assertFalse(viewModel.addPastedAttachments(target, listOf(image)))
assertTrue(
viewModel.uiState.value.pendingAttachments
.isEmpty(),
)
}
@Test
fun pastedImages_rejectChangedServerAndProfilesAndGeneration() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
for (stale in listOf(
target.copy(baseUrl = "http://other-paste-test.local/"),
target.copy(connectionProfileId = "other-connection"),
target.copy(agentProfileId = "other-agent"),
target.copy(generation = target.generation + 1),
)) {
assertFalse(viewModel.addPastedAttachments(stale, listOf(image)))
}
assertTrue(
viewModel.uiState.value.pendingAttachments
.isEmpty(),
)
}
@Test
fun pastedImages_cannotCaptureTargetBeforeReady() =
runTest(testDispatcher) {
val viewModel = createViewModel()
advanceUntilIdle()
assertNull(viewModel.captureAttachmentTarget())
}
@Test
fun pastedImages_rejectDisconnectedSessionBeforeCombinedUiStateCatchesUp() =
runTest(testDispatcher) {
val (viewModel, _) = createViewModelWithSession()
val target = checkNotNull(viewModel.captureAttachmentTarget())
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
assertNull(viewModel.captureAttachmentTarget())
assertFalse(viewModel.addPastedAttachments(target, emptyList()))
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Create a ViewModel with the fake repo injected directly. */
@@ -498,6 +573,32 @@ class ChatViewModelTest {
assertEquals("op-1218", operation?.opId)
}
@Test
fun sessionResume_sendsDesktopSourceLikeSessionCreate() =
runTest {
// #1450: session.create declared source="desktop" but session.resume omitted it, so
// the gateway resolved the resumed runtime from its host env ("tui") and staged a
// bogus surface switch that also unloaded the desktop_ui toolset.
val viewModel = createViewModel()
advanceUntilIdle()
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
every { HermesWsClient.send(any(), any(), any()) } answers {
val id = "req-${captured.size + 1}"
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
arg<((String) -> Unit)?>(2)?.invoke(id)
id
}
viewModel.switchSession("stored-session")
advanceUntilIdle()
val resumeSent = captured.firstOrNull { it.first == WsMethods.SESSION_RESUME }
assertNotNull("session.resume should be dispatched", resumeSent)
assertEquals(JsonPrimitive("stored-session"), resumeSent!!.second["session_id"])
assertEquals(JsonPrimitive("desktop"), resumeSent.second["source"])
}
@Test
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
runTest {
@@ -627,7 +728,7 @@ class ChatViewModelTest {
val (viewModel, sessionId) = createViewModelWithSession()
every {
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true)
} returns
CompletableDeferred(
mapOf("type" to "exec", "output" to "**Available Commands:**\n\u2022 `/status`\n\u2022 `/new`"),
@@ -1287,7 +1388,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/status")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1298,7 +1399,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/sessions")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1309,7 +1410,7 @@ class ChatViewModelTest {
viewModel.sendMessage("/stats")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
}
@Test
@@ -1635,6 +1736,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-from-notification"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -5088,6 +5190,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -5943,6 +6046,7 @@ class ChatViewModelTest {
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"source" to JsonPrimitive("desktop"),
"omit_messages" to JsonPrimitive(true),
),
),
@@ -10636,7 +10740,8 @@ class ChatViewModelTest {
.last()
.content,
)
assertEquals(PendingSendState.UNKNOWN, store.all().single().state)
// #1427: a mismatched history row cannot retire the gateway-confirmed receipt.
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
assertTrue(vm.uiState.value.isSessionReady)
assertTrue(vm.sendMessage("probe-second"))
advanceUntilIdle()
@@ -10648,9 +10753,10 @@ class ChatViewModelTest {
assertNull(vm.uiState.value.errorMessage)
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "probe-first" }.state)
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
// The accepted row 10 remains visible separately from the unrelated history row 20.
assertEquals(
1,
2,
vm.transcriptState.value.messages
.count { it.content == "probe-first" },
)
@@ -478,4 +478,27 @@ class FullBleedTurnsTest {
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
}
@Test
fun `react_to_message tool rows are hidden but other tools stay`() {
val messages =
listOf(
msg("u1", MessageRole.USER),
msg("react", MessageRole.TOOL).copy(toolName = "react_to_message"),
msg("term", MessageRole.TOOL).copy(toolName = "terminal"),
msg("a1", MessageRole.ASSISTANT),
)
val agent = groupIntoTurns(messages).filterIsInstance<ChatTurn.Agent>().single()
assertEquals(
listOf("term", "a1"),
agent.entries.map {
when (it) {
is AgentEntry.Prose -> it.message.id
is AgentEntry.ToolRow -> it.message.id
is AgentEntry.SystemEvent -> it.message.id
}
},
)
assertEquals(emptyList<SearchMatch>(), ChatSearchController().findMatches(messages.take(2), "react").matches)
}
}
@@ -0,0 +1,233 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** #1433: gateway notices are per-session transcript rows that keep their position. */
class GatewayNoticeTest {
private val compactedText = "✓ Context compaction complete — continuing turn..."
private fun status(
kind: String,
text: String?,
sessionId: String? = "s1",
) = WsEvent.StatusUpdate(
status = null,
data = mapOf("kind" to kind, "text" to text),
sessionId = sessionId,
)
private fun reduce(
state: ChatUiState,
event: WsEvent,
streaming: StreamingState = StreamingState(),
) = ChatWsEventReducer.reduce(state, streaming, event, currentSessionId = "s1")
@Test
fun sentinelMatchesEveryGatewayInterruptShape() {
listOf(
"[the reply degenerated into a repetition loop and was interrupted]",
"Operation interrupted.",
"Operation interrupted: waiting for the provider to recover (cycle 1/3).",
"Operation interrupted: retrying API call after error (retry 2/5).",
"Operation interrupted: handling API error (RateLimitError: slow down).",
"Operation interrupted: retrying empty response from model (retry 1/2).",
"Operation interrupted during retry (timeout, attempt 1/3).",
" Operation interrupted.\n",
).forEach { assertTrue(it, GatewayNotice.isInterruptSentinel(it)) }
listOf(
"Operation interrupted",
"Operation interrupted. Here is what I found so far",
"The operation was interrupted.",
"Operation interrupted: first line.\nsecond line.",
).forEach { assertFalse(it, GatewayNotice.isInterruptSentinel(it)) }
}
@Test
fun compactedNoticeBecomesPersistedSystemRowAndClearsTailState() {
val user = ChatMessage(id = "u", role = MessageRole.USER, content = "go")
val state =
ChatUiState(
currentSessionId = "s1",
messages = listOf(user),
isCompressing = true,
compressionStatus = "🗜️ Compacting context",
)
val result = reduce(state, status("compacted", compactedText))
assertFalse(result.state.isCompressing)
assertNull(result.state.compressionStatus)
val notice = result.state.messages.last()
assertEquals(MessageRole.SYSTEM, notice.role)
assertEquals(compactedText, notice.content)
assertTrue(result.effects.any { it is ReducerEffect.PersistMessage && it.message.id == notice.id })
}
@Test
fun replayedNoticeInSameTurnDoesNotStack() {
val state =
ChatUiState(
currentSessionId = "s1",
messages = listOf(ChatMessage(role = MessageRole.USER, content = "go")),
)
val once = reduce(state, status("compacted", compactedText)).state
val twice = reduce(once, status("compacted", compactedText)).state
assertEquals(1, twice.messages.count { it.role == MessageRole.SYSTEM })
val nextTurn = twice.copy(messages = twice.messages + ChatMessage(role = MessageRole.USER, content = "again"))
val again = reduce(nextTurn, status("compacted", compactedText)).state
assertEquals(2, again.messages.count { it.role == MessageRole.SYSTEM })
}
@Test
fun otherSessionsStatusUpdatesAreIgnored() {
val state = ChatUiState(currentSessionId = "s1")
listOf("compacting", "compacted", "goal", "warn").forEach { kind ->
val result = reduce(state, status(kind, "text for $kind", sessionId = "other"))
assertEquals(kind, state, result.state)
}
}
@Test
fun durableKindsAppendAndTransientKindsDoNot() {
val state = ChatUiState(currentSessionId = "s1")
listOf("goal", "loop", "warn", "fallback").forEach { kind ->
assertEquals(kind, 1, reduce(state, status(kind, "✓ $kind")).state.messages.size)
}
listOf("status", "lifecycle", "process", "heartbeat", "ready").forEach { kind ->
assertTrue(kind, reduce(state, status(kind, "x")).state.messages.isEmpty())
}
}
@Test
fun repetitionLoopCompletionDropsLoopedPartialAndAppendsNotice() {
val sealed = ChatMessage(id = "looped", role = MessageRole.ASSISTANT, content = "again again again")
val state = ChatUiState(currentSessionId = "s1", messages = listOf(sealed), isAgentTyping = true)
val event =
WsEvent.MessageComplete(
text = GatewayNotice.REPETITION_LOOP_INTERRUPTED,
sessionId = "s1",
rawPayload = mapOf("status" to "interrupted"),
)
val result = reduce(state, event, StreamingState(interruptedMessage = sealed))
assertEquals(listOf(MessageRole.SYSTEM), result.state.messages.map { it.role })
assertEquals(
GatewayNotice.REPETITION_LOOP_INTERRUPTED,
result.state.messages
.single()
.content,
)
assertFalse(result.state.isAgentTyping)
assertTrue(result.effects.contains(ReducerEffect.DeleteLocalMessage("looped")))
}
@Test
fun operationInterruptedCompletionKeepsRealPartial() {
val stream = ChatMessage(id = "p", role = MessageRole.ASSISTANT, content = "half an answer", isStreaming = true)
val state = ChatUiState(currentSessionId = "s1")
val event = WsEvent.MessageComplete(text = "Operation interrupted.", sessionId = "s1")
val result = reduce(state, event, StreamingState(streamingMessage = stream))
assertEquals(
listOf("p", null),
result.state.messages.map {
if (it.role ==
MessageRole.SYSTEM
) {
null
} else {
it.id
}
},
)
assertFalse(
result.state.messages
.first()
.isStreaming,
)
assertEquals(
"Operation interrupted.",
result.state.messages
.last()
.content,
)
}
@Test
fun persistedSentinelRowMapsToSystemNoticeAndMergesWithLiveCopy() {
val rows =
listOf(
SessionMessage(id = 1, role = "user", content = JsonPrimitive("go")),
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("Operation interrupted.")),
SessionMessage(id = 3, role = "user", content = JsonPrimitive("next")),
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("done")),
)
val page = mapServerMessages("s", rows, 0, true, emptyList())
assertEquals(MessageRole.SYSTEM, page[1].role)
// Live view after the interrupted turn: the notice sits between the two turns.
val live =
listOf(
ChatMessage(id = "u1", role = MessageRole.USER, content = "go"),
ChatMessage(id = "n", role = MessageRole.SYSTEM, content = "Operation interrupted."),
ChatMessage(id = "u2", role = MessageRole.USER, content = "next"),
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "done"),
)
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
assertEquals(
listOf("go", "Operation interrupted.", "next", "done"),
merged.map { it.content },
)
}
@Test
fun liveOnlyNoticeStaysInPlaceAcrossLaterTurnsAndRefresh() {
val page =
mapServerMessages(
"s",
listOf(
SessionMessage(id = 1, role = "user", content = JsonPrimitive("first")),
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("one")),
SessionMessage(id = 3, role = "user", content = JsonPrimitive("second")),
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("two")),
),
0,
true,
emptyList(),
)
val live =
listOf(
ChatMessage(id = "u1", role = MessageRole.USER, content = "first"),
ChatMessage(id = "a1", role = MessageRole.ASSISTANT, content = "one"),
ChatMessage(id = "c", role = MessageRole.SYSTEM, content = compactedText),
ChatMessage(id = "u2", role = MessageRole.USER, content = "second"),
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "two"),
)
val once = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
val twice = mergeTranscriptWithLive(page, once, preserveLiveIds = true)
val expected = listOf("first", "one", compactedText, "second", "two")
assertEquals(expected, once.map { it.content })
assertEquals(expected, twice.map { it.content })
}
@Test
fun roomRoundTripNormalizesLegacyAssistantSentinel() {
val legacy = ChatMessage(id = "x", role = MessageRole.ASSISTANT, content = "Operation interrupted.")
val restored = legacy.toEntity("s").toUiModel()
assertEquals(MessageRole.SYSTEM, restored.role)
val prose = ChatMessage(id = "y", role = MessageRole.ASSISTANT, content = "fine")
assertEquals(MessageRole.ASSISTANT, prose.toEntity("s").toUiModel().role)
}
}
@@ -161,7 +161,7 @@ class SlashCommandDispatchRpcTest {
// can end up holding session.usage instead of command.dispatch (CI run
// 31137581335 on 4645a2b: slash-focus flake).
// Specific per-test request() stubs registered later take precedence.
every { HermesWsClient.request(any(), any(), any()) } answers {
every { HermesWsClient.request(any(), any(), any(), any()) } answers {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>(Unit)
}
@@ -194,7 +194,7 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `non-hardcoded slash command forwards name arg session_id to COMMAND_DISPATCH`() =
fun `non-hardcoded slash command forwards command and session_id to SLASH_EXEC`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
@@ -202,7 +202,7 @@ class SlashCommandDispatchRpcTest {
val paramsCalls = mutableListOf<Map<String, Any>>()
var captured = false
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
captured = true
CompletableDeferred<Any?>(Unit)
@@ -216,14 +216,67 @@ class SlashCommandDispatchRpcTest {
// Captures hold ALL requests — the create flow's session.usage /
// context_breakdown can land after the dispatch one (capture race,
// CI 2026-08-06 + 2026-08-07). Find the record, don't trust "last".
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals(JsonPrimitive("help"), params["name"])
assertEquals(JsonPrimitive(""), params["arg"])
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
val params = paramsCalls[execIndex]
assertEquals(JsonPrimitive("help"), params["command"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
fun `usage renders warning and suppresses intermediate error events`() =
runTest {
val (vm, _) = createViewModelWithSession()
every { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) } returns
CompletableDeferred<Any?>(mapOf("output" to "Session Token Usage", "warning" to "estimated"))
vm.sendMessage("/usage")
advanceUntilIdle()
verify(exactly = 1) { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
verify(exactly = 0) { HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any(), any()) }
assertEquals(
"warning: estimated\nSession Token Usage",
vm.uiState.value.messages
.last()
.content,
)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `slash failure falls back with name arg and suppresses both error events`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val calls = mutableListOf<String>()
val params = mutableListOf<Map<String, Any>>()
every { HermesWsClient.request(capture(calls), capture(params), any(), true) } answers {
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("worker unavailable"))
}
} else {
CompletableDeferred<Any?>(mapOf("type" to "plugin", "output" to "plugin result"))
}
}
vm.sendMessage("/custom some args")
advanceUntilIdle()
assertEquals(listOf(WsMethods.SLASH_EXEC, WsMethods.COMMAND_DISPATCH), calls)
assertEquals(JsonPrimitive("custom"), params[1]["name"])
assertEquals(JsonPrimitive("some args"), params[1]["arg"])
assertEquals(JsonPrimitive(sessionId), params[1]["session_id"])
assertEquals(
"plugin result",
vm.uiState.value.messages
.last()
.content,
)
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `slash queue submits the arg with queued=true instead of command dispatch`() =
runTest {
@@ -235,9 +288,15 @@ class SlashCommandDispatchRpcTest {
val sentText = slot<String>()
val sentQueued = slot<Boolean>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
every {
HermesWsClient.sendMessage(
@@ -372,9 +431,15 @@ class SlashCommandDispatchRpcTest {
val rpcMethods = mutableListOf<String>()
var sendCalls = 0
every {
HermesWsClient.request(capture(rpcMethods), any(), any())
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
sendCalls++
@@ -416,12 +481,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `non-registry-miss backend error is surfaced directly (no fallback)`() =
fun `actionable fallback error is surfaced`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val d = CompletableDeferred<Any?>()
d.completeExceptionally(
@@ -442,14 +507,14 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `registry-miss 4018 on command dispatch falls back to slash_exec and surfaces output`() =
fun `slash exec success surfaces output without command dispatch`() =
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
// Read the method from THIS invocation, not from the shared
// capture list: a concurrent request() landing between capture
@@ -474,16 +539,14 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// The fallback must have hit slash.exec with the full command string.
// Successful slash.exec must not need a fallback.
// Captures hold ALL requests — find the records instead of trusting
// "last" (create-flow session.usage can land after; capture race).
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0)
assertTrue("dispatch must precede fallback", dispatchIndex < execIndex)
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
val params = paramsCalls[execIndex]
assertEquals(JsonPrimitive("/status"), params["command"])
assertEquals(JsonPrimitive("status"), params["command"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
// And the slash.exec output must be surfaced to the user.
@@ -500,9 +563,15 @@ class SlashCommandDispatchRpcTest {
val rpcMethods = mutableListOf<String>()
every {
HermesWsClient.request(capture(rpcMethods), any(), any())
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /redraw is TUI-only (issue #574) — hidden from suggestions but a
@@ -533,12 +602,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `slash_exec double-fault surfaces the secondary error`() =
fun `fallback registry miss preserves original slash exec error`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -561,7 +630,7 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// Both RPCs fail -> the secondary slash.exec error must surface.
// A registry miss must not mask the original worker failure.
val last =
vm.uiState.value.messages
.lastOrNull()
@@ -569,12 +638,12 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `slash_exec blank output appends no assistant message`() =
fun `slash_exec blank output reports no output like desktop`() =
runTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -595,12 +664,10 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/status")
advanceUntilIdle()
// The user's "/status" message is added, but blank slash.exec output
// must NOT append an assistant bubble — so the last message is still
// the user's own command, and only one message was added.
assertEquals(before + 1, vm.uiState.value.messages.size)
// Desktop reports an explicit no-output notice rather than silently succeeding.
assertEquals(before + 2, vm.uiState.value.messages.size)
assertEquals(
"/status",
"/status: no output",
vm.uiState.value.messages
.lastOrNull()
?.content,
@@ -615,9 +682,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /init generates-or-updates AGENTS.md (backend command.dispatch
@@ -643,9 +716,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
vm.sendMessage("/init extra context here")
@@ -668,9 +747,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
// /focus is the display-only focus view (backend command.dispatch
@@ -696,9 +781,15 @@ class SlashCommandDispatchRpcTest {
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
CompletableDeferred<Any?>(Unit)
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
CompletableDeferred<Any?>().apply {
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
} else {
CompletableDeferred<Any?>(Unit)
}
}
vm.sendMessage("/focus on")
@@ -714,7 +805,7 @@ class SlashCommandDispatchRpcTest {
}
@Test
fun `command dispatch type=send submits the returned prompt (init)`() =
fun `slash exec type=send submits the returned prompt (init)`() =
runTest {
val (vm, _) = createViewModelWithSession()
@@ -722,12 +813,12 @@ class SlashCommandDispatchRpcTest {
val paramsCalls = mutableListOf<Map<String, Any>>()
val sentText = slot<String>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
} answers {
val d = CompletableDeferred<Any?>()
// Read the method from THIS invocation, not from the shared
// capture list (see the 4018 test above).
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
// 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 {
@@ -766,7 +857,7 @@ class SlashCommandDispatchRpcTest {
val (vm, _) = createViewModelWithSession()
every {
HermesWsClient.request(any(), any(), any())
HermesWsClient.request(any(), any(), any(), any())
} answers {
val m = arg<String>(0)
val d = CompletableDeferred<Any?>()
@@ -774,7 +865,7 @@ class SlashCommandDispatchRpcTest {
// Backend /focus returns type:"exec" with a notice line.
d.complete(mapOf("type" to "exec", "output" to "Focus view: ON (tool progress pinned off)"))
} else {
d.complete(mapOf("session_id" to "session-xyz"))
d.completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
}
d
}
@@ -0,0 +1,355 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
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
/**
* Regression tests for user image hydration:
* - Direct unit tests for [userImageAttachments]
* - Integration via [mapServerMessages] with USER turns
*/
class UserImageHydrationTest {
@Test
fun restHydrationEnrichesCachedUserWithoutReplacingLocalAttachments() {
val rest =
mapServerMessages(
sessionId = "session-1",
messages =
listOf(
SessionMessage(id = 42, role = "user", content = JsonPrimitive("caption\n@image:/image.png")),
),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { "https://gateway.test/image.png" },
).single()
val cached =
rest.copy(
attachments = null,
content = "[{\"image_url\":\"data:image/png;base64,old\"}]",
isHistoricalCache = true,
)
val hydrated = mergeTranscriptWithLive(listOf(rest), listOf(cached)).single()
assertEquals(rest.attachments, hydrated.attachments)
assertEquals(rest.content, hydrated.content)
assertEquals(cached.id, hydrated.id)
val localAttachments =
listOf(
com.m57.hermescontrol.data.model
.Attachment("content://local/image", "image.png", "image/png"),
)
val local = rest.copy(attachments = localAttachments)
assertEquals(localAttachments, mergeTranscriptWithLive(listOf(rest), listOf(local)).single().attachments)
}
@Test
fun roomRoundTripKeepsReferencesForGatewayReconstruction() {
val content = "caption\n@image:/image.png"
val message = ChatMessage(id = "cached", role = MessageRole.USER, content = content)
val restored = message.toEntity("session-1").toUiModel()
assertEquals(content, restored.content)
val attachments = userImageAttachments(restored.content) { "https://current-gateway.test$it" }
assertEquals("https://current-gateway.test/image.png", attachments.single().gatewayUrl)
}
@Test
fun userImageAttachments_recognizesTrimmedWholeLineImageDirectiveWithAbsolutePaths() {
val content =
"""
Here is my photo
@image:/absolute/path/to/cat.png
And another one
@image:/absolute/path/to/dog.jpg
""".trimIndent()
val attachments =
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
assertEquals(2, attachments.size)
val first = attachments[0]
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.uri)
assertEquals("cat.png", first.name)
assertEquals("image/png", first.mimeType)
assertEquals(0L, first.size)
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.gatewayUrl)
assertEquals(AttachmentSource.GATEWAY, first.source)
assertTrue(first.isImage)
val second = attachments[1]
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/dog.jpg", second.uri)
assertEquals("dog.jpg", second.name)
assertEquals("image/jpeg", second.mimeType)
assertEquals(AttachmentSource.GATEWAY, second.source)
assertTrue(second.isImage)
}
@Test
fun userImageAttachments_allowsSpacesInAbsolutePaths() {
val content = "@image:/Users/test user/My Documents/holiday photo 2026.png"
val attachments =
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
assertEquals(1, attachments.size)
val attachment = attachments.single()
assertEquals(
"https://gateway.local/api/files/download?path=/Users/test user/My Documents/holiday photo 2026.png",
attachment.uri,
)
assertEquals("holiday photo 2026.png", attachment.name)
assertEquals("image/png", attachment.mimeType)
assertEquals(AttachmentSource.GATEWAY, attachment.source)
}
@Test
fun userImageAttachments_deduplicatesRepeatPathsKeepingFirst() {
val content =
"""
First mention
@image:/images/duplicated.png
Second mention
@image:/images/duplicated.png
Third mention with whitespace padding
@image:/images/duplicated.png
Different image
@image:/images/unique.png
""".trimIndent()
val attachments = userImageAttachments(content) { path -> "https://gateway.local/file?p=$path" }
assertEquals(2, attachments.size)
assertEquals("https://gateway.local/file?p=/images/duplicated.png", attachments[0].uri)
assertEquals("duplicated.png", attachments[0].name)
assertEquals("https://gateway.local/file?p=/images/unique.png", attachments[1].uri)
assertEquals("unique.png", attachments[1].name)
}
@Test
fun userImageAttachments_ignoresInlineProseEmptyAndRelativeReferences() {
val content =
"""
Do not match inline prose such as check out @image:/absolute/path/foo.png in middle of text
Do not match trailing inline text @image:/path/bar.png extra
Do not match relative path @image:relative/path/image.png
@image:
@image:
@image:relative/foo.jpg
@image:./local/foo.png
@image:../parent/foo.png
@image:not-an-absolute-path.jpg
Valid absolute path at start of line:
@image:/valid/match.png
""".trimIndent()
val attachments = userImageAttachments(content) { path -> "http://gateway.test$path" }
assertEquals(1, attachments.size)
assertEquals("http://gateway.test/valid/match.png", attachments.single().uri)
assertEquals("match.png", attachments.single().name)
}
@Test
fun userImageAttachments_whenCallbackReturnsNull_returnsNoAttachments() {
val content =
"""
@image:/valid/photo1.jpg
@image:/valid/photo2.png
""".trimIndent()
val attachments = userImageAttachments(content) { null }
assertTrue(attachments.isEmpty())
}
@Test
fun userImageAttachments_whenContentHasNoImageDirectives_returnsEmptyList() {
val content = "Just a regular text conversation with no images attached."
val attachments = userImageAttachments(content) { path -> "https://gw/$path" }
assertTrue(attachments.isEmpty())
}
@Test
fun mapServerMessages_withUserTurn_attachesGatewayImageFromPlainStringContent() {
val userRow =
SessionMessage(
id = 1,
role = "user",
content = JsonPrimitive("Please review this design\n@image:/data/mockups/home.png"),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gateway.local/files?p=$path" },
)
assertEquals(1, mapped.size)
val chatMessage = mapped.single()
assertEquals(MessageRole.USER, chatMessage.role)
assertTrue(chatMessage.content.contains("Please review this design"))
assertTrue(chatMessage.content.contains("@image:/data/mockups/home.png"))
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(1, attachments!!.size)
val att = attachments.single()
assertEquals("https://gateway.local/files?p=/data/mockups/home.png", att.uri)
assertEquals("home.png", att.name)
assertEquals("image/png", att.mimeType)
assertEquals(AttachmentSource.GATEWAY, att.source)
assertTrue(att.isGateway)
}
@Test
fun mapServerMessages_withUserMultipart748kPayload_preservesCaptionAndNoBase64InContent() {
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
val multipart =
buildJsonArray {
add(
buildJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", largeBase64)
}
},
)
add(
buildJsonObject {
put("type", "text")
put("text", caption)
},
)
}
val userRow =
SessionMessage(
id = 42,
role = "user",
content = multipart,
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gw.internal/download?path=$path" },
)
assertEquals(1, mapped.size)
val chatMessage = mapped.single()
// Content must preserve the caption and reference text, but contain no base64 payload
assertTrue(chatMessage.content.contains("Check out this screenshot"))
assertTrue(chatMessage.content.contains("@image:/opt/data/images/screen.png"))
assertFalse(chatMessage.content.contains("data:image/png;base64"))
assertFalse(chatMessage.content.contains("iVBORw0KGgoAAAANSUhEUgAA"))
// Attachment hydrated from @image directive
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(1, attachments!!.size)
val att = attachments.single()
assertEquals("https://gw.internal/download?path=/opt/data/images/screen.png", att.uri)
assertEquals("screen.png", att.name)
assertEquals("image/png", att.mimeType)
assertEquals(AttachmentSource.GATEWAY, att.source)
}
@Test
fun mapServerMessages_withUserTurn_deduplicatesRepeatImagePaths() {
val content =
"""
Examining these photos:
@image:/data/photos/target.jpg
@image:/data/photos/target.jpg
@image:/data/photos/target.jpg
@image:/data/photos/another.png
""".trimIndent()
val userRow =
SessionMessage(
id = 5,
role = "user",
content = JsonPrimitive(content),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { path -> "https://gw.internal$path" },
)
val chatMessage = mapped.single()
val attachments = chatMessage.attachments
assertNotNull(attachments)
assertEquals(2, attachments!!.size)
assertEquals("https://gw.internal/data/photos/target.jpg", attachments[0].uri)
assertEquals("target.jpg", attachments[0].name)
assertEquals("https://gw.internal/data/photos/another.png", attachments[1].uri)
assertEquals("another.png", attachments[1].name)
}
@Test
fun mapServerMessages_withUserTurn_whenGatewayUnavailable_preservesCaptionAndAddsNoAttachments() {
val caption = "Look at this error log\n@image:/var/log/screenshot.png"
val userRow =
SessionMessage(
id = 10,
role = "user",
content = JsonPrimitive(caption),
)
val mapped =
mapServerMessages(
sessionId = "session-1",
messages = listOf(userRow),
offset = 0,
latestPaging = true,
liveMessages = emptyList(),
mediaUrl = { null }, // Gateway unavailable
)
val chatMessage = mapped.single()
assertTrue(chatMessage.content.contains("Look at this error log"))
assertTrue(chatMessage.content.contains("@image:/var/log/screenshot.png"))
assertNull(chatMessage.attachments)
}
@Test
fun hideImageRefLines_removesOnlyPathLinesAndKeepsCaption() {
val content = "caption\n@image:/opt/data/images/a.jpg\nmore text @image:/inline.png"
assertEquals("caption\nmore text @image:/inline.png", hideImageRefLines(content))
assertEquals("plain", hideImageRefLines("plain"))
}
}
@@ -0,0 +1,342 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
import com.m57.hermescontrol.ui.chat.PastedImage
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.RandomAccessFile
@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class)
class ChatImagePasteControllerTest {
@get:Rule val temp = TemporaryFolder()
private val target = ChatAttachmentTarget("session", 1, "http://test.local", null, null)
private fun image(name: String): PastedImage {
val file = temp.newFile(name).apply { writeBytes(byteArrayOf(1, 2)) }
return PastedImage(Attachment("content://test/$name", name, "image/png", file.length()), file)
}
@Test
fun successPublishesAnOrderedBatchOnlyAfterEveryImageIsCopied() =
runTest {
val first = image("one.png")
val second = image("two.png")
val gate = CompletableDeferred<Unit>()
val committed = mutableListOf<Attachment>()
val errors = mutableListOf<Int>()
var count = 0
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
if (count++ == 0) {
first
} else {
gate.await()
second
}
},
captureTarget = { target },
commit = { _, attachments ->
committed.addAll(attachments)
true
},
onError = errors::add,
)
assertTrue(controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any()))
assertTrue(controller.isReceiving)
runCurrent()
assertTrue(committed.isEmpty())
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertFalse(controller.isReceiving)
assertEquals(listOf(first.attachment, second.attachment), committed)
assertTrue(first.file.exists() && second.file.exists())
assertTrue(errors.isEmpty())
}
@Test
fun laterUnreadableImageRollsBackTheEntireBatch() =
runTest {
val first = image("rollback.png")
var count = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (count++ == 0) first else null },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(0, commits)
assertFalse(first.file.exists())
assertEquals(listOf(R.string.chat_image_paste_failed), errors)
assertFalse(controller.isReceiving)
}
@Test
fun sessionChangeDuringCopyDiscardsImageWithoutTouchingTheNewChat() =
runTest {
val result = image("stale.png")
val gate = CompletableDeferred<Unit>()
var owner = target
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
gate.await()
result
},
captureTarget = { owner },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>()), Any())
runCurrent()
owner = target.copy(generation = 3)
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(0, commits)
assertFalse(result.file.exists())
assertTrue(errors.isEmpty())
}
@Test
fun disposalCancelsCopyAndDeletesOnlyUncommittedFiles() =
runTest {
val first = image("cancel.png")
var count = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (count++ == 0) first else awaitCancellation() },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
runCurrent()
controller.close()
coroutineContext.job.children
.toList()
.joinAll()
assertFalse(first.file.exists())
assertEquals(0, commits)
assertTrue(errors.isEmpty())
assertFalse(controller.receiveImages(listOf(mockk<Uri>()), Any()))
}
@Test
fun unreadySessionNeverOpensTheProvider() =
runTest {
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
null
},
captureTarget = { null },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
assertTrue(controller.receiveImages(listOf(mockk<Uri>()), Any()))
assertEquals(0, reads)
assertEquals(listOf(R.string.chat_session_not_ready), errors)
assertFalse(controller.isReceiving)
}
@Test
fun secondPasteWhileImportingDoesNotStartAnotherBatch() =
runTest {
val result = image("busy.png")
val gate = CompletableDeferred<Unit>()
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
gate.await()
result
},
captureTarget = { target },
commit = { _, _ -> true },
onError = errors::add,
)
controller.receiveImages(listOf(mockk<Uri>()), Any())
controller.receiveImages(listOf(mockk<Uri>()), Any())
gate.complete(Unit)
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(1, reads)
assertEquals(listOf(R.string.chat_image_paste_busy), errors)
}
@Test
fun ninthImageRejectsBeforeOpeningAnyProvider() =
runTest {
var reads = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ ->
reads++
null
},
captureTarget = { target },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
assertTrue(controller.receiveImages(List(9) { mockk<Uri>() }, Any()))
runCurrent()
assertEquals(0, reads)
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
assertFalse(controller.isReceiving)
}
@Test
fun exactBatchLimitKeepsQueuedFilesAndPassesRemainingBudget() =
runTest {
val first = image("first-large.png")
val second = image("second-large.png")
listOf(first, second).forEach {
RandomAccessFile(it.file, "rw").use { file ->
file.setLength(10L * 1024 * 1024)
}
}
val caps = mutableListOf<Long>()
val committed = mutableListOf<Attachment>()
val controller =
ChatImagePasteController(
this,
importImage = { _, cap ->
caps += cap
if (caps.size == 1) first else second
},
captureTarget = { target },
commit = { _, attachments ->
committed.addAll(attachments)
true
},
onError = { error("Unexpected error: $it") },
)
controller.receiveImages(List(2) { mockk<Uri>() }, Any())
runCurrent()
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024), caps)
assertEquals(listOf(first.attachment, second.attachment), committed)
assertTrue(first.file.exists() && second.file.exists())
}
@Test
fun exhaustedBudgetRejectsNextWithoutIoAndRollsBack() =
runTest {
val first = image("full-a.png")
val second = image("full-b.png")
listOf(first, second).forEach {
RandomAccessFile(it.file, "rw").use { file ->
file.setLength(10L * 1024 * 1024)
}
}
var reads = 0
var commits = 0
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, _ -> if (++reads == 1) first else second },
captureTarget = { target },
commit = { _, _ ->
commits++
true
},
onError = errors::add,
)
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(2, reads)
assertEquals(0, commits)
assertFalse(first.file.exists() || second.file.exists())
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
}
@Test
fun remainingBudgetOverflowReportsBatchLimitAndCleansUp() =
runTest {
val first = image("partial-a.png")
val second = image("partial-b.png")
RandomAccessFile(first.file, "rw").use { it.setLength(10L * 1024 * 1024) }
RandomAccessFile(second.file, "rw").use { it.setLength(2L * 1024 * 1024) }
val caps = mutableListOf<Long>()
val errors = mutableListOf<Int>()
val controller =
ChatImagePasteController(
this,
importImage = { _, cap ->
caps += cap
when (caps.size) {
1 -> first
2 -> second
else -> throw PastedImageTooLargeException()
}
},
captureTarget = { target },
commit = { _, _ -> error("Must not commit") },
onError = errors::add,
)
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
coroutineContext.job.children
.toList()
.joinAll()
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024, 8L * 1024 * 1024), caps)
assertFalse(first.file.exists() || second.file.exists())
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
}
}
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -22,6 +25,54 @@ class TranscriptUiStateTest {
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
private val clarify = ClarifyUi(text = "choose")
@Test
fun emptyStateDoesNotHideOtherRenderableTailContent() {
val empty = TranscriptUiState.resolve(ChatUiState(), ChatTimelineState(), StreamingState(), null, null)
assertTrue(shouldShowChatEmptyState(empty, hasReplyError = false))
val visibleStates =
listOf(
empty.copy(vaultUnlockPrompt = VaultUnlockPromptUi("unlock", "session")),
empty.copy(vaultSaveLoginPrompt = VaultSaveLoginPromptUi("save", "session")),
empty.copy(vaultCodePrompt = VaultCodePromptUi("code", "session")),
empty.copy(isCompressing = true),
empty.copy(compressionStatus = "Compressing"),
empty.copy(streamingState = StreamingState(streamingMessage = live)),
empty.copy(isAgentTyping = true),
empty.copy(isLoading = true),
empty.copy(messages = listOf(live)),
)
visibleStates.forEach { assertFalse(shouldShowChatEmptyState(it, hasReplyError = false)) }
assertFalse(shouldShowChatEmptyState(empty, hasReplyError = true))
}
@Test
fun `clarify on empty live transcript uses list rather than empty state`() {
val transcript =
TranscriptUiState.resolve(
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
ChatTimelineState(),
StreamingState(),
null,
null,
)
assertSame(clarify, transcript.clarifyRequest)
assertFalse(shouldShowChatEmptyState(transcript, hasReplyError = false))
}
@Test
fun `empty historical snapshot remains empty without displaying live clarify`() {
val transcript =
TranscriptUiState.resolve(
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
ChatTimelineState(historyMessages = emptyList()),
StreamingState(),
null,
null,
)
assertNull(transcript.clarifyRequest)
assertTrue(shouldShowChatEmptyState(transcript, hasReplyError = false))
}
@Test
fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() {
val image = Attachment("file:///private/photo", "photo.png", "image/png", 3)