From 98c417ae8420f5d4ff6243896b426e3a8c8f6529 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:37:49 +0300 Subject: [PATCH 01/18] fix(#1212): require bulk completion evidence --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 66 ++++++++++++++++++- .../ui/kanban/KanbanViewModel.kt | 16 ++++- app/src/main/res/values/strings.xml | 1 + .../ui/kanban/KanbanMutationTest.kt | 33 +++++++++- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index d64d5f75..68565cc7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -151,6 +151,7 @@ fun KanbanScreen( var isMultiSelectMode by remember { mutableStateOf(false) } var selectedTaskIds by remember { mutableStateOf(setOf()) } var showBulkMoveDialog by remember { mutableStateOf(false) } + var showBulkCompleteDialog by remember { mutableStateOf(false) } var showBulkAssignDialog by remember { mutableStateOf(false) } var showBulkDeleteDialog by remember { mutableStateOf(false) } @@ -1052,9 +1053,13 @@ fun KanbanScreen( writableMoveColumns.forEach { col -> TextButton( onClick = { - viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> - selectedTaskIds = failed - if (failed.isEmpty()) isMultiSelectMode = false + if (col.name.equals("done", ignoreCase = true)) { + showBulkCompleteDialog = true + } else { + viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } } showBulkMoveDialog = false }, @@ -1074,6 +1079,24 @@ fun KanbanScreen( ) } + if (showBulkCompleteDialog) { + BulkCompleteTasksDialog( + taskCount = selectedTaskIds.size, + onConfirm = { summary -> + viewModel.bulkMove( + taskIds = selectedTaskIds.toList(), + targetStatus = "done", + summary = summary, + ) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkCompleteDialog = false + }, + onDismiss = { showBulkCompleteDialog = false }, + ) + } + if (showBulkAssignDialog) { AlertDialog( onDismissRequest = { showBulkAssignDialog = false }, @@ -1431,6 +1454,43 @@ private fun CompleteTaskDialog( ) } +@Composable +private fun BulkCompleteTasksDialog( + taskCount: Int, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var summary by remember { mutableStateOf("") } + val trimmedSummary = summary.trim() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.kanban_bulk_complete_title, taskCount)) }, + text = { + OutlinedTextField( + value = summary, + onValueChange = { summary = it }, + label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, + placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + }, + confirmButton = { + Button( + onClick = { onConfirm(trimmedSummary) }, + enabled = trimmedSummary.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_action_complete)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + private fun KanbanTaskAction.labelRes(): Int = when (this) { KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index a148ab85..874f7ee4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -618,12 +618,26 @@ class KanbanViewModel( fun bulkMove( taskIds: List, targetStatus: String, + summary: String? = null, onComplete: ((failedIds: Set) -> Unit)? = null, ) { val board = _uiState.value.selectedBoard ?: return + val completionSummary = summary?.trim()?.takeIf { it.isNotEmpty() } + if (targetStatus.equals("done", ignoreCase = true) && completionSummary == null) { + _uiState.update { it.copy(toastMessage = "Completion summary is required") } + onComplete?.invoke(taskIds.toSet()) + return + } viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) { + val body = + BulkTasksBody( + ids = taskIds, + status = targetStatus, + summary = completionSummary, + result = completionSummary, + ) + when (val res = repository.bulkTasks(board.id, body)) { is NetworkResult.Success -> { val results = res.data.results val succeeded = results.filter { it.ok }.map { it.id }.toSet() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d6e2222..374f6406 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,6 +443,7 @@ Unassign Delete Delete %1$d selected tasks? This cannot be undone. + Complete %1$d tasks Export Board Import Board Working diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 8e9b9709..89aad88e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -510,7 +510,7 @@ class KanbanMutationTest { testDispatcher.scheduler.advanceUntilIdle() var reportedFailed: Set? = null - vm.bulkMove(listOf("t_1", "t_2"), "done") { failed -> + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks") { failed -> reportedFailed = failed } testDispatcher.scheduler.advanceUntilIdle() @@ -565,18 +565,45 @@ class KanbanMutationTest { vm.selectBoard(board) testDispatcher.scheduler.advanceUntilIdle() - vm.bulkMove(listOf("t_1", "t_2"), "done") + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks") testDispatcher.scheduler.advanceUntilIdle() coVerify { mockRepository.bulkTasks( "dev", - match { it.ids == listOf("t_1", "t_2") && it.status == "done" }, + match { + it.ids == listOf("t_1", "t_2") && + it.status == "done" && + it.summary == "Finished selected tasks" && + it.result == "Finished selected tasks" + }, ) } assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage) } + @Test + fun testBulkMoveToDoneRejectsEmptyCompletionEvidence() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList())))) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + var reportedFailed: Set? = null + vm.bulkMove(listOf("t_1", "t_2"), "done", summary = " ") { failed -> + reportedFailed = failed + } + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.bulkTasks(any(), any()) } + assertEquals(setOf("t_1", "t_2"), reportedFailed) + assertEquals("Completion summary is required", vm.uiState.value.toastMessage) + } + @Test fun testBulkArchiveTasksSuccess() = runTest(testDispatcher) { From 303019b9a5d5daeabb9362aa32638e0a17f9357b Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:45:57 +0300 Subject: [PATCH 02/18] fix(#1213): scope profile-owned REST routes (#1224) --- .../data/remote/ProfileScopeInterceptor.kt | 40 +++++-- .../remote/ProfileScopeInterceptorTest.kt | 111 ++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 72de4781..497a5a63 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -18,18 +18,16 @@ import okhttp3.Response * - If no profile is active, pass through unchanged (legacy "default" behavior). * - If the URL already carries an explicit `profile=` query, leave it * untouched โ€” explicit beats global. - * - Only the endpoint families below honor `?profile=` on the backend; - * everything else (ops, pairing, profiles themselves) is machine-global - * or self-scoped and must NOT be rewritten. Cron, sessions and plugins - * are profile-scoped on the backend (`/api/cron/jobs?profile=`, - * `/api/sessions?profile=`, plugin REST) and ARE rewritten here so - * switching profiles actually switches what the app shows. + * - Only backend-confirmed profile-aware endpoints are rewritten. Ops is + * intentionally classified route-by-route instead of matching `/api/ops` + * wholesale, so future machine-global operations stay untouched. */ object ProfileScopeInterceptor : Interceptor { private val PROFILE_SCOPED_PREFIXES = listOf( "/api/analytics", "/api/config", + "/api/credentials/pool", "/api/cron", "/api/env", "/api/gateway", @@ -48,6 +46,28 @@ object ProfileScopeInterceptor : Interceptor { "/api/skills", "/api/status", "/api/tools/toolsets", + "/api/webhooks", + ) + + /** + * Mobile-used Ops handlers that accept `?profile=` in Hermes Agent. + * + * Keep this explicit rather than adding `/api/ops`: these routes are + * profile-owned today, while the family itself is not a blanket scoping + * contract. See `hermes_cli/web_routers/ops.py` and `status.py`. + */ + private val PROFILE_SCOPED_OPS_PREFIXES = + listOf( + "/api/ops/backup", + "/api/ops/checkpoints", + "/api/ops/config-migrate", + "/api/ops/debug-share", + "/api/ops/doctor", + "/api/ops/dump", + "/api/ops/hooks", + "/api/ops/import-upload", + "/api/ops/prompt-size", + "/api/ops/security-audit", ) /** @@ -55,8 +75,14 @@ object ProfileScopeInterceptor : Interceptor { * e.g. `/api/status` or `/api/status/health`, but NOT `/api/statusXYZ` * or `/api/gatewayExtra` (Sourcery review, PR #540). */ + private fun matchesPrefix( + path: String, + prefix: String, + ): Boolean = path == prefix || path.startsWith("$prefix/") + private fun isProfileScopedPath(path: String): Boolean = - PROFILE_SCOPED_PREFIXES.any { prefix -> path == prefix || path.startsWith("$prefix/") } + PROFILE_SCOPED_PREFIXES.any { prefix -> matchesPrefix(path, prefix) } || + PROFILE_SCOPED_OPS_PREFIXES.any { prefix -> matchesPrefix(path, prefix) } override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index c97cee18..97a5a730 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -4,8 +4,10 @@ import com.m57.hermescontrol.data.local.AuthManager import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -49,6 +51,21 @@ class ProfileScopeInterceptorTest { return server.takeRequest().requestUrl!! } + private fun requestedUrl( + client: OkHttpClient, + path: String, + method: String = "GET", + ): HttpUrl { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val builder = Request.Builder().url(server.url(path)) + when (method) { + "DELETE" -> builder.delete() + else -> builder.get() + } + client.newCall(builder.build()).execute().close() + return server.takeRequest().requestUrl!! + } + @Test fun scopedEndpoint_appendsProfile() { val client = clientFor("work") @@ -131,6 +148,100 @@ class ProfileScopeInterceptorTest { } } + @Test + fun newlyScopedReads_followActiveProfileAcrossSwitches() { + val client = clientFor("alpha") + + assertEquals("alpha", requestedUrl(client, "api/webhooks").queryParameter("profile")) + AuthManager.setActiveProfileId("beta") + assertEquals("beta", requestedUrl(client, "api/credentials/pool").queryParameter("profile")) + AuthManager.setActiveProfileId("alpha") + assertEquals("alpha", requestedUrl(client, "api/ops/hooks").queryParameter("profile")) + } + + @Test + fun newlyScopedMutations_followActiveProfileAcrossSwitches() { + val client = clientFor("alpha") + + assertEquals( + "alpha", + requestedUrl(client, "api/webhooks/build-events", method = "DELETE").queryParameter("profile"), + ) + AuthManager.setActiveProfileId("beta") + assertEquals( + "beta", + requestedUrl(client, "api/credentials/pool/openai/1", method = "DELETE").queryParameter("profile"), + ) + AuthManager.setActiveProfileId("alpha") + assertEquals( + "alpha", + requestedUrl(client, "api/ops/hooks", method = "DELETE").queryParameter("profile"), + ) + } + + @Test + fun mobileUsedOpsRoutes_areScopedWithoutBlanketOpsMatch() { + val client = clientFor("work") + val paths = + listOf( + "api/ops/backup", + "api/ops/backup/download", + "api/ops/doctor", + "api/ops/security-audit", + "api/ops/prompt-size", + "api/ops/dump", + "api/ops/config-migrate", + "api/ops/import-upload", + "api/ops/debug-share", + "api/ops/checkpoints", + "api/ops/checkpoints/prune", + "api/ops/hooks", + ) + + for (path in paths) { + val url = requestedUrl(client, path) + assertEquals("work", url.queryParameter("profile")) + } + + val unclassified = requestedUrl(client, "api/ops/machine-global") + assertNull(unclassified.queryParameter("profile")) + } + + @Test + fun destructiveWebhookDelete_avoidsMultiplexUnnamedProfile400() { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.requestUrl?.queryParameter("profile").isNullOrBlank()) { + MockResponse().setResponseCode(400) + } else { + MockResponse().setResponseCode(200) + } + } + + val unscopedClient = clientFor(null) + val unscopedRequest = + Request + .Builder() + .url(server.url("api/webhooks/build-events")) + .delete() + .build() + unscopedClient.newCall(unscopedRequest).execute().use { response -> + assertEquals(400, response.code) + } + + val scopedClient = clientFor("beta") + val scopedRequest = + Request + .Builder() + .url(server.url("api/webhooks/build-events")) + .delete() + .build() + scopedClient.newCall(scopedRequest).execute().use { response -> + assertEquals(200, response.code) + } + } + @Test fun lookalikePath_notScoped() { // Sourcery review (PR #540): `startsWith` must not match non-segment From 799bb2959e62ea7cc523251e1aadfda5a634229f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:46:17 +0300 Subject: [PATCH 03/18] chore: bump coil from 3.6.2 to 3.6.3 (#1225) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 15577824..a6841a43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ retrofit = "3.0.0" securityCrypto = "1.0.0" mockk = "1.14.11" turbine = "1.2.1" -coil = "3.6.2" +coil = "3.6.3" room = "3.0.2" sqlcipher = "4.19.0" datastore = "1.1.2" From 83b2a8fa9b5900905038b1dbc48c96dccfaabba1 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:26:06 +0200 Subject: [PATCH 04/18] fix(chat): preserve attachment references from JSON RPC results (#1210) Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com> --- .../ui/chat/ChatServerMessageMapper.kt | 9 +- .../ui/chat/ChatTranscriptMerge.kt | 34 ++++- .../hermescontrol/ui/chat/ChatViewModel.kt | 7 +- .../ui/chat/ChatViewModelTest.kt | 122 ++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index b536fdae..b4f63282 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -196,7 +196,14 @@ internal fun mapServerMessages( return@forEachIndexed } - var finalContent = rawContent + // Context-reference preprocessing can persist model-facing attachment + // contents after the user's text. A cold reload / second device has no + // optimistic local bubble to hide that enriched REST representation, + // so project the producer-owned suffix out at hydration time too. + // Keep the actual @file:/@image: token here: unlike the dedupe path, + // REST-only hydration cannot prove that reference was not user-authored. + var finalContent = + if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent var attachments: List? = null if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { val items = HostMediaExtractor.extract(rawContent) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt index a485a53e..43cbdcab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -6,6 +6,38 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +private val ATTACHED_CONTEXT_MARKER_RE = + Regex("""(?:^|\n)--- Attached Context ---\s*\n""") + +private val CONTEXT_REF_RE = + Regex( + """@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""", + ) + +/** + * Remove a gateway-generated `--- Attached Context ---` suffix while + * preserving the user-authored portion of the message. + * + * The marker itself is plain text a user can legitimately type, so do not + * treat it as a producer boundary unless the suffix also contains a context + * reference of the shape emitted by the gateway. + * + * Unlike [stripAttachmentRefLines], this intentionally keeps `@file:` / + * `@image:` references in the visible text. REST-only hydration has no local + * optimistic bubble proving those references were injected by Mobile, and + * users may have authored context references themselves. + */ +internal fun stripGatewayAttachedContext(content: String): String { + val marker = ATTACHED_CONTEXT_MARKER_RE.find(content) ?: return content + val attachedContext = content.substring(marker.range.last + 1) + + if (!CONTEXT_REF_RE.containsMatchIn(attachedContext)) { + return content + } + + return content.substring(0, marker.range.first).trimEnd() +} + /** * Canonical comparison key for a tool message's result payload. * @@ -127,7 +159,7 @@ internal fun sameLogicalMessage( * run_agent). Blank lines left behind are dropped too. */ internal fun stripAttachmentRefLines(content: String): String = - content + stripGatewayAttachedContext(content) .lines() .map { it.trim() } .filterNot { line -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 1154a550..ccde9151 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1877,7 +1877,7 @@ class ChatViewModel( "filename" to attachment.name, "ext" to attachment.fileExtension, ), - ) + ).let { if (it is JsonElement) it.toAny() else it } if (result != null) { @Suppress("UNCHECKED_CAST") val ok = (result as? Map)?.get("attached") as? Boolean @@ -1897,7 +1897,10 @@ class ChatViewModel( "data_url" to "data:${attachment.mimeType};base64,$b64", "name" to attachment.name, ), - )?.let { result -> + )?.let { response -> + // request() returns raw JsonElement values; normalize before reading Kotlin types. + val result = if (response is JsonElement) response.toAny() else response + @Suppress("UNCHECKED_CAST") val refText = (result as? Map)?.get("ref_text") as? String diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 68a69b9c..84918f52 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -120,6 +120,100 @@ class ChatViewModelTest { assertEquals("ws-local-1", merged.single().id) } + @Test + fun mergeTranscriptWithLive_collapsesGatewayAttachmentContextCopy() { + val local = + ChatMessage( + id = "ws-local-attachment", + role = MessageRole.USER, + content = "What is the secret word in the attached file?", + timestamp = 100L, + ) + val rest = + ChatMessage( + id = "rest-sess-attachment", + role = MessageRole.USER, + content = + """ + @file:files/agent-vault/hermes/attachments/note.txt + + What is the secret word in the attached file? + + --- Attached Context --- + + ๐Ÿ“„ @file:files/agent-vault/hermes/attachments/note.txt (8 tokens) + ``` + THE_SECRET_WORD_IS_MANGO_8421 + ``` + """.trimIndent(), + timestamp = 100L, + ) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(local)) + + assertEquals(1, merged.size) + assertEquals("ws-local-attachment", merged.single().id) + } + + @Test + fun mapServerMessages_restOnlyAttachmentHidesGatewayContext() { + val ref = "@file:files/agent-vault/hermes/attachments/note.txt" + val persisted = + """ + $ref + + What is the secret word in the attached file? + + --- Attached Context --- + + ๐Ÿ“„ $ref (8 tokens) + ``` + THE_SECRET_WORD_IS_MANGO_8421 + ``` + """.trimIndent() + + val mapped = + mapServerMessages( + sessionId = "session-1", + messages = + listOf( + SessionMessage( + id = 42, + role = "user", + content = JsonPrimitive(persisted), + timestamp = JsonPrimitive("100"), + ), + ), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ) + + assertEquals(1, mapped.size) + assertEquals( + """ + $ref + + What is the secret word in the attached file? + """.trimIndent(), + mapped.single().content, + ) + assertFalse(mapped.single().content.contains("--- Attached Context ---")) + assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421")) + } + + @Test + fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() { + val authored = + """ + Explain this heading: + --- Attached Context --- + this is ordinary user-authored text + """.trimIndent() + + assertEquals(authored, stripAttachmentRefLines(authored)) + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -1981,6 +2075,34 @@ class ChatViewModelTest { verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } } + @Test + fun sendMessage_rawJsonFileAttachmentRetainsReference() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val uriString = "content://test/note" + val uri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns uri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() } + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred( + buildJsonObject { + put("attached", true) + put("ref_text", "@file:attachments/note.txt") + }, + ) + viewModel.addAttachment(uriString, "note.txt", "text/plain", 5) + + viewModel.sendMessage("Inspect file") + advanceUntilIdle() + + verify { + HermesWsClient.sendMessage(any(), "@file:attachments/note.txt\n\nInspect file", any(), any()) + } + } + @Test fun sendMessage_laterGrowingAttachmentDoesNotPartiallyUploadEarlierAttachments() = runTest { From a78b20bc99dfc0f59ccdf3e524a8a18466899f35 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:42:03 +0200 Subject: [PATCH 05/18] feat(chat): add paged history with stable transcript and scroll identity (#1211) Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com> --- app/build.gradle.kts | 1 + .../9.json | 136 +++ .../data/local/ChatMessagePagingDeviceTest.kt | 379 ++++++++ .../chat/fullbleed/FullBleedChatAnchorTest.kt | 252 ++++++ .../data/local/ChatMessageDao.kt | 74 +- .../data/local/ChatMessageEntity.kt | 23 +- .../data/local/ChatMessageMapper.kt | 3 + .../data/local/HermesDatabase.kt | 28 +- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 8 + .../ui/chat/ChatPersistenceRepository.kt | 44 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 41 +- .../ui/chat/ChatServerMessageMapper.kt | 254 ++---- .../ui/chat/ChatTranscriptMerge.kt | 329 +++++-- .../hermescontrol/ui/chat/ChatViewModel.kt | 659 +++++++------- .../ui/chat/components/ChatHistoryPrefetch.kt | 29 + .../chat/components/ChatScrollController.kt | 31 +- .../ui/chat/fullbleed/FullBleedChatList.kt | 468 ++++++---- .../ui/chat/fullbleed/FullBleedTurns.kt | 92 +- .../ui/chat/ChatPagingMergeTest.kt | 617 +++++++++++++ .../ui/chat/ChatPersistenceRepositoryTest.kt | 149 ++++ .../ui/chat/ChatToolDedupeTest.kt | 9 +- .../ui/chat/ChatViewModelTest.kt | 836 +++++++++++++++++- .../ui/chat/FullBleedItemKeysTest.kt | 39 + .../ReadNotificationReviewRegressionTest.kt | 172 +++- .../components/ChatHistoryPrefetchTest.kt | 71 ++ .../ui/chat/fakes/FakeChatMessageDao.kt | 82 +- 26 files changed, 3942 insertions(+), 884 deletions(-) create mode 100644 app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/9.json create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetch.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetchTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 010ab018..e365e0eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,7 @@ plugins { android { namespace = "com.m57.hermescontrol" + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") lint { disable += "MissingTranslation" diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/9.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/9.json new file mode 100644 index 00000000..c6f9af0e --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/9.json @@ -0,0 +1,136 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "d3d3b8cd95987e6070d2500269bd052f", + "entities": [ + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, `rest_id` TEXT, `sort_group` INTEGER NOT NULL DEFAULT 1, `sort_order` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reasoningText", + "columnName": "reasoning_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolName", + "columnName": "tool_name", + "affinity": "TEXT" + }, + { + "fieldPath": "toolCallId", + "columnName": "tool_call_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toolStatus", + "columnName": "tool_status", + "affinity": "TEXT" + }, + { + "fieldPath": "isStreaming", + "columnName": "is_streaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayKind", + "columnName": "display_kind", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenCount", + "columnName": "token_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "tps", + "columnName": "tps", + "affinity": "REAL" + }, + { + "fieldPath": "completionId", + "columnName": "completion_id", + "affinity": "TEXT" + }, + { + "fieldPath": "restId", + "columnName": "rest_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sortGroup", + "columnName": "sort_group", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_session_id_sort_group_sort_order_id", + "unique": false, + "columnNames": [ + "session_id", + "sort_group", + "sort_order", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_sort_group_sort_order_id` ON `${TABLE_NAME}` (`session_id`, `sort_group`, `sort_order`, `id`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd3d3b8cd95987e6070d2500269bd052f')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt new file mode 100644 index 00000000..795859bb --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt @@ -0,0 +1,379 @@ +package com.m57.hermescontrol.data.local + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.execSQL +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatPersistenceRepository +import com.m57.hermescontrol.ui.chat.MessageRole +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.driver.SQLCipherDriver +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +/** Executes the generated Room DAO against SQLCipher, rather than reproducing its SQL in a fake. */ +@RunWith(AndroidJUnit4::class) +class ChatMessagePagingDeviceTest { + private lateinit var context: Context + private lateinit var database: HermesDatabase + private lateinit var repository: ChatPersistenceRepository + private val databaseName = "paging-regression-${UUID.randomUUID()}.db" + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + System.loadLibrary("sqlcipher") + openDatabase() + } + + private fun openDatabase() { + database = + Room + .databaseBuilder(context, HermesDatabase::class.java, databaseName) + .setDriver(driver()) + .addMigrations(HermesDatabase.MIGRATION_8_9) + .build() + repository = ChatPersistenceRepository(database.chatMessageDao()) + } + + @After + fun tearDown() { + database.close() + context.deleteDatabase(databaseName) + } + + @Test + fun unpaddedRestIdsSelectNumericLatestPageAndWalkEveryRowExactlyOnce() = + runBlocking { + val rows = (1..350).map { restRow(it, timestamp = 10L) } + repository.persistMessages(rows, "session") + repository.persistMessage(restRow(999, timestamp = 10L).copy(id = "rest-other-999"), "other") + val latest = repository.loadPage("session", null, 150) + assertEquals(rows.takeLast(150).map { it.id }, latest.messages.map { it.id }) + repository.persistMessage(restRow(351, timestamp = 1L), "session") + val middle = repository.loadPage("session", latest.cursor, 150) + val oldest = repository.loadPage("session", middle.cursor, 150) + assertEquals(rows.map { it.id }, (oldest.messages + middle.messages + latest.messages).map { it.id }) + assertEquals(true, latest.hasOlder) + assertEquals(true, middle.hasOlder) + assertEquals(false, oldest.hasOlder) + assertEquals( + "rest-session-351", + repository + .loadPage("session", null, 1) + .messages + .single() + .id, + ) + assertEquals( + "rest-other-999", + repository + .loadPage("other", null, 1) + .messages + .single() + .id, + ) + } + + @Test + fun nonMonotonicTimestampsKeepServerOrderInBothPageArrivalOrdersAfterReopen() = + runBlocking { + val rows = + listOf(9, 10, 100, 200).mapIndexed { index, id -> + restRow(id, listOf(900L, 100L, 800L, 200L)[index]) + } + for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) { + repository.clearMessagesForSession("session") + pages.forEach { repository.persistMessages(it, "session") } + database.close() + openDatabase() + val latest = repository.loadPage("session", null, 2) + val older = repository.loadPage("session", latest.cursor, 2) + assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id }) + assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id }) + assertEquals(false, older.hasOlder) + } + } + + @Test + fun uuidOnlyRowsKeepInsertionOrderAfterUpdateAppendAndReopen() = + runBlocking { + val rows = + listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix -> + ChatMessage( + id = "$prefix-0000-4000-8000-000000000000", + role = MessageRole.USER, + content = "message-$index", + timestamp = listOf(900L, 100L, 800L)[index], + ) + } + rows.forEach { repository.persistMessage(it, "local") } + repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local") + database.close() + openDatabase() + val latest = repository.loadPage("local", null, 2) + repository.persistMessage( + rows.last().copy(id = "bbbbbbbb-0000-4000-8000-000000000000", content = "new", timestamp = 1L), + "local", + ) + val older = repository.loadPage("local", latest.cursor, 2) + assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id }) + assertEquals(listOf(rows.first().id), older.messages.map { it.id }) + assertEquals("edited", older.messages.single().content) + assertEquals(false, older.hasOlder) + assertEquals( + "new", + repository + .loadPage("local", null, 1) + .messages + .single() + .content, + ) + } + + @Test + fun migration8to9ValidatesRealSchemaAndPreservesRowsMetadataAndPaging() = + runBlocking { + val canonical = + listOf(9, 10, 100, 200).mapIndexed { index, id -> + restRow(id, listOf(900L, 100L, 800L, 200L)[index]).toEntity("session").copy( + reasoningText = "trace-$id", + toolName = "terminal", + toolCallId = "call-$id", + toolStatus = "COMPLETED", + isStreaming = true, + displayKind = "marker-$id", + tokenCount = id, + tps = 2.5, + completionId = "completion-$id", + ) + } + val local = + listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix -> + ChatMessageEntity( + id = "$prefix-0000-4000-8000-000000000000", + sessionId = "local", + role = "USER", + content = "local-$index", + timestamp = 100L - index, + ) + } + val malformed = + listOf( + "rest-session-", + "rest-session-10x", + "rest-other-50", + "rest-session-9223372036854775808", + ).map { + ChatMessageEntity( + id = it, + sessionId = "session", + role = "USER", + content = it, + timestamp = 1L, + ) + } + // REST pages can be inserted backwards; UUID order is physical insertion, not timestamp or text. + val seeded = canonical.asReversed() + local + malformed + createVersion8(seeded) + openDatabase() + val dao = database.chatMessageDao() + val restored = dao.getMessagesForSession("session") + dao.getMessagesForSession("local") + assertEquals(seeded.size, restored.size) + seeded.forEach { original -> + val row = restored.single { it.id == original.id } + assertEquals(original, row.copy(sortGroup = 1, sortOrder = 0)) + } + assertEquals(canonical.map { it.id }, restored.filter { it.sortGroup == 0 }.map { it.id }) + assertTrue(restored.filter { it.id in malformed.map { row -> row.id } }.all { it.sortGroup == 1 }) + assertEquals(local.map { it.id }, dao.getMessagesForSession("local").map { it.id }) + val first = repository.loadPage("session", null, 2) + var cursor = first.cursor + val pages = mutableListOf(first.messages) + var hasOlder = first.hasOlder + while (hasOlder) { + val page = repository.loadPage("session", cursor, 2) + pages += page.messages + cursor = page.cursor + hasOlder = page.hasOlder + } + assertEquals((canonical + malformed).map { it.id }, pages.asReversed().flatten().map { it.id }) + repository.persistMessage(local.first().toUiModel().copy(content = "edited", timestamp = 9999L), "local") + database.close() + openDatabase() + assertEquals(local.map { it.id }, repository.loadPage("local", null, 3).messages.map { it.id }) + assertEquals( + "edited", + repository + .loadPage("local", null, 3) + .messages + .first() + .content, + ) + assertPagingIndexUsed() + } + + @Test + fun migration8to9ValidatesEmptyDatabaseAndAllowsNewWrites() = + runBlocking { + createVersion8(emptyList()) + openDatabase() + assertTrue(repository.loadPage("session", null, 1).messages.isEmpty()) + repository.persistMessage(restRow(100, 1L), "session") + assertEquals(100L, database.chatMessageDao().getMessage("rest-session-100")?.sortOrder) + assertPagingIndexUsed() + } + + @Test + fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() = + runBlocking { + val live = + ChatMessage( + id = "ffffffff-0000-4000-8000-000000000000", + role = MessageRole.ASSISTANT, + content = "newer WS payload", + reasoningText = "live trace", + completionId = "completion-100", + ) + repository.persistMessage(live, "session") + repository.confirmIdentities( + listOf(live.copy(content = "stale snapshot", restId = "rest-session-100")), + "session", + ) + // A later WS write with no alias must retain the confirmed cache identity. + repository.persistMessage(live.copy(content = "final payload"), "session") + repository.persistMessage(restRow(9, 9999L), "session") + database.close() + openDatabase() + val rows = repository.loadPage("session", null, 2).messages + assertEquals(listOf("rest-session-9", live.id), rows.map { it.id }) + assertEquals("rest-session-100", rows.last().restId) + assertEquals("final payload", rows.last().content) + assertEquals("live trace", rows.last().reasoningText) + assertEquals("completion-100", rows.last().completionId) + } + + @Test + fun confirmingLastLocalRowDoesNotReuseItsCursorForALaterInsertion() = + runBlocking { + val live = + ChatMessage( + id = "ffffffff-0000-4000-8000-000000000000", + role = MessageRole.ASSISTANT, + content = "first", + ) + repository.persistMessage(live, "session") + val cursor = repository.loadPage("session", null, 1).cursor + repository.confirmIdentities(listOf(live.copy(restId = "rest-session-100")), "session") + val next = live.copy(id = "00000000-0000-4000-8000-000000000000", content = "second") + repository.persistMessage(next, "session") + assertFalse(repository.loadPage("session", cursor, 10).messages.any { it.id == next.id }) + assertEquals( + next.id, + repository + .loadPage("session", null, 1) + .messages + .single() + .id, + ) + } + + private fun driver() = SQLCipherDriver("paging-test-only".toByteArray(), null, null) + + /** Build the actual exported v8 schema, then let Room run AND validate its migration on open. */ + private fun createVersion8(rows: List) { + database.close() + context.deleteDatabase(databaseName) + val schema = + InstrumentationRegistry + .getInstrumentation() + .context.assets + .open("com.m57.hermescontrol.data.local.HermesDatabase/8.json") + .bufferedReader() + .use { JSONObject(it.readText()).getJSONObject("database") } + val file = context.getDatabasePath(databaseName) + file.parentFile?.mkdirs() + driver().open(file.absolutePath).use { connection -> + val entity = schema.getJSONArray("entities").getJSONObject(0) + connection.execSQL( + entity.getString("createSql").replace("\${TABLE_NAME}", "chat_messages"), + ) + val indices = entity.getJSONArray("indices") + for (index in 0 until indices.length()) { + connection.execSQL( + indices + .getJSONObject(index) + .getString("createSql") + .replace("\${TABLE_NAME}", "chat_messages"), + ) + } + val setup = schema.getJSONArray("setupQueries") + for (index in 0 until setup.length()) connection.execSQL(setup.getString(index)) + connection + .prepare( + "INSERT INTO chat_messages (id, session_id, role, content, reasoning_text, timestamp, " + + "tool_name, tool_call_id, tool_status, is_streaming, display_kind, " + + "token_count, tps, completion_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ).use { insert -> + rows.forEach { row -> + insert.bindText(1, row.id) + insert.bindText(2, row.sessionId) + insert.bindText(3, row.role) + insert.bindText(4, row.content) + insert.bindText(5, row.reasoningText) + insert.bindLong(6, row.timestamp) + row.toolName?.let { insert.bindText(7, it) } ?: insert.bindNull(7) + insert.bindText(8, row.toolCallId) + row.toolStatus?.let { insert.bindText(9, it) } ?: insert.bindNull(9) + insert.bindLong(10, if (row.isStreaming) 1L else 0L) + row.displayKind?.let { insert.bindText(11, it) } ?: insert.bindNull(11) + row.tokenCount?.let { insert.bindLong(12, it.toLong()) } ?: insert.bindNull(12) + row.tps?.let { insert.bindDouble(13, it) } ?: insert.bindNull(13) + row.completionId?.let { insert.bindText(14, it) } ?: insert.bindNull(14) + insert.step() + insert.reset() + insert.clearBindings() + } + } + connection.execSQL("PRAGMA user_version = 8") + } + } + + private fun assertPagingIndexUsed() { + database.close() + driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection -> + for (bound in listOf("", "AND (sort_group, sort_order, id) < (1, 100, 'cursor')")) { + connection + .prepare( + "EXPLAIN QUERY PLAN SELECT * FROM chat_messages WHERE session_id = 'session' $bound " + + "ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT 151", + ).use { query -> + val plan = buildList { while (query.step()) add(query.getText(3)) }.joinToString(" ") + assertTrue(plan, plan.contains("index_chat_messages_session_id_sort_group_sort_order_id")) + assertFalse(plan, plan.contains("TEMP B-TREE", ignoreCase = true)) + } + } + } + } + + private fun restRow( + id: Int, + timestamp: Long, + ) = ChatMessage( + id = "rest-session-$id", + role = MessageRole.ASSISTANT, + content = "answer-$id", + timestamp = timestamp, + ) +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt new file mode 100644 index 00000000..dfbf9241 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt @@ -0,0 +1,252 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatSearchState +import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.ToolStatus +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Real lazy layout regression for the custom23 history prepend jump. */ +@RunWith(AndroidJUnit4::class) +class FullBleedChatAnchorTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun loadingOnly_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = false) + + @Test + fun loadingOnly_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = false) + + @Test + fun prepend_atExactTop_keepsContentPixel() = checkAnchor(index = 0, offset = 0, prepend = true) + + @Test + fun prepend_atPartialTop_keepsContentPixel() = checkAnchor(index = 0, offset = 23, prepend = true) + + @Test + fun prepend_atInteriorRow_keepsContentPixel() = checkAnchor(index = 8, offset = 17, prepend = true) + + @Test + fun prepend_mergingAgentTurn_keepsToolPixel() = + checkAnchor(index = 0, offset = 13, prepend = true, role = MessageRole.TOOL) + + @Test + fun loadingOnly_doesNotCancelAnActiveHistoryScroll() = + checkAnchor(index = 8, offset = 17, prepend = false, holdScroll = true) + + @Test + fun prepend_usesTheReadersPositionWhenThePageArrives() = + checkAnchor(index = 0, offset = 23, prepend = true, moveWhileLoading = true) + + @Test + fun prepend_afterReaderReachesAgentStatus_keepsTailPixel() = checkTailAnchor("agent_status") + + @Test + fun prepend_afterReaderReachesClarify_keepsTailPixel() = checkTailAnchor("clarify_bubble") + + @Test + fun prepend_afterReaderReachesVaultUnlock_keepsTailPixel() = checkTailAnchor("vault_unlock_card") + + @Test + fun prepend_afterReaderReachesVaultSave_keepsTailPixel() = checkTailAnchor("vault_save_login_card") + + @Test + fun prepend_afterReaderReachesVaultCode_keepsTailPixel() = checkTailAnchor("vault_code_card") + + @Test + fun prepend_duringActiveScroll_keepsGestureAndLiveTail_thenPublishesAtIdle() = + checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true) + + @Test + fun prepend_deferredDuringScroll_doesNotSurviveSessionSwitch() = + checkAnchor(index = 8, offset = 17, prepend = true, holdScroll = true, switchSession = true) + + private fun checkTailAnchor(key: String) = checkAnchor(index = 0, offset = 0, prepend = true, tailKey = key) + + private fun checkAnchor( + index: Int, + offset: Int, + prepend: Boolean, + role: MessageRole = MessageRole.USER, + holdScroll: Boolean = false, + moveWhileLoading: Boolean = false, + tailKey: String? = null, + switchSession: Boolean = false, + ) { + fun message(id: Int) = + ChatMessage( + id = "row-$id", + role = role, + content = "Message $id\nSecond line\nThird line", + toolName = if (role == MessageRole.TOOL) "terminal" else null, + toolStatus = if (role == MessageRole.TOOL) ToolStatus.COMPLETED else null, + ) + + val messages = mutableStateOf((150..189).map(::message)) + val loading = mutableStateOf(false) + val session = mutableStateOf("session-one") + val streaming = mutableStateOf(StreamingState()) + val tailKeys = + listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card") + val listState = LazyListState(index, offset) + val viewModel = mockk(relaxed = true) + lateinit var scope: CoroutineScope + compose.setContent { + scope = rememberCoroutineScope() + val controller = rememberChatScrollController(listState, scope) + Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) { + FullBleedChatList( + messages = messages.value, + streamingState = streaming.value, + isAgentTyping = tailKey != null, + searchState = ChatSearchState(), + typingEffectEnabled = false, + typingEffectDelayMs = 30, + isLoading = false, + isLoadingOlder = loading.value, + listState = listState, + scrollController = controller, + lastAnimatedMessageId = null, + onLastAnimatedMessageIdChange = {}, + viewModel = viewModel, + pagingSessionId = session.value, + clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null, + vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null, + vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null, + vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null, + ) + } + } + var before = + compose.runOnIdle { + listState.layoutInfo.visibleItemsInfo + .first { it.index == index } + .let { it.key to it.offset } + } + + fun assertAnchor(stage: String) { + compose.runOnIdle { + val after = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == before.first } + assertNotNull("$stage: anchor ${before.first} left viewport", after) + assertEquals("$stage: anchor ${before.first} moved (pixels)", before.second, after!!.offset) + } + } + + val scrollJob = + compose.runOnIdle { + if (holdScroll) { + scope.launch { + listState.scroll(MutatePriority.UserInput) { awaitCancellation() } + } + } else { + null + } + } + compose.runOnIdle { + if (holdScroll) assertTrue("scroll started", listState.isScrollInProgress) + loading.value = true + } + assertAnchor("loading started") + if (tailKey != null) { + compose.runOnIdle { scope.launch { listState.scrollToItem(40 + tailKeys.indexOf(tailKey)) } } + before = + compose.runOnIdle { + listState.layoutInfo.visibleItemsInfo + .first { it.index == listState.firstVisibleItemIndex } + .let { it.key to it.offset } + } + assertEquals("reader reached requested tail row", tailKey, before.first) + } + if (moveWhileLoading) { + compose.runOnIdle { scope.launch { listState.scrollToItem(index + 3, 19) } } + before = + compose.runOnIdle { + listState.layoutInfo.visibleItemsInfo + .first { it.index == listState.firstVisibleItemIndex } + .let { it.key to it.offset } + } + } + compose.runOnIdle { + if (prepend) messages.value = (0..149).map(::message) + messages.value + loading.value = false + } + assertAnchor(if (prepend) "page prepended" else "loading stopped without data") + compose.runOnIdle { + if (holdScroll) { + assertTrue("loading must not cancel the gesture", scrollJob!!.isActive) + assertTrue("scroll still in progress", listState.isScrollInProgress) + } + } + if (holdScroll && prepend) { + compose.runOnIdle { + assertEquals("older prefix waits for idle", 40, listState.layoutInfo.totalItemsCount) + messages.value = messages.value + message(190) + streaming.value = + StreamingState(streamingMessage = ChatMessage("stream", MessageRole.ASSISTANT, "Live answer")) + } + compose.runOnIdle { + assertEquals("new settled and streaming tail remain live", 42, listState.layoutInfo.totalItemsCount) + assertTrue("tail update must not cancel scrolling", scrollJob!!.isActive) + listState.dispatchRawDelta(18f) + } + before = + compose.runOnIdle { + listState.layoutInfo.visibleItemsInfo + .first { it.index == listState.firstVisibleItemIndex } + .let { it.key to it.offset } + } + if (switchSession) { + compose.runOnIdle { + messages.value = (200..239).map(::message) + streaming.value = StreamingState() + session.value = "session-two" + } + compose.runOnIdle { + assertEquals( + "session changes immediately", + 40, + listState.layoutInfo.totalItemsCount, + ) + } + } + } + compose.runOnIdle { scrollJob?.cancel() } + if (holdScroll && prepend) { + compose.runOnIdle { + assertEquals( + "no lost or stale pending rows", + if (switchSession) 40 else 192, + listState.layoutInfo.totalItemsCount, + ) + } + if (!switchSession) assertAnchor("gesture ended after reader moved; prefix published") + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt index a1040019..df2fb46f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageDao.kt @@ -1,23 +1,83 @@ package com.m57.hermescontrol.data.local import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert @Dao interface ChatMessageDao { @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)") suspend fun sessionExists(sessionId: String): Boolean - @Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC") + @Query( + "SELECT * FROM chat_messages WHERE session_id = :sessionId " + + "ORDER BY sort_group, sort_order, id", + ) suspend fun getMessagesForSession(sessionId: String): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsert(message: ChatMessageEntity) + @Query( + "SELECT * FROM chat_messages WHERE session_id = :sessionId " + + "ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit", + ) + suspend fun getLatestMessagePage( + sessionId: String, + limit: Int, + ): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(messages: List) + @Query( + "SELECT * FROM chat_messages WHERE session_id = :sessionId " + + "AND (sort_group, sort_order, id) < (:beforeGroup, :beforeOrder, :beforeId) " + + "ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit", + ) + suspend fun getMessagePage( + sessionId: String, + beforeGroup: Int, + beforeOrder: Long, + beforeId: String, + limit: Int, + ): List + + @Query("SELECT * FROM chat_messages WHERE id = :id") + suspend fun getMessage(id: String): ChatMessageEntity? + + // Physical insertion sequence includes confirmed aliases, so confirmation cannot reuse a local cursor key. + @Query("SELECT COALESCE(MAX(rowid), 0) + 1 FROM chat_messages") + suspend fun nextLocalOrder(): Long + + @Upsert + suspend fun writeMessage(message: ChatMessageEntity) + + // Allocate once, in the same transaction as the write. Content/clock updates never move a local row. + @Transaction + suspend fun upsert(message: ChatMessageEntity) { + val existing = getMessage(message.id) + val restId = message.restId ?: existing?.restId + val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId) + writeMessage( + message.copy( + restId = restId, + completionId = message.completionId ?: existing?.completionId, + sortGroup = if (canonicalOrder != null) 0 else 1, + sortOrder = canonicalOrder ?: existing?.sortOrder ?: nextLocalOrder(), + ), + ) + } + + @Transaction + suspend fun upsertAll(messages: List) { + messages.forEach { upsert(it) } + } + + // History confirms identity, but must not overwrite a newer WS-owned payload. + @Transaction + suspend fun confirmIdentity(message: ChatMessageEntity) { + val existing = getMessage(message.id) + upsert( + existing?.copy(restId = message.restId, completionId = existing.completionId ?: message.completionId) + ?: message, + ) + } @Query("DELETE FROM chat_messages WHERE session_id = :sessionId") suspend fun deleteMessagesForSession(sessionId: String) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt index 3a4d6f18..b77843d4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt @@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey * UI model but is stored independently so it survives process death. * * Messages are scoped by [sessionId] so switching sessions loads the right - * thread. The [timestamp] field preserves original ordering even if Room - * reorders internally. + * thread. [sortOrder] preserves numeric server order or local insertion order; + * [timestamp] is display metadata only. */ @Entity( tableName = "chat_messages", indices = [ - Index(value = ["session_id", "timestamp"]), + Index(value = ["session_id", "sort_group", "sort_order", "id"]), ], ) data class ChatMessageEntity( @@ -48,4 +48,21 @@ data class ChatMessageEntity( val tps: Double? = null, @ColumnInfo(name = "completion_id") val completionId: String? = null, + @ColumnInfo(name = "rest_id") + val restId: String? = null, + @ColumnInfo(name = "sort_group", defaultValue = "1") + val sortGroup: Int = 1, + @ColumnInfo(name = "sort_order", defaultValue = "0") + val sortOrder: Long = 0, ) + +/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */ +internal fun canonicalMessageOrder( + id: String, + sessionId: String, +): Long? { + val prefix = "rest-$sessionId-" + if (!id.startsWith(prefix)) return null + val suffix = id.removePrefix(prefix) + return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull() +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt index 953c093d..3a246c3a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt @@ -38,6 +38,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = tokenCount = tokenCount, tps = tps, completionId = completionId, + restId = restId, + localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 }, ) fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = @@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = tokenCount = tokenCount, tps = tps, completionId = completionId, + restId = restId, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt index f3525f14..c30d2891 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt @@ -14,7 +14,7 @@ import java.io.File @Database( entities = [ChatMessageEntity::class], - version = 8, + version = 9, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -90,6 +90,31 @@ abstract class HermesDatabase : RoomDatabase() { } } + val MIGRATION_8_9: Migration = + object : Migration(8, 9) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE chat_messages ADD COLUMN rest_id TEXT") + connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_group INTEGER NOT NULL DEFAULT 1") + connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0") + // v8 has no durable local sequence. Preserve its surviving physical insertion order. + connection.execSQL("UPDATE chat_messages SET sort_order = rowid") + val suffix = "substr(id, length(session_id) + 7)" + val digits = "ltrim($suffix, '0')" + connection.execSQL( + "UPDATE chat_messages SET sort_group = 0, sort_order = CAST($suffix AS INTEGER) " + + "WHERE substr(id, 1, length(session_id) + 6) = 'rest-' || session_id || '-' " + + "AND $suffix != '' AND $suffix NOT GLOB '*[^0-9]*' " + + "AND (length($digits) < 19 OR " + + "(length($digits) = 19 AND $digits <= '9223372036854775807'))", + ) + connection.execSQL("DROP INDEX index_chat_messages_session_id_timestamp") + connection.execSQL( + "CREATE INDEX index_chat_messages_session_id_sort_group_sort_order_id " + + "ON chat_messages (session_id, sort_group, sort_order, id)", + ) + } + } + suspend fun get(context: Context): HermesDatabase = withContext(Dispatchers.IO) { instance?.let { return@withContext it } @@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9, ).fallbackToDestructiveMigration(false) .build() .also { instance = it } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 3f48a358..f65c6a6d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -87,8 +87,16 @@ data class ChatMessage( val tps: Double? = null, /** Stable completion identity correlating with reply notifications for read tracking. */ val completionId: String? = null, + /** Confirmed transcript identity (#859); [id] remains the stable live/render key. */ + val restId: String? = null, + /** Cache insertion sequence for unconfirmed local rows; null before first persistence. */ + val localOrder: Long? = null, ) +/** Cached REST rows already carry their canonical identity in the persisted primary key. */ +internal val ChatMessage.canonicalRestId: String? + get() = restId ?: id.takeIf { it.startsWith("rest-") } + /** * Single live transcript log entry for subagent execution. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt index a2acc530..da1b4537 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepository.kt @@ -36,6 +36,50 @@ open class ChatPersistenceRepository( suspend fun loadMessages(sessionId: String): List = daoProvider().getMessagesForSession(sessionId).map { it.toUiModel() } + data class Cursor( + val group: Int, + val order: Long, + val id: String, + ) + + data class Page( + val messages: List, + val cursor: Cursor?, + val hasOlder: Boolean, + ) + + /** Read at most one page plus a lookahead row; never trim a full-session query. */ + suspend fun loadPage( + sessionId: String, + before: Cursor?, + limit: Int, + ): Page { + require(limit in 1..1_000) + val dao = daoProvider() + val rows = + if (before == null) { + dao.getLatestMessagePage(sessionId, limit + 1) + } else { + dao.getMessagePage(sessionId, before.group, before.order, before.id, limit + 1) + } + val page = rows.take(limit) + val oldest = page.lastOrNull() + return Page( + messages = page.asReversed().map { it.toUiModel() }, + cursor = oldest?.let { Cursor(it.sortGroup, it.sortOrder, it.id) } ?: before, + hasOlder = rows.size > limit, + ) + } + + /** Record confirmed UUID aliases without replacing their newer locally persisted content. */ + suspend fun confirmIdentities( + messages: List, + sessionId: String, + ) { + val dao = daoProvider() + messages.forEach { dao.confirmIdentity(it.toEntity(sessionId)) } + } + /** Clear all cached messages for a session (e.g. after /undo rewind). */ suspend fun clearMessagesForSession(sessionId: String) { daoProvider().deleteMessagesForSession(sessionId) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 8c935e40..49d7cfc9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -59,7 +59,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -115,8 +114,6 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.util.ConnectorUrlValidator import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch private const val SESSION_SYNC_INTERVAL_MS = 30_000L @@ -211,7 +208,6 @@ fun ChatScreen( val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) - var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) } var showContextSheet by remember { mutableStateOf(false) } var pendingSavePath by rememberSaveable { mutableStateOf(null) } var pendingSaveName by rememberSaveable { mutableStateOf(null) } @@ -265,38 +261,9 @@ fun ChatScreen( } } - // Arm + trigger older-message paging when the user scrolls near the top. - // Before prepending, capture the anchor (first visible message id + offset) - // so the same content stays under the reader's eye after the insert - // (issue #682). Restored in a LaunchedEffect once the page actually lands. - val pagingAnchor = remember { mutableStateOf?>(null) } - LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) { - if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect - snapshotFlow { listState.firstVisibleItemIndex } - .distinctUntilChanged() - .collectLatest { firstVisibleIndex -> - if (firstVisibleIndex > 2) { - isOlderPagingArmed = true - } else if (isOlderPagingArmed || state.messages.size <= 3) { - val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id - val offset = scrollController.captureAnchorOffset() - pagingAnchor.value = if (anchorId != null) anchorId to offset else null - viewModel.loadOlderMessages() - } - } - } - - // After older history is prepended, restore the anchor message (by id, so - // streaming-token inserts during paging don't skew the index) plus offset. - LaunchedEffect(state.messages) { - val anchor = pagingAnchor.value ?: return@LaunchedEffect - val (anchorId, offset) = anchor - val index = state.messages.indexOfFirst { it.id == anchorId } - if (index >= 0) { - scrollController.scrollToItem(index, offset) - pagingAnchor.value = null - } - } + // FullBleedChatList preserves the live lazy row key and pixel offset on prepend. + // Never restore a message index captured before an asynchronous fetch: it is not + // a lazy row index and the reader may have moved in the meantime. // Continuous bottom-follow tracking from LazyListState (issue #682). LaunchedEffect(Unit) { @@ -705,6 +672,8 @@ fun ChatScreen( maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, + hasOlderMessages = state.hasOlderMessages, + pagingSessionId = state.currentSessionId, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index b4f63282..f4b24abc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -10,10 +10,9 @@ import com.m57.hermescontrol.notification.ReplyNotificationTracker /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. * - * Verbatim extraction of ChatViewModel.mapServerMessages (corral 2 of the - * 2026-08 delegate refactor). Pure with respect to the VM: it reads the live - * message list supplied by the caller and one paging-mode flag โ€” no state - * mutation, no I/O beyond URL construction. + * Reads the supplied transcript snapshot and recovers notification identity through + * ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher; + * it never mutates ViewModel state or dismisses a notification. */ internal fun mapServerMessages( sessionId: String, @@ -24,15 +23,11 @@ internal fun mapServerMessages( isPagingOlder: Boolean = false, context: android.content.Context? = null, ): List { - val existingReasoningMap = - liveMessages - .filter { it.reasoningText.isNotBlank() } - .associateBy { it.content } - + val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id } val liveByExactId = liveMessages .filter { !it.completionId.isNullOrBlank() } - .associateBy { it.id } + .associateBy { it.canonicalRestId ?: it.id } val unmappedLiveWsAssistants = if (isPagingOlder) { @@ -40,112 +35,91 @@ internal fun mapServerMessages( } else { liveMessages .filter { - it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") && + it.role == MessageRole.ASSISTANT && it.canonicalRestId == null && !it.completionId.isNullOrBlank() } } - val wsCompletionIdByRestIndex = mutableMapOf() - if (!isPagingOlder) { - if (unmappedLiveWsAssistants.isNotEmpty()) { - val remainingWs = unmappedLiveWsAssistants.toMutableList() - for (i in messages.indices.reversed()) { - if (remainingWs.isEmpty()) break - val m = messages[i] - val r = - when (m.role?.lowercase()) { - "user" -> MessageRole.USER - "system" -> MessageRole.SYSTEM - "tool" -> MessageRole.TOOL - else -> MessageRole.ASSISTANT - } - if (r != MessageRole.ASSISTANT) continue - val rawContent = m.contentText - // Reasoning-only or empty tool placeholder: skip, does not become visible prose - if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue - - val restId = - if (latestPaging) { - m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}" - } else { - "rest-$sessionId-${offset + i}" - } - if (liveByExactId.containsKey(restId)) continue - - val canonicalContent = - if (rawContent.contains("MEDIA:")) { - HostMediaExtractor.strip(rawContent).trim() - } else { - rawContent.trim() - } - - val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent } - if (wsIdx >= 0) { - remainingWs.removeAt(wsIdx).completionId?.let { compId -> - wsCompletionIdByRestIndex[i] = compId - } - } - } + fun restIdAt(index: Int): String = + if (latestPaging) { + "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}" + } else { + "rest-$sessionId-${offset + index}" } - // REST-only recovery is fail-closed: only a durable server row ID may - // transfer notification identity after the WebSocket copy is gone. - val activeTarget = ReplyNotificationTracker.getActiveTarget(context) - if ( - activeTarget != null && - activeTarget.sessionId == sessionId && - activeTarget.serverMessageId != null && - wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId } - ) { + val wsCompletionIdByRestIndex = mutableMapOf() + if (!isPagingOlder) { + // #129: reserve durable identity before matching any repeated live prose. + // REST-only recovery remains fail-closed when the target row is absent. + val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId } + val durableTarget = activeTarget?.takeIf { it.serverMessageId != null } + if (durableTarget != null) { val exactIndex = messages.indexOfFirst { message -> - message.id == activeTarget.serverMessageId && + message.id == durableTarget.serverMessageId && message.role.equals("assistant", ignoreCase = true) } - if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) { - wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId + if (exactIndex >= 0 && !liveByExactId.containsKey(restIdAt(exactIndex))) { + wsCompletionIdByRestIndex[exactIndex] = durableTarget.completionId + } + } + val confirmedCompletions = + liveMessages.filter { it.canonicalRestId != null }.mapNotNull { it.completionId }.toSet() + val remainingWs = + unmappedLiveWsAssistants + .filter { + it.completionId !in confirmedCompletions && it.completionId != durableTarget?.completionId + }.toMutableList() + // #129: reserve known completions and live aliases. A metadata-free cached REST + // echo must remain eligible for its pending WS completion before older duplicates. + val reservedRestIds = liveByExactId.keys + liveMessages.mapNotNull { it.restId } + // Retain upstream's newest-to-newest, one-to-one WS echo matching. + for (i in messages.indices.reversed()) { + if (remainingWs.isEmpty()) break + if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue + val m = messages[i] + if (m.role?.lowercase() in listOf("user", "system", "tool")) continue + val rawContent = m.contentText + if (rawContent.isBlank()) continue + val canonicalContent = + if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim() + val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent } + if (wsIdx >= 0) { + remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it } } } } - // Tool rows in the REST transcript carry NO tool name โ€” the live WS - // stream was the only source of `toolName`. Match each REST tool row - // to its WS counterpart by RESULT CONTENT (not position โ€” pagination - // and mixed cache state make positional mapping misalign, leaving - // the newest call with a null name โ†’ generic "tool" bubble). When a - // match is found the live message is reused wholesale (same id + - // toolName + rich payload), so persistence upserts the same row - // instead of accumulating a second `rest-` copy in Room. Issue #771. - val liveToolByResult = linkedMapOf() - liveMessages - .filter { it.role == MessageRole.TOOL } - .sortedBy { it.id.startsWith("rest-") } // prefer live WS copies - .forEach { msg -> - canonicalToolResultKey(msg.content)?.let { key -> - liveToolByResult.putIfAbsent(key, msg) + // Reasoning follows the same reserved identities as completions, never a reusable text lookup. + val assistants = liveMessages.filter { it.role == MessageRole.ASSISTANT } + val assistantsByRestId = assistants.groupBy { it.canonicalRestId } + val assistantsByCompletion = assistants.groupBy { it.completionId } + val reasoningSources = arrayOfNulls(messages.size) + messages.forEachIndexed { index, row -> + if (row.role?.lowercase() in listOf("user", "system", "tool")) return@forEachIndexed + val exact = assistantsByRestId[restIdAt(index)].orEmpty() + val completion = liveByExactId[restIdAt(index)]?.completionId ?: wsCompletionIdByRestIndex[index] + val candidates = exact + completion?.let { assistantsByCompletion[it] }.orEmpty() + reasoningSources[index] = candidates.firstOrNull { it.reasoningText.isNotBlank() } ?: candidates.firstOrNull() + } + if (!isPagingOlder) { + val remaining = + assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList() + for (index in messages.indices.reversed()) { + val row = messages[index] + if (reasoningSources[index] != null || row.contentText.isBlank() || + row.role?.lowercase() in listOf("user", "system", "tool") || + liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex + ) { + continue } + val content = HostMediaExtractor.strip(row.contentText).trim() + val match = remaining.indexOfLast { it.content.trim() == content } + if (match >= 0) reasoningSources[index] = remaining.removeAt(match) } - - // Issue #842: REST transcript rows carry the gateway's `tool_call_id` - // โ€” prefer matching live bubbles by that 1:1 identity. It works for - // EVERY tool shape, including MCP/web rows whose REST copy is raw - // `` text with no JSON key to canonicalize. - val liveToolByCallId = linkedMapOf() - liveMessages - .filter { it.role == MessageRole.TOOL && it.toolCallId.isNotBlank() } - .sortedBy { it.id.startsWith("rest-") } // prefer live WS copies - .forEach { msg -> - liveToolByCallId.putIfAbsent(msg.toolCallId, msg) - } + } val mapped = mutableListOf() - // The gateway stores a reasoning-model's thinking as its OWN assistant - // row (content = "", reasoning = trace) directly before the answer row. - // Rendering that as a standalone empty assistant bubble is the - // "reasoning box in a separate bubble" artifact โ€” fold it into the - // next assistant message with content instead. Issue #771. - var pendingReasoning: String? = null - messages.forEachIndexed { index, msg -> val role = when (msg.role?.lowercase()) { @@ -154,47 +128,32 @@ internal fun mapServerMessages( "tool" -> MessageRole.TOOL else -> MessageRole.ASSISTANT } - val globalIndex = offset + index // Issue #859: under newest-anchored paging use the server's // AUTOINCREMENT row id as the stable key โ€” from-end positions shift // as the transcript grows and would collide across hydrations // (distinctBy would silently drop the newest copy). Legacy paging // keeps the absolute-position key its count-based sync math needs. - val restId = - if (latestPaging) { - msg.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-$globalIndex" - } else { - "rest-$sessionId-$globalIndex" - } + val restId = restIdAt(index) val timestamp = msg.timestampText ?.toDoubleOrNull() ?.times(1000) ?.toLong() + ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() val rawContent = msg.contentText val rowReasoning = - if (msg.reasoningText.isNotBlank()) { - msg.reasoningText - } else { - existingReasoningMap[rawContent]?.reasoningText.orEmpty() + msg.reasoningText.ifBlank { + if (role == MessageRole.ASSISTANT) { + reasoningSources[index]?.reasoningText.orEmpty() + } else { + existingById[restId]?.reasoningText.orEmpty() + } } - // Empty assistant row โ€” two cases stored by the gateway: - // 1. Reasoning-only: thinking-model split storage (content = "", - // reasoning = trace). Stash the trace and fold it into the - // next assistant message that has content (issue #771). - // 2. Tool-call placeholder: non-reasoning models emit content = "" - // with tool_calls metadata and no reasoning. These carry no - // user-visible text and must not render as empty bubbles - // (issue #956). - if (role == MessageRole.ASSISTANT && rawContent.isBlank()) { - if (rowReasoning.isNotBlank()) { - pendingReasoning = rowReasoning - } - return@forEachIndexed - } + // Retain canonical reasoning rows across page boundaries; hide only textless placeholders. + if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isBlank()) return@forEachIndexed // Context-reference preprocessing can persist model-facing attachment // contents after the user's text. A cold reload / second device has no @@ -232,34 +191,6 @@ internal fun mapServerMessages( } } - val finalReasoning = - if (rowReasoning.isNotBlank()) { - rowReasoning - } else if (role == MessageRole.ASSISTANT && pendingReasoning != null) { - pendingReasoning.also { pendingReasoning = null } - } else { - "" - } - - // Tool rows in the REST transcript carry no tool name. When the - // result payload matches a live WS tool message, reuse it whole โ€” - // keeps the real name, the rich WS payload, AND the same id so - // Room upserts instead of accumulating a duplicate `rest-` row. - if (role == MessageRole.TOOL) { - // Prefer the gateway call id (1:1, works for every tool - // shape), then fall back to result-content matching. - liveToolByCallId[msg.toolCallId]?.let { live -> - mapped.add(live) - return@forEachIndexed - } - canonicalToolResultKey(rawContent)?.let { key -> - liveToolByResult[key]?.let { live -> - mapped.add(live) - return@forEachIndexed - } - } - } - val completionId = if (role == MessageRole.ASSISTANT) { liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index] @@ -272,7 +203,7 @@ internal fun mapServerMessages( id = restId, role = role, content = finalContent, - reasoningText = finalReasoning, + reasoningText = rowReasoning, toolCallId = msg.toolCallId, attachments = attachments, timestamp = timestamp, @@ -284,17 +215,12 @@ internal fun mapServerMessages( ) } - // A reasoning-only row with no following answer (interrupted turn): - // don't drop the trace โ€” attach it to the last assistant message. - if (pendingReasoning != null) { - val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT } - if (lastAssistantIdx >= 0) { - val target = mapped[lastAssistantIdx] - if (target.reasoningText.isBlank()) { - mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning) - } - } + // REST echoes must not reserve a match before the richer WS copy of that tool. + val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") } + val mappedTools = mapped.filter { it.role == MessageRole.TOOL } + val matches = matchTranscriptMessages(mappedTools, liveTools) + val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] } + return mapped.map { message -> + toolsById[message.id]?.copy(restId = message.canonicalRestId) ?: message } - - return mapped } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt index 43cbdcab..6d7e6125 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -107,48 +107,120 @@ internal fun canonicalToolResultKey(content: String): String? { internal fun sameLogicalMessage( a: ChatMessage, b: ChatMessage, -): Boolean { - if (a.role != b.role) return false - if (a.role == MessageRole.TOOL) { - // Issue #842: prefer the gateway's tool call id when both sides carry - // it โ€” the REST transcript stores `tool_call_id` and the live WS - // bubble keeps it from `tool.start`. Content canonicalization cannot - // cover MCP/web tools: the REST side stores the payload as raw - // `` text (not JSON), so it has no key at all. - if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) { - return a.toolCallId == b.toolCallId +): Boolean = TranscriptComparison().same(a, b) + +/** One operation owns this cache; even invalid JSON is parsed at most once per content. */ +internal class TranscriptComparison( + private val canonicalize: (String) -> String? = ::canonicalToolResultKey, +) { + private val toolKeys = HashMap() + private val trimmed = HashMap() + private val captions = HashMap() + + fun toolKey(content: String): String? { + if (!toolKeys.containsKey(content)) toolKeys[content] = canonicalize(content) + return toolKeys[content] + } + + fun same( + a: ChatMessage, + b: ChatMessage, + ): Boolean { + if (a.role != b.role) return false + val aRestId = a.canonicalRestId + val bRestId = b.canonicalRestId + if (aRestId != null && bRestId != null) return aRestId == bRestId + // #129: completion-bearing replies need confirmed identity, not repeated prose. + // Older pages deliberately do not acquire a live reply's completion in the mapper. + if (a.role == MessageRole.ASSISTANT && (a.completionId != null || b.completionId != null)) { + return a.completionId != null && a.completionId == b.completionId } - val ka = canonicalToolResultKey(a.content) - val kb = canonicalToolResultKey(b.content) - return ka != null && ka == kb + if (a.role == MessageRole.ASSISTANT && a.content.isBlank() && b.content.isBlank() && + (a.reasoningText.isNotBlank() || b.reasoningText.isNotBlank()) + ) { + return a.reasoningText.isNotBlank() && a.reasoningText == b.reasoningText + } + if (a.role == MessageRole.TOOL) { + if (a.toolCallId.isNotBlank() && b.toolCallId.isNotBlank()) return a.toolCallId == b.toolCallId + val key = toolKey(a.content) + return key != null && key == toolKey(b.content) + } + val ta = trimmed.getOrPut(a.content) { a.content.trim() } + val tb = trimmed.getOrPut(b.content) { b.content.trim() } + if (ta == tb) return true + if (a.role == MessageRole.USER && + captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == + captions.getOrPut(tb) { stripAttachmentRefLines(tb) } + ) { + return true + } + return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb)) } - val ta = a.content.trim() - val tb = b.content.trim() - if (ta == tb) return true - // Attachment dedupe: when the user sends an image/file, the backend - // persists an ENRICHED copy of the prompt โ€” the real caption plus - // `@image:`/`@file:` reference lines and a `[screenshot]` marker - // (tui_gateway _build_image_ref_message / run_agent flattening). The - // optimistic bubble carries the raw caption only, so the exact compare - // above fails and the REST sync renders the same message twice. Compare - // USER rows on the caption with injection lines stripped (both sides โ€” - // stripping a clean string is a no-op). USER-only so assistant text that - // legitimately mentions these tokens is never collapsed. - if (a.role == MessageRole.USER) { - val ca = stripAttachmentRefLines(ta) - val cb = stripAttachmentRefLines(tb) - if (ca == cb) return true +} + +/** Consume each occurrence once, reserving exact IDs before considering content echoes. */ +internal fun matchTranscriptMessages( + incoming: List, + existing: List, + comparison: TranscriptComparison = TranscriptComparison(), + allowAssistantContentMatches: Boolean = true, +): List { + val byId = existing.withIndex().associate { it.value.id to it.index } + val byRestId = + existing.withIndex().mapNotNull { (index, message) -> message.canonicalRestId?.let { it to index } }.toMap() + val used = BooleanArray(existing.size) + val matches = arrayOfNulls(incoming.size) + incoming.forEachIndexed { index, message -> + (byId[message.id] ?: message.canonicalRestId?.let { byRestId[it] })?.let { match -> + if (!used[match]) { + used[match] = true + matches[index] = existing[match] + } + } } - // Issue #842: a seal race can leave the live orphan a few trailing tokens - // short of the streamed narration (the last delta was still in the - // throttled buffer when tool.start sealed the message). The backend - // persists the COMPLETE copy, so the orphan is a strict prefix of the - // REST row. Accept prefix-covering only for substantial texts (>=40 - // chars) so a short reply can never be swallowed by a longer message - // that merely starts with it. - return ta.length >= 40 && - tb.length >= 40 && - (tb.startsWith(ta) || ta.startsWith(tb)) + val byRole = existing.indices.groupBy { existing[it].role } + val byCall = + existing.indices + .filter { existing[it].toolCallId.isNotBlank() } + .groupBy { existing[it].toolCallId } + val byResult = + existing.indices + .filter { existing[it].role == MessageRole.TOOL } + .groupBy { comparison.toolKey(existing[it].content) } + val matchOrder = + incoming.indices.filter { incoming[it].role != MessageRole.ASSISTANT } + + incoming.indices.reversed().filter { incoming[it].role == MessageRole.ASSISTANT } + matchOrder.forEach { index -> + val message = incoming[index] + if (matches[index] != null) return@forEach + val candidates = + if (message.role == MessageRole.TOOL) { + val callMatches = byCall[message.toolCallId].orEmpty() + callMatches + byResult[comparison.toolKey(message.content)].orEmpty() + } else { + // Prefix matching scans only candidates with the same role. + byRole[message.role].orEmpty().let { + if (message.role == MessageRole.ASSISTANT) it.asReversed() else it + } + } + val match = + candidates.firstOrNull { candidate -> + val other = existing[candidate] + !used[candidate] && + ( + allowAssistantContentMatches || message.role != MessageRole.ASSISTANT || + message.completionId != null || other.completionId != null + ) && + // Distinct IDs within the same source are separate occurrences, not echoes. + ((message.canonicalRestId == null) != (other.canonicalRestId == null)) && + comparison.same(message, other) + } + if (match != null) { + used[match] = true + matches[index] = existing[match] + } + } + return matches.toList() } /** @@ -176,55 +248,154 @@ internal fun stripAttachmentRefLines(content: String): String = * same call twice. Drop the `rest-` copy whenever a WS copy of the same * logical message exists (issue #771). */ -internal fun dedupeCachedMessages(messages: List): List { - val rest = messages.filter { it.id.startsWith("rest-") } - if (rest.isEmpty()) return messages - val nonRest = messages.filterNot { it.id.startsWith("rest-") } - if (nonRest.isEmpty()) return messages - val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } } - return (nonRest + keepRest).sortedBy { it.timestamp } +internal fun dedupeCachedMessages( + messages: List, + confirmedOnly: Boolean = false, +): List { + val unique = messages.dedupeById() + val rest = unique.filter { it.id.startsWith("rest-") } + val live = unique.filterNot { it.id.startsWith("rest-") } + if (rest.isEmpty() || live.isEmpty()) return unique + val matches = + matchTranscriptMessages(rest, live).mapIndexed { index, match -> + match?.takeIf { + !confirmedOnly || rest[index].canonicalRestId == it.canonicalRestId || + (rest[index].completionId != null && rest[index].completionId == it.completionId) + } + } + val echoes = + rest.indices + .filter { matches[it] != null } + .map { rest[it].id } + .toSet() + // #859: retain the REST identity when keeping a rich UUID row, including across cache pages. + val aliases = + rest.indices + .mapNotNull { index -> + matches[index]?.id?.let { it to rest[index] } + }.toMap() + return unique.filterNot { it.id in echoes }.map { message -> + aliases[message.id]?.let { + message.copy(restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId) + } ?: message + } } -/** - * A transcript reload must NOT yank live WS bubbles the server has not - * persisted yet. The gateway stores a tool row only once the tool - * COMPLETES server-side, so a reload that lands while a tool is running - * (app background/foreground mid-turn, reconnect re-resume, pull-refresh) - * returns a page without the tool row โ€” replacing the list outright made - * the in-flight tool bubble vanish and left tool.complete with no RUNNING - * message to update (issue #771). - * - * Merge instead of replace: append any current message the REST page does - * not already cover (checked by id AND logical content via - * [sameLogicalMessage]) and keep chronological order. - */ +/** Cache pages enrich confirmed identities without changing keys already on screen (#859). */ +internal fun mergeCachedTranscriptPage( + page: List, + current: List, +): List { + val currentById = current.associateBy { it.id } + // Legacy cached UUIDs may lack an alias. Restore a known alias before page-local matching + // so an older identical REST row cannot claim that UUID again. + val incoming = + dedupeCachedMessages( + page.map { message -> + currentById[message.id]?.restId?.let { message.copy(restId = it) } ?: message + }, + ) + val matches = matchTranscriptMessages(incoming, current) + val replacements = + incoming + .mapIndexedNotNull { index, message -> + val match = matches[index] ?: return@mapIndexedNotNull null + val rich = if (match.id.startsWith("rest-") && !message.id.startsWith("rest-")) message else match + match.id to + rich.copy( + id = match.id, + restId = message.canonicalRestId ?: match.canonicalRestId, + completionId = match.completionId ?: message.completionId, + ) + }.toMap() + return ( + current.map { replacements[it.id] ?: it } + + incoming.filterIndexed { index, _ -> matches[index] == null } + ).dedupeById().inTranscriptOrder().reconcileReasoningRows() +} + +/** Merge one page with the current snapshot without consuming repeated results more than once. */ internal fun mergeTranscriptWithLive( restMessages: List, currentMessages: List, + chronological: Boolean = true, + preserveLiveIds: Boolean = false, ): List { - val dedupedRest = restMessages.dedupeById() - val dedupedCurrent = currentMessages.dedupeById() - // User rows can carry live/cache-only metadata (for example whether the - // bubble continues the active turn). Keep that richer copy when REST - // returns the same logical row. - val currentUsers = dedupedCurrent.filter { it.role == MessageRole.USER }.toMutableList() - val mergedRest = - dedupedRest.map { rest -> - if (rest.role != MessageRole.USER) return@map rest - val matchIndex = - currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 } - ?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) } - if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest + val incoming = restMessages.dedupeById() + val current = currentMessages.dedupeById() + val matches = matchTranscriptMessages(incoming, current, allowAssistantContentMatches = chronological) + val consumed = matches.mapNotNull { it?.id }.toSet() + val merged = + incoming.mapIndexed { index, message -> + val match = matches[index] + // Keep local user metadata and stable IDs already used by the renderer. + when { + match?.role == MessageRole.USER -> { + match.copy( + restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, + ) + } + + preserveLiveIds && match != null && !match.id.startsWith("rest-") -> { + match.copy( + restId = message.canonicalRestId ?: match.canonicalRestId, + content = message.content, + timestamp = message.timestamp, + isStreaming = message.isStreaming, + reasoningText = message.reasoningText.ifBlank { match.reasoningText }, + attachments = message.attachments ?: match.attachments, + toolName = message.toolName ?: match.toolName, + toolCallId = message.toolCallId.ifBlank { match.toolCallId }, + toolStatus = message.toolStatus ?: match.toolStatus, + displayKind = message.displayKind ?: match.displayKind, + tokenCount = message.tokenCount ?: match.tokenCount, + completionId = message.completionId ?: match.completionId, + ) + } + + else -> { + message.copy(completionId = message.completionId ?: match?.completionId) + } + } } - val restIds = dedupedRest.map { it.id }.toSet() - val liveTail = - dedupedCurrent.filter { old -> - old.id !in restIds && mergedRest.none { sameLogicalMessage(it, old) } + val transcript = + if (chronological) { + current.filterNot { it.id in consumed } + merged + } else { + merged + current.filterNot { it.id in consumed } } - if (liveTail.isEmpty()) return mergedRest.dedupeById() - return (mergedRest + liveTail).dedupeById().sortedBy { it.timestamp } + // #129: a mapped completion can confirm a cached REST row while its rich UUID + // copy is also present. Fold that now-confirmed echo without changing the live key. + // The page has already consumed its content matches; consuming another would + // collapse a separate repeated occurrence. Only confirmed identities can fold here. + return dedupeCachedMessages(transcript.inTranscriptOrder(), confirmedOnly = true).reconcileReasoningRows() } +/** Numeric canonical history first, then unconfirmed local insertion order; clocks never sort a transcript. */ +private fun List.inTranscriptOrder(): List = + sortedWith( + compareBy { it.canonicalOrder == null } + .thenBy { it.canonicalOrder ?: it.localOrder ?: Long.MAX_VALUE }, + ) + +private val ChatMessage.canonicalOrder: Long? + get() = if (localOrder != null && restId == null) null else canonicalRestId?.substringAfterLast('-')?.toLongOrNull() + +/** Retain the canonical trace once, without copying it across a user/system/tool boundary. */ +private fun List.reconcileReasoningRows(): List = + mapIndexed { index, message -> + val previous = getOrNull(index - 1) + if (previous?.role == MessageRole.ASSISTANT && previous.content.isBlank() && + previous.canonicalRestId != null && previous.reasoningText.isNotBlank() && + message.role == MessageRole.ASSISTANT && message.content.isNotBlank() && + message.canonicalRestId != null && message.reasoningText == previous.reasoningText + ) { + message.copy(reasoningText = "") + } else { + message + } + } + /** Merge a REST page without matching it against already-settled transcript rows. */ internal fun mergeIncrementalTranscriptPage( restMessages: List, @@ -234,7 +405,7 @@ internal fun mergeIncrementalTranscriptPage( ): List { val settledEnd = currentMessages.indexOfLast { message -> - serverMessageIndex(message.id, sessionId)?.let { it < offset } == true + serverMessageIndex(message.canonicalRestId ?: message.id, sessionId)?.let { it < offset } == true } return ( currentMessages.take(settledEnd + 1) + diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ccde9151..09a0faa1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -45,6 +45,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -374,6 +376,7 @@ class ChatViewModel( slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext), searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, + private val historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = searchDispatcher, ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -443,6 +446,15 @@ class ChatViewModel( */ private var latestPaging = false private var isSyncingMessages = false + private var cacheCursor: ChatPersistenceRepository.Cursor? = null + private var cacheHasOlder = false + private var cacheLoaded = false + private var serverHasOlder = false + private var cacheJob: Job? = null + private var hydrationJob: Job? = null + private var olderJob: Job? = null + private var syncJob: Job? = null + private val historyFetchMutex = Mutex() // โ”€โ”€ Session resume recovery (desktop parity) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Bounded auto-retry with exponential backoff, mirroring the desktop's @@ -642,7 +654,19 @@ class ChatViewModel( activeHydrationRequestSequence = ++hydrationRequestSequence // Preserve metadata so late results and errors are rejected as stale. sessionGeneration++ - _uiState.update { it.copy(isSessionReady = false) } + // #129: invalidate and cancel paged work together, including its busy flags. + hydrationJob?.cancel() + olderJob?.cancel() + syncJob?.cancel() + cacheJob?.cancel() + isSyncingMessages = false + cancelResumeRetry() + _uiState.update { + it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false) + } + _uiState.value.currentSessionId?.let { sessionId -> + if (!cacheLoaded) loadCachedMessages(sessionId, sessionGeneration) + } // Fail any in-flight awaited RPCs so callers don't hang // across the disconnect (delegated to HermesWsClient, issue #526). wsClient.rejectAllPending() @@ -2904,151 +2928,201 @@ class ChatViewModel( } } + /** + * CPU work never runs in StateFlow.update: it may retry. If a WS event or another + * page changes the transcript during compute, recompute against that newer snapshot. + */ + private suspend fun mergeHistoryPage( + isCurrent: () -> Boolean, + cached: Boolean = false, + prepend: Boolean = false, + mapPage: (List) -> List, + ): List? { + while (isCurrent()) { + val snapshot = _uiState.value + val computed = + withContext(historyDispatcher) { + val page = mapPage(snapshot.messages) + val merged = + if (cached) { + mergeCachedTranscriptPage(page, snapshot.messages) + } else { + mergeTranscriptWithLive( + page, + snapshot.messages, + chronological = !prepend, + preserveLiveIds = true, + ) + } + val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged + Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos }) + } + if (!isCurrent()) return null + var applied = false + _uiState.update { current -> + applied = isCurrent() && current.messages === snapshot.messages && current.todos === snapshot.todos + if (applied) current.copy(messages = computed.second, todos = computed.third) else current + } + if (applied) return computed.first + } + return null + } + + private fun publishHistoryAvailability() { + _uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) } + } + + private suspend fun readCachedPage( + sessionId: String, + generation: Long, + isCurrent: () -> Boolean = { isCurrentSessionRequest(sessionId, generation) }, + ): Boolean { + val before = cacheCursor + // Room suspend queries own their IO executor; entity mapping/dedup runs on Default. + val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) } + val valid = { isCurrent() && cacheCursor == before } + if (!valid()) return false + mergeHistoryPage(valid, cached = true) { page.messages } ?: return false + if (!valid()) return false + cacheCursor = page.cursor + cacheHasOlder = page.hasOlder + cacheLoaded = true + publishHistoryAvailability() + return true + } + private fun loadCachedMessages( sessionId: String, generation: Long, - ): Job = - viewModelScope.launch(ioDispatcher) { - val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId)) - _uiState.update { state -> - // Only paint if still showing this session AND no fresher server - // page has landed yet (a fast REST fetch must not be clobbered - // by a stale cache read that loses the race). - if (isCurrentSessionRequest(sessionId, generation) && - state.messages.isEmpty() && - cachedMessages.isNotEmpty() - ) { - state.copy( - messages = cachedMessages, - isLoading = false, - todos = hydrateTodosFromMessages(cachedMessages), - ) - } else { - state + ): Job { + cacheJob?.cancel() + return viewModelScope + .launch { + try { + readCachedPage(sessionId, generation) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isCurrentSessionRequest(sessionId, generation)) { + // The first read can be retried by the same older-history action. + cacheHasOlder = true + publishHistoryAvailability() + _uiState.update { it.copy(errorMessage = "Failed to load cached messages: ${e.message}") } + } + } finally { + if (isCurrentSessionRequest(sessionId, generation) && _uiState.value.messages.isNotEmpty()) { + _uiState.update { it.copy(isLoading = false) } + } } - } - } + }.also { cacheJob = it } + } private fun loadSessionMessages( sessionId: String, generation: Long, ) { + if (activeHydrationRequestSequence != 0L && cacheJob?.isActive == true) { + cacheJob?.cancel() + if (!cacheLoaded) loadCachedMessages(sessionId, generation) + } val requestSequence = ++hydrationRequestSequence activeHydrationRequestSequence = requestSequence - viewModelScope.launch { - // Initial page: ask for the NEWEST page directly (order=latest โ€” - // offset is measured back from the newest message, page returned - // chronologically; verified in hermes_state.py get_messages). - // No count-based anchor, so a stale session-list message_count can - // no longer land the page at the wrong position (issue #859). - // Legacy backends without the `order` param ignore it and echo no - // `pagination` โ€” detect that and fall back to the count-based - // anchor so nothing regresses. - val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest") - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch - val (result, requestedOffset) = - if (latestResult is NetworkResult.Success && latestResult.data.pagination?.order == "latest") { - latestPaging = true - latestResult to 0 - } else if (latestResult is NetworkResult.Success) { - latestPaging = false - val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence) - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch - val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0) - fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset - } else { - latestPaging = false - latestResult to 0 - } - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch - when (result) { - is NetworkResult.Success -> { - // REST 200 โ€” the gateway has the row for this session. - sessionHasServerPresence = true - val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset - val chatMessages = - mapServerMessages( - sessionId, - result.data.messages.orEmpty(), - serverOffset, - latestPaging, - _uiState.value.messages, - context = getApplication(), - ) - loadedMessageOffset = serverOffset - withContext(ioDispatcher) { - repo.persistMessages(chatMessages, sessionId) - } - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch - _uiState.update { state -> - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update state - // Merge, don't replace: a reload mid-turn must not - // drop live WS bubbles (running tool call, streaming - // answer) the server hasn't persisted yet. Issue #771. - val merged = mergeTranscriptWithLive(chatMessages, state.messages) - val hasOlder = - if (latestPaging) { - // Newest-anchored: older messages exist iff the - // page came back FULL (a short page means we hit - // the oldest boundary). - val returned = result.data.pagination?.returned ?: chatMessages.size - returned >= MESSAGE_PAGE_SIZE && chatMessages.isNotEmpty() + hydrationJob?.cancel() + olderJob?.cancel() + syncJob?.cancel() + isSyncingMessages = false + _uiState.update { it.copy(isLoadingOlder = false) } + val valid = { isCurrentHydration(sessionId, generation, requestSequence) } + hydrationJob = + viewModelScope.launch { + try { + val latestResult = fetchMessagePage(sessionId, 0, MESSAGE_PAGE_SIZE, order = "latest") + if (!valid()) return@launch + // Backends without stable latest IDs use the legacy absolute-position protocol. + val useLatest = + latestResult is NetworkResult.Success && + latestResult.data.pagination?.order == "latest" && + latestResult.data.messages.all { it.id != null } + val (result, requestedOffset) = + if (useLatest || latestResult is NetworkResult.Failure) { + latestResult to 0 + } else { + val count = fetchServerMessageCount(sessionId, generation, requestSequence) + if (!valid()) return@launch + val offset = (count - MESSAGE_PAGE_SIZE).coerceAtLeast(0) + fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) to offset + } + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val serverOffset = result.data.pagination?.offset ?: result.data.offset ?: requestedOffset + val raw = result.data.messages + val page = + mergeHistoryPage(valid) { current -> + mapServerMessages( + sessionId, + raw, + serverOffset, + useLatest, + current, + context = getApplication(), + ) + } ?: return@launch + persistHistoryPage(page, sessionId) + if (!valid()) return@launch + latestPaging = useLatest + // Cursor counts RAW rows, including hidden reasoning/placeholder rows. + loadedMessageOffset = if (useLatest) serverOffset + raw.size else serverOffset + serverHasOlder = raw.isNotEmpty() && + if (useLatest) raw.size >= MESSAGE_PAGE_SIZE else serverOffset > 0 + sessionHasServerPresence = true + publishHistoryAvailability() + hydratedGeneration = generation + finishResumeWhenHydrated(generation) + } + + is NetworkResult.Failure -> { + val error = result.error.message + if (error.contains("404", ignoreCase = true) || + error.contains("not found", ignoreCase = true) + ) { + serverHasOlder = false + publishHistoryAvailability() + hydratedGeneration = generation + finishResumeWhenHydrated(generation) } else { - serverOffset > 0 && chatMessages.isNotEmpty() + if (hydratedGeneration == generation) hydratedGeneration = -1L + handleResumeFailure(sessionId, generation, "Failed to load messages: $error") } - state.copy( - messages = merged, - todos = hydrateTodosFromMessages(merged), - isLoading = false, - hasOlderMessages = hasOlder, - isLoadingOlder = false, - ) - } - hydratedGeneration = generation - finishResumeWhenHydrated(generation) - } - - is NetworkResult.Failure -> { - val errorMsg = result.error.message - val is404 = - errorMsg.contains("404", ignoreCase = true) || - errorMsg.contains("not found", ignoreCase = true) - if (is404) { - // A 404 from GET /api/sessions/{id}/messages indicates the session has no - // persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history). - // It is NOT a fatal error: keep the session open with empty transcript, clear loading, - // and mark hydrated cleanly. - _uiState.update { - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it - it.copy( - isLoading = false, - isLoadingOlder = false, - hasOlderMessages = false, - ) } - hydratedGeneration = generation - finishResumeWhenHydrated(generation) - return@launch } - if (hydratedGeneration == generation) hydratedGeneration = -1L - _uiState.update { - if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it - it.copy( - isLoading = false, - isLoadingOlder = false, - ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + hydratedGeneration = -1L + handleResumeFailure(sessionId, generation, "Failed to load messages: ${e.message}") } - // Route the transcript failure through the bounded resume - // retry (desktop parity) instead of a one-shot snackbar โ€” - // a transient backend/network blip recovers on its own, - // and a persistent failure ends in an explicit Retry. - handleResumeFailure( - sessionId, - generation, - "Failed to load messages: ${result.error.message}", - ) + } finally { + if (valid()) _uiState.update { it.copy(isLoading = false) } } } + } + + private suspend fun persistHistoryPage( + page: List, + sessionId: String, + ) { + // A mapped page can reuse a live WS message. Never overwrite its newer persisted + // version with the snapshot used for mapping; WS owns persistence of those IDs. + val pageIds = page.mapNotNull { it.canonicalRestId }.toSet() + val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") } + withContext(historyDispatcher) { + repo.persistMessages( + page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } }, + sessionId, + ) + repo.confirmIdentities(aliases, sessionId) } } @@ -3096,6 +3170,15 @@ class ChatViewModel( runtimeSessionId = null pendingInitialPrompt = null ActiveSessionHolder.clear() + cacheJob?.cancel() + hydrationJob?.cancel() + olderJob?.cancel() + syncJob?.cancel() + activeHydrationRequestSequence = 0L + cacheCursor = null + cacheHasOlder = false + cacheLoaded = false + serverHasOlder = false loadedMessageOffset = 0 latestPaging = false isSyncingMessages = false @@ -3372,222 +3455,150 @@ class ChatViewModel( fun loadOlderMessages() { val state = _uiState.value val sessionId = state.currentSessionId ?: return + if (!state.hasOlderMessages || state.isLoadingOlder || olderJob?.isActive == true) return + if (cacheJob?.isActive == true) return + val fromCache = cacheHasOlder || !cacheLoaded + if (!fromCache && (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder)) return val generation = sessionGeneration - if (!state.hasOlderMessages || state.isLoadingOlder) return - if (!latestPaging && loadedMessageOffset <= 0) return - val oldOffset = loadedMessageOffset - // latest: offsets count BACK from the newest message, so older pages go - // UP; legacy: absolute offsets go DOWN toward 0 (issue #859). - val newOffset = - if (latestPaging) { - oldOffset + MESSAGE_PAGE_SIZE - } else { - (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0) - } - // Legacy: the final page can be short; requesting a full page at a - // clamped offset OVERLAPS already-loaded rows (duplicate stable keys - // crash LazyColumn), so size the request to the real gap. Latest: - // pages are disjoint from-end ranges, so a full page never overlaps. - val limit = if (latestPaging) MESSAGE_PAGE_SIZE else oldOffset - newOffset + val requestSequence = activeHydrationRequestSequence + val valid = { isCurrentHydration(sessionId, generation, requestSequence) } _uiState.update { it.copy(isLoadingOlder = true) } - viewModelScope.launch { - val result = - fetchMessagePage( - sessionId, - newOffset, - limit, - order = if (latestPaging) "latest" else null, - ) - when (result) { - is NetworkResult.Success -> { - if (!isCurrentSessionRequest(sessionId, generation)) return@launch - val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: newOffset - val older = - mapServerMessages( - sessionId, - result.data.messages.orEmpty(), - returnedOffset, - latestPaging, - _uiState.value.messages, - isPagingOlder = true, - ) - loadedMessageOffset = returnedOffset - withContext(ioDispatcher) { repo.persistMessages(older, sessionId) } - _uiState.update { current -> - if (!isCurrentSessionRequest(sessionId, generation)) return@update current - val hasOlder = - if (latestPaging) { - // Full page = more older messages behind it; a - // short (or empty) page is the oldest boundary. - val returned = result.data.pagination?.returned ?: older.size - returned >= limit && older.isNotEmpty() - } else { - returnedOffset < oldOffset && older.isNotEmpty() && returnedOffset > 0 + olderJob = + viewModelScope.launch { + try { + while (cacheHasOlder || !cacheLoaded) { + val beforeMessages = _uiState.value.messages + if (!readCachedPage(sessionId, generation, valid)) return@launch + val afterMessages = _uiState.value.messages + val addedRows = + withContext(historyDispatcher) { + val previousIds = beforeMessages.mapTo(HashSet()) { it.id } + afterMessages.any { it.id !in previousIds } } - current.copy( - messages = (older + current.messages).distinctBy { it.id }, - isLoadingOlder = false, - hasOlderMessages = hasOlder, - ) + if (!valid()) return@launch + if (addedRows) return@launch + // Hydration persists canonical rows behind the local cache cursor. Consume + // those already-visible echoes without spending another older-history action. } - } + if (hydrationJob?.isActive == true || isSyncingMessages || !serverHasOlder) return@launch + // Cache reads may suspend through hydration/sync; use the current server cursor. + val useLatest = latestPaging + val oldOffset = loadedMessageOffset + val offset = if (useLatest) oldOffset else (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0) + val limit = if (useLatest) MESSAGE_PAGE_SIZE else oldOffset - offset + if (limit <= 0) return@launch + val result = fetchMessagePage(sessionId, offset, limit, order = if (useLatest) "latest" else null) + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val returnedOffset = result.data.pagination?.offset ?: result.data.offset ?: offset + val raw = result.data.messages + val page = + mergeHistoryPage(valid, prepend = true) { current -> + mapServerMessages( + sessionId, + raw, + returnedOffset, + useLatest, + current, + isPagingOlder = true, + context = getApplication(), + ) + } ?: return@launch + persistHistoryPage(page, sessionId) + if (!valid()) return@launch + loadedMessageOffset = if (useLatest) returnedOffset + raw.size else returnedOffset + serverHasOlder = raw.isNotEmpty() && + if (useLatest) { + loadedMessageOffset > oldOffset && raw.size >= limit + } else { + returnedOffset < oldOffset && returnedOffset > 0 + } + publishHistoryAvailability() + } - is NetworkResult.Failure -> { - _uiState.update { - if (isCurrentSessionRequest(sessionId, generation)) { - it.copy(isLoadingOlder = false) - } else { - it + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + errorMessage = "Failed to load older messages: ${result.error.message}", + ) + } } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + _uiState.update { + it.copy( + errorMessage = "Failed to load older messages: ${e.message}", + ) + } + } + } finally { + if (valid()) _uiState.update { it.copy(isLoadingOlder = false) } } } - } } fun syncCurrentSession() { - // Issue #840: a session created but never prompted has no server row - // yet โ€” the REST transcript 404s and the resume-retry machinery spins - // forever (visible in logcat as repeated /messages 404s). MessageStart - // flips the flag once the first prompt persists the row. if (!sessionHasServerPresence) return val state = _uiState.value val sessionId = state.currentSessionId ?: return - val generation = sessionGeneration - if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping || - _streamingState.value.streamingMessage != null + if (isSyncingMessages || hydrationJob?.isActive == true || state.isLoading || state.isLoadingOlder || + state.isAgentTyping || _streamingState.value.streamingMessage != null ) { return } + val generation = sessionGeneration + val requestSequence = activeHydrationRequestSequence + val valid = { isCurrentHydration(sessionId, generation, requestSequence) } + val useLatest = latestPaging val nextOffset = - if (latestPaging) { - // Newest-anchored paging can't compute an absolute - // "after my last row" offset (from-end offsets shift as the - // transcript grows), so refetch the newest page; the logical - // merge below keeps existing copies and only adds new rows - // (issue #859). + if (useLatest) { 0 } else { state.messages - .mapNotNull { serverMessageIndex(it.id, sessionId) } + .mapNotNull { serverMessageIndex(it.canonicalRestId ?: it.id, sessionId) } .maxOrNull() ?.plus(1) ?: loadedMessageOffset } isSyncingMessages = true - viewModelScope.launch { - try { - val result = - fetchMessagePage( - sessionId, - nextOffset, - MESSAGE_PAGE_SIZE, - order = if (latestPaging) "latest" else null, - ) - when (result) { - is NetworkResult.Success -> { - if (!isCurrentSessionRequest(sessionId, generation)) return@launch - val incoming = - mapServerMessages( - sessionId, - result.data.messages.orEmpty(), - nextOffset, - latestPaging, - _uiState.value.messages, - isPagingOlder = true, - ) - if (incoming.isEmpty()) return@launch - withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) } - _uiState.update { current -> - if (!isCurrentSessionRequest(sessionId, generation)) return@update current - // Issue #771: the sync merge was dropping the - // newest tool bubble โ€” the incoming REST page - // didn't include it yet (server persists tool rows - // at completion, but the sync offset may predate - // that), and the fragile toolName/content match - // consumed the WRONG incoming tool for an existing - // one, leaving the newest WS tool with no match - // โ†’ dropped. Use sameLogicalMessage (canonical - // result-key match) and always preserve any WS - // message that has no REST counterpart. - val unmatchedIncoming: MutableList = incoming.toMutableList() - val incomingById = HashMap(unmatchedIncoming.size) - for (i in unmatchedIncoming.indices) { - val id = unmatchedIncoming[i]?.id - if (id != null && !incomingById.containsKey(id)) { - incomingById[id] = i - } - } - - val mergedList = mutableListOf() - - for (existing in current.messages) { - val existingServerIndex = serverMessageIndex(existing.id, sessionId) - if (existingServerIndex != null) { - val matchIdx = incomingById[existing.id] - if (matchIdx != null && unmatchedIncoming[matchIdx] != null) { - mergedList.add(unmatchedIncoming[matchIdx]!!) - unmatchedIncoming[matchIdx] = null - } else if (latestPaging) { - // Newest-anchored paging re-keys rows by - // from-end position, so a transcript that - // grew since the last fetch shifted ids โ€” - // match by logical content and keep the - // existing copy (issue #859). - val logicalIdx = - unmatchedIncoming.indexOfFirst { inc -> - inc != null && sameLogicalMessage(inc, existing) - } - if (logicalIdx >= 0) { - mergedList.add(existing) - unmatchedIncoming[logicalIdx] = null - } else { - mergedList.add(existing) - } - } else { - mergedList.add(existing) - } - } else { - // WS message (UUID id, no server index): - // match by canonical content, not fragile - // toolName/content equality. - val matchIdx = - unmatchedIncoming.indexOfFirst { inc -> - inc != null && sameLogicalMessage(inc, existing) - } - if (matchIdx >= 0) { - // Prefer the WS copy (richer payload, - // real tool name) when available. - mergedList.add(existing) - unmatchedIncoming[matchIdx] = null - } else { - // No REST counterpart (server hasn't - // persisted yet) โ€” KEEP the WS message. - mergedList.add(existing) - } - } - } - - for (inc in unmatchedIncoming) { - if (inc != null) { - mergedList.add(inc) - } - } - val merged = mergedList.distinctBy { it.id } - if (sameMessages(current.messages, merged)) { - current - } else { - current.copy(messages = merged) - } - } + syncJob = + viewModelScope.launch { + try { + val result = + fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE, if (useLatest) "latest" else null) + if (!valid()) return@launch + if (result is NetworkResult.Success) { + val offset = result.data.pagination?.offset ?: result.data.offset ?: nextOffset + val page = + mergeHistoryPage(valid) { current -> + mapServerMessages( + sessionId, + result.data.messages, + offset, + useLatest, + current, + // Sync fetches recent replies, so confirm live completion identities. + isPagingOlder = false, + context = getApplication(), + ) + } ?: return@launch + persistHistoryPage(page, sessionId) + // Never derive the older cursor from displayed rows or reset it to this latest page. + // Append-only growth shifts from-end offsets toward newer rows: the next older + // request may overlap, but stable IDs remove echoes without skipping any history. } - - is NetworkResult.Failure -> {} + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) _uiState.update { it.copy(errorMessage = "Failed to sync messages: ${e.message}") } + } finally { + if (valid()) isSyncingMessages = false } - } finally { - if (generation == sessionGeneration) isSyncingMessages = false } - } } internal fun onModelSwitchInitiated() { @@ -3926,15 +3937,17 @@ class ChatViewModel( offset: Int, limit: Int, order: String? = null, - ) = withContext(ioDispatcher) { - safeApiCall { - ApiClient.hermesApi.getSessionMessages( - sessionId = sessionId, - limit = limit, - offset = offset, - includeCompacted = true, - order = order, - ) + ) = historyFetchMutex.withLock { + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getSessionMessages( + sessionId = sessionId, + limit = limit, + offset = offset, + includeCompacted = true, + order = order, + ) + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetch.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetch.kt new file mode 100644 index 00000000..eed574ec --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetch.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.components + +/** One older-page request per deliberate upward gesture, including an overscroll at index zero. */ +internal class ChatHistoryPrefetch { + private var upwardPixels = 0f + private var requested = false + + fun onScroll( + firstVisibleIndex: Int, + deltaY: Float, + userInput: Boolean, + canLoad: Boolean, + ): Boolean { + if (userInput && deltaY < 0f) upwardPixels = 0f + if (deltaY <= 0f || !canLoad || requested) return false + // A fling retains the drag's intent until scrolling stops. Programmatic + // scrolling without an upward user gesture must never load history. + if (!userInput && upwardPixels == 0f) return false + upwardPixels += deltaY + if (firstVisibleIndex > 25 || upwardPixels < 24f) return false + requested = true + return true + } + + fun endGesture() { + upwardPixels = 0f + requested = false + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index faafe0d7..20e76b3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -33,9 +33,9 @@ import kotlinx.coroutines.yield * 5. **Unread indicator** โ€” when follow is paused, [pendingCount] accumulates the * number of tail updates; shows on the FAB. Tapping the FAB resumes following * and clears the count. - * 6. **Paging anchor preservation** โ€” capture the first visible item + offset - * with [captureAnchor] before prepending older history, then - * [restoreAnchor] after insertion so the same content stays under the eye. + * 6. **Paging anchors** โ€” the full-bleed renderer preserves the live lazy row + * key and offset when older history is inserted; this controller owns no + * fetch-time anchor or animated compensation. * 7. **Serialized scroll commands** โ€” every scroll (send, FAB, session switch, * search navigation, auto-follow) is launched from [scope] so animations * don't compete. @@ -189,30 +189,17 @@ class ChatScrollController( } } + /** An explicit history gesture must not be undone by a short list's bottom-follow. */ + fun pauseFollowing() { + isFollowingBottom = false + } + /** FAB tap: resume following + clear unread. */ fun resumeFollowing() = jumpToBottom(animated = true) /** True when the FAB should be visible (not following bottom + content exists). */ fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent - /** - * Current pixel scroll offset of the first visible item โ€” captured before - * prepending older history so it can be restored after insertion. - */ - fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset - - /** - * Restore the viewport to a specific item (resolved by id in the caller, so - * the restore is robust even if streaming tokens arrive during the page - * insert). Keeps the reader's eye on the same content after prepend. - */ - fun scrollToItem( - index: Int, - offset: Int, - ) { - scope.launch { listState.scrollToItem(index, offset) } - } - /** * Navigate to a search match (serialized through [scope]). * @@ -258,7 +245,7 @@ fun tailContentKey( clarifyRequest: Any?, ): Any = listOf( - messages.size, + messages.lastOrNull(), streamingMessage?.hashCode() ?: 0, isThinking, subagentIndicators.size, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index cd861d78..33d24c21 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -12,9 +11,17 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Density @@ -32,6 +39,7 @@ import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble import com.m57.hermescontrol.ui.chat.components.ReasoningCard @@ -93,6 +101,9 @@ fun FullBleedChatList( savingAttachmentPath: String? = null, openingAttachmentPath: String? = null, onImageClick: (ImageViewerModel) -> Unit = {}, + hasOlderMessages: Boolean = false, + pagingSessionId: String? = null, + onLoadOlder: () -> Unit = viewModel::loadOlderMessages, ) { if (messages.isEmpty() && !isLoading && !isAgentTyping) { Box( @@ -105,19 +116,133 @@ fun FullBleedChatList( ) } } else { - val toolMilestones = remember(messages) { toolCallMilestones(messages) } + // Keep only the incoming older prefix out of layout during a drag/fling. + // The input remains authoritative: overlap updates, new messages and streaming + // stay live, and a session change resets the boundary immediately. + val previousFirstId = remember(listState, pagingSessionId) { mutableStateOf(messages.firstOrNull()?.id) } + val firstRenderedId = previousFirstId.value + val olderPrefixSize = + remember(messages, firstRenderedId) { + if (firstRenderedId != null && firstRenderedId != messages.firstOrNull()?.id) { + messages.indexOfFirst { it.id == firstRenderedId }.coerceAtLeast(0) + } else { + 0 + } + } + val hiddenPrefixSize = if (olderPrefixSize > 0 && listState.isScrollInProgress) olderPrefixSize else 0 + val renderedMessages = + remember(messages, hiddenPrefixSize) { + if (hiddenPrefixSize > 0) messages.subList(hiddenPrefixSize, messages.size) else messages + } + val toolMilestones = remember(renderedMessages) { toolCallMilestones(renderedMessages) } + val settledTurns = remember(renderedMessages) { groupIntoTurns(renderedMessages) } + val settledIds = remember(renderedMessages) { renderedMessages.mapTo(HashSet()) { it.id } } val turns = - remember(messages, streamingState.streamingMessage) { - groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage) + remember(settledTurns, streamingState.streamingMessage) { + appendStreamingTurn(settledTurns, settledIds, streamingState.streamingMessage) } - val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages) - // messageId โ†’ LazyColumn item index. The lazy list has EXTRA items - // vs the message list (reasoning hoists, tool rows), so search-match - // scrolling must resolve the lazy index, not use the message index. - val lazyIndexById = - remember(turns, isLoadingOlder) { - messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0) + val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, renderedMessages) + // A single ordered definition supplies both emitted tail rows and anchor keys. + val tailItems = + buildMap Unit> { + agentStatus?.let { status -> + put("agent_status") { + AgentStatusIndicator(status = status) + } + } + + // Clarify bubble โ€” rendered at the very bottom + if (clarifyRequest != null) { + put("clarify_bubble") { + ClarifyBubble( + clarifyRequest = clarifyRequest, + onRespondSingle = { option -> onRespondClarify?.invoke(option) }, + onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, + onDismiss = { onDismissClarify?.invoke() }, + ) + } + } + + if (vaultUnlockPrompt != null) { + put("vault_unlock_card") { + VaultUnlockCard( + prompt = vaultUnlockPrompt, + onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, + onDismiss = { onDismissVaultUnlock?.invoke() }, + ) + } + } + + if (vaultSaveLoginPrompt != null) { + put("vault_save_login_card") { + VaultSaveLoginCard( + prompt = vaultSaveLoginPrompt, + onConfirm = { identifier, password -> + onRespondVaultSaveLogin?.invoke(identifier, password) + }, + onDismiss = { onDismissVaultSaveLogin?.invoke() }, + ) + } + } + + if (vaultCodePrompt != null) { + put("vault_code_card") { + VaultCodeCard( + prompt = vaultCodePrompt, + onConfirm = { code -> onRespondVaultCode?.invoke(code) }, + onDismiss = { onDismissVaultCode?.invoke() }, + ) + } + } } + // custom23 prepend regression: a 150-row page can move the anchor beyond + // Compose's nearest-key lookup window. Capture the LIVE layout at insertion, + // not when fetching starts, and request its new index before the next measure. + // A staged prefix only reaches here at idle, so this cannot stop its drag/fling. + SideEffect { + val oldFirstId = previousFirstId.value + val firstId = renderedMessages.firstOrNull()?.id + if (oldFirstId != null && oldFirstId != firstId && renderedMessages.any { it.id == oldFirstId }) { + val firstVisibleIndex = listState.firstVisibleItemIndex + val anchor = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == firstVisibleIndex } + if (anchor != null) { + val newIndex = (fullBleedItemKeys(turns) + tailItems.keys).indexOf(anchor.key) + if (newIndex >= 0 && newIndex != anchor.index) { + listState.requestScrollToItem(newIndex, listState.firstVisibleItemScrollOffset) + } + } + } + previousFirstId.value = firstId + } + val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() } + val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading) + val loadOlder = rememberUpdatedState(onLoadOlder) + val prefetchConnection = + remember(listState, prefetch, scrollController) { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (prefetch.onScroll( + firstVisibleIndex = listState.firstVisibleItemIndex, + deltaY = available.y, + userInput = source == NestedScrollSource.UserInput, + canLoad = canLoad.value, + ) + ) { + scrollController.pauseFollowing() + loadOlder.value() + } + return Offset.Zero + } + } + } + LaunchedEffect(listState, prefetch) { + snapshotFlow { listState.isScrollInProgress }.collect { scrolling -> + if (!scrolling) prefetch.endGesture() + } + } // Scroll the current search match into view, word-focused. Lives here // (not in ChatLifecycleEffects) because only this composable knows @@ -128,6 +253,7 @@ fun FullBleedChatList( searchState.currentIndex, searchState.matchIndices, searchState.matchOffsets, + renderedMessages.firstOrNull()?.id, ) { if (searchState.isActive && searchState.currentIndex >= 0 && @@ -135,6 +261,9 @@ fun FullBleedChatList( ) { val messageIndex = searchState.matchIndices[searchState.currentIndex] if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect + // Search indices refer to incoming messages; resolve by id into the + // rendered rows. A hit in a staged prefix is retried when it becomes visible. + val lazyIndexById = messageIdToLazyIndex(turns) val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 } val contentLength = messages[messageIndex].content.length @@ -153,202 +282,163 @@ fun FullBleedChatList( } CompositionLocalProvider(LocalDensity provides chatDensity) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - ) { - if (isLoadingOlder) { - item(key = "loading-older") { - Box( - modifier = Modifier.fillMaxWidth().padding(12.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } - } - - turns.forEach { turn -> - when (turn) { - is ChatTurn.User -> { - // Eager captures: item lambda reads these at - // composition time (lazy), so capture now. - val userMessage = turn.message - item(key = "user-${userMessage.id}") { - Column(modifier = Modifier.padding(bottom = 12.dp)) { - renderChatBubble( - message = userMessage, - searchQuery = if (searchState.isActive) searchState.query else "", - isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == userMessage.id, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = savingAttachmentPath, - openingAttachmentPath = openingAttachmentPath, - onImageClick = onImageClick, - messageStatsEnabled = messageStatsEnabled, - showUserMessageTokens = showUserMessageTokens, - ) - } - } - } - - is ChatTurn.Agent -> { - // Reasoning hoist: the turn's reasoning renders at the - // TOP of the turn โ€” above tool rows โ€” so thinking - // leads, then the tool work, then the answer. The - // matching prose entry renders without its own card. - val turnReasoning = - turn.entries - .filterIsInstance() - .firstOrNull { it.message.reasoningText.isNotBlank() } - if (turnReasoning != null) { - val reasoning = turnReasoning.message - item(key = "reasoning-${reasoning.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - ReasoningCard( - reasoningText = reasoning.reasoningText, - isStreaming = reasoning.isStreaming, + Box(Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().nestedScroll(prefetchConnection), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + turns.forEach { turn -> + when (turn) { + is ChatTurn.User -> { + // Eager captures: item lambda reads these at + // composition time (lazy), so capture now. + val userMessage = turn.message + item(key = "user-${userMessage.id}") { + Column(modifier = Modifier.padding(bottom = 12.dp)) { + renderChatBubble( + message = userMessage, + searchQuery = if (searchState.isActive) searchState.query else "", + isCurrentMatch = + searchState.currentMatchId != null && + searchState.currentMatchId == userMessage.id, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, + onImageClick = onImageClick, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, ) } } } - turn.entries.forEach { entry -> - when (entry) { - is AgentEntry.Prose -> { - val proseMessage = entry.message - val hoistedReasoning = - turnReasoning != null && - proseMessage.id == turnReasoning.message.id - if (proseMessage.hasVisibleAgentContent()) { - item(key = "prose-${proseMessage.id}") { - Column(modifier = Modifier.padding(bottom = 12.dp)) { - if (proseMessage.isStreaming && typingEffectEnabled) { - StreamingFullBleedWithTypingEffect( - streaming = proseMessage, - typingDelayMs = typingEffectDelayMs, - showReasoning = !hoistedReasoning, - ) + + is ChatTurn.Agent -> { + // Reasoning hoist: the turn's reasoning renders at the + // TOP of the turn โ€” above tool rows โ€” so thinking + // leads, then the tool work, then the answer. The + // matching prose entry renders without its own card. + val turnReasoning = + turn.entries + .filterIsInstance() + .firstOrNull { it.message.reasoningText.isNotBlank() } + if (turnReasoning != null) { + val reasoning = turnReasoning.message + item(key = "reasoning-${reasoning.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + ReasoningCard( + reasoningText = reasoning.reasoningText, + isStreaming = reasoning.isStreaming, + ) + } + } + } + turn.entries.forEach { entry -> + when (entry) { + is AgentEntry.Prose -> { + val proseMessage = entry.message + val hoistedReasoning = + turnReasoning != null && + proseMessage.id == turnReasoning.message.id + if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning && + proseMessage.reasoningText.isNotBlank() + ) { + item(key = "reasoning-${proseMessage.id}") { + ReasoningCard( + reasoningText = proseMessage.reasoningText, + isStreaming = proseMessage.isStreaming, + ) + } + } + if (proseMessage.hasVisibleAgentContent()) { + item(key = "prose-${proseMessage.id}") { + Column(modifier = Modifier.padding(bottom = 12.dp)) { + if (proseMessage.isStreaming && typingEffectEnabled) { + StreamingFullBleedWithTypingEffect( + streaming = proseMessage, + typingDelayMs = typingEffectDelayMs, + showReasoning = !hoistedReasoning, + ) + } else { + FullBleedAgentMessage( + message = proseMessage, + // Highlight only bubbles that actually contain a match โ€” + // the rest skip the highlight scan entirely. + searchQuery = + if (searchState.isActive && + proseMessage.id in searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId != null && + searchState.currentMatchId == proseMessage.id, + showReasoning = !hoistedReasoning, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, + canSaveAttachment = savingAttachmentPath == null, + onImageClick = onImageClick, + messageStatsEnabled = messageStatsEnabled, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, + ) + } + } + } + } + } + + is AgentEntry.ToolRow -> { + val toolMessage = entry.message + val milestone = toolMilestones[toolMessage.id] + item(key = "tool-${toolMessage.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + FullBleedToolRow(toolMessage) + milestone?.let { count -> + ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) + } + } + } + } + + is AgentEntry.SystemEvent -> { + val sysMessage = entry.message + item(key = "sys-${sysMessage.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + if (sysMessage.displayKind != null) { + // Timeline marker (issue #904): + // model/personality switches and + // auto-continues render as a chip. + TimelineMarkerChip(message = sysMessage) } else { - FullBleedAgentMessage( - message = proseMessage, - // Highlight only bubbles that actually contain a match โ€” - // the rest skip the highlight scan entirely. - searchQuery = - if (searchState.isActive && - proseMessage.id in searchState.matchedIds - ) { - searchState.query - } else { - "" - }, - isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == proseMessage.id, - showReasoning = !hoistedReasoning, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = savingAttachmentPath, - openingAttachmentPath = openingAttachmentPath, - canSaveAttachment = savingAttachmentPath == null, - onImageClick = onImageClick, - messageStatsEnabled = messageStatsEnabled, - showAssistantMessageTokens = showAssistantMessageTokens, - showTokensPerSecond = showTokensPerSecond, + FullBleedSystemEvent( + message = sysMessage, + onRespondApproval = viewModel::respondToApproval, ) } } } } } - - is AgentEntry.ToolRow -> { - val toolMessage = entry.message - val milestone = toolMilestones[toolMessage.id] - item(key = "tool-${toolMessage.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - FullBleedToolRow(toolMessage) - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } - } - } - } - - is AgentEntry.SystemEvent -> { - val sysMessage = entry.message - item(key = "sys-${sysMessage.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - if (sysMessage.displayKind != null) { - // Timeline marker (issue #904): - // model/personality switches and - // auto-continues render as a chip. - TimelineMarkerChip(message = sysMessage) - } else { - FullBleedSystemEvent( - message = sysMessage, - onRespondApproval = viewModel::respondToApproval, - ) - } - } - } - } } } } } - } - agentStatus?.let { status -> - item(key = "agent_status") { - AgentStatusIndicator(status = status) + tailItems.forEach { (key, content) -> + item(key = key) { content() } } } - - // Clarify bubble โ€” rendered at the very bottom - if (clarifyRequest != null) { - item(key = "clarify_bubble") { - ClarifyBubble( - clarifyRequest = clarifyRequest, - onRespondSingle = { option -> onRespondClarify?.invoke(option) }, - onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, - onDismiss = { onDismissClarify?.invoke() }, - ) - } - } - - if (vaultUnlockPrompt != null) { - item(key = "vault_unlock_card") { - VaultUnlockCard( - prompt = vaultUnlockPrompt, - onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, - onDismiss = { onDismissVaultUnlock?.invoke() }, - ) - } - } - - if (vaultSaveLoginPrompt != null) { - item(key = "vault_save_login_card") { - VaultSaveLoginCard( - prompt = vaultSaveLoginPrompt, - onConfirm = { identifier, password -> - onRespondVaultSaveLogin?.invoke(identifier, password) - }, - onDismiss = { onDismissVaultSaveLogin?.invoke() }, - ) - } - } - - if (vaultCodePrompt != null) { - item(key = "vault_code_card") { - VaultCodeCard( - prompt = vaultCodePrompt, - onConfirm = { code -> onRespondVaultCode?.invoke(code) }, - onDismiss = { onDismissVaultCode?.invoke() }, - ) - } + // Loading history must never become the list's first visible anchor. + if (isLoadingOlder) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.TopCenter).padding(12.dp).size(24.dp), + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index bfa614b8..cc83a539 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -128,19 +128,22 @@ fun groupIntoTurns(messages: List): List { fun groupIntoTurnsWithStreaming( messages: List, streamingMessage: ChatMessage?, +): List = appendStreamingTurn(groupIntoTurns(messages), messages.mapTo(HashSet()) { it.id }, streamingMessage) + +/** Reuse every settled turn; only the last agent turn needs a changing streaming entry. */ +internal fun appendStreamingTurn( + settled: List, + settledIds: Set, + streamingMessage: ChatMessage?, ): List { - if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) { - return groupIntoTurns(messages) - } - val turns = groupIntoTurns(messages).toMutableList() + if (streamingMessage == null || streamingMessage.id in settledIds) return settled val prose = AgentEntry.Prose(streamingMessage) - val last = turns.lastOrNull() - if (last is ChatTurn.Agent) { - turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose) + val last = settled.lastOrNull() + return if (last is ChatTurn.Agent) { + settled.dropLast(1) + ChatTurn.Agent(last.entries + prose) } else { - turns += ChatTurn.Agent(listOf(prose)) + settled + ChatTurn.Agent(listOf(prose)) } - return turns } /** @@ -156,8 +159,9 @@ fun groupIntoTurnsWithStreaming( * * Mirrors the item emission order in [FullBleedChatList] exactly: * user turn โ†’ 1 item; agent turn โ†’ optional reasoning item, then one item per - * visible entry. [leadingItems] accounts for fixed items emitted before the - * turns (e.g. the `loading-older` spinner when paging). + * visible entry, including later reasoning-only rows at their own positions. + * [leadingItems] accounts for fixed items emitted before the + * turns. The history spinner is an overlay and consumes no index. * * @return messageId โ†’ LazyColumn item index of the message's content item * (prose items for agent messages; user items for user messages). @@ -165,48 +169,54 @@ fun groupIntoTurnsWithStreaming( fun messageIdToLazyIndex( turns: List, leadingItems: Int = 0, -): Map { - val map = mutableMapOf() - var itemIndex = leadingItems - turns.forEach { turn -> - when (turn) { - is ChatTurn.User -> { - map[turn.message.id] = itemIndex - itemIndex++ +): Map = + fullBleedItemKeys(turns) + .mapIndexedNotNull { index, key -> + if (key.startsWith("user-") || key.startsWith("prose-")) { + key.substringAfter('-') to index + leadingItems + } else { + null } + }.toMap() - is ChatTurn.Agent -> { - val hasReasoning = - turn.entries - .filterIsInstance() - .any { it.message.reasoningText.isNotBlank() } - if (hasReasoning) { - // The reasoning- hoist item occupies one slot. - itemIndex++ +/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */ +internal fun fullBleedItemKeys(turns: List): List = + buildList { + turns.forEach { turn -> + when (turn) { + is ChatTurn.User -> { + add("user-${turn.message.id}") } - turn.entries.forEach { entry -> - when (entry) { - is AgentEntry.Prose -> { - if (entry.message.hasVisibleAgentContent()) { - map[entry.message.id] = itemIndex - itemIndex++ + + is ChatTurn.Agent -> { + val hoisted = + turn.entries + .filterIsInstance() + .firstOrNull { it.message.reasoningText.isNotBlank() } + hoisted?.let { add("reasoning-${it.message.id}") } + turn.entries.forEach { entry -> + when (entry) { + is AgentEntry.Prose -> { + if (entry.message.hasVisibleAgentContent()) { + add("prose-${entry.message.id}") + } else if (entry != hoisted && entry.message.reasoningText.isNotBlank()) { + add("reasoning-${entry.message.id}") + } } - } - is AgentEntry.ToolRow -> { - itemIndex++ - } + is AgentEntry.ToolRow -> { + add("tool-${entry.message.id}") + } - is AgentEntry.SystemEvent -> { - itemIndex++ + is AgentEntry.SystemEvent -> { + add("sys-${entry.message.id}") + } } } } } } } - return map -} /** * Resolve the message id of the CURRENT search match once, so per-item diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt new file mode 100644 index 00000000..dcf91260 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt @@ -0,0 +1,617 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao +import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys +import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatPagingMergeTest { + @Test + fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { + val content = if (role == MessageRole.TOOL) "{\"output\":\"ok\"}" else "continue" + val live = ChatMessage(id = "uuid-new", role = role, content = content, timestamp = 2L) + val newest = live.copy(id = "rest-s-20") + val earlier = live.copy(id = "rest-s-10", timestamp = 1L) + val hydrated = mergeTranscriptWithLive(listOf(newest), listOf(live), preserveLiveIds = true) + val paged = + mergeTranscriptWithLive( + listOf(earlier), + hydrated, + chronological = false, + preserveLiveIds = true, + ) + assertEquals("Distinct $role occurrences must survive across pages", 2, paged.size) + assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id }) + val refreshed = mergeTranscriptWithLive(listOf(newest), paged, preserveLiveIds = true) + assertEquals(2, refreshed.size) + assertEquals("uuid-new", refreshed.last().id) + } + } + + @Test + fun mapperAndMergeKeepConfirmedOccurrencesMetadataAndKeysAcrossPages() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { + val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue" + val live = + ChatMessage( + id = "uuid-new", + role = role, + content = content, + timestamp = 100L, + reasoningText = "live reasoning", + toolName = "terminal", + toolStatus = ToolStatus.COMPLETED, + attachments = + listOf( + Attachment(uri = "content://image", name = "image", mimeType = "image/png", size = 1), + ), + finishTimestamp = 200L, + tps = 5.0, + ) + + fun page( + id: Int, + current: List, + ): List = + mapServerMessages( + "s", + listOf(SessionMessage(id = id, role = role.name.lowercase(), content = JsonPrimitive(content))), + 0, + true, + current, + ) + + val hydrated = mergeTranscriptWithLive(page(20, listOf(live)), listOf(live), preserveLiveIds = true) + val confirmed = hydrated.single() + assertEquals("rest-s-20", confirmed.restId) + assertEquals(live.id, confirmed.id) + assertEquals(live.reasoningText, confirmed.reasoningText) + assertEquals(live.attachments, confirmed.attachments) + assertEquals(live.finishTimestamp, confirmed.finishTimestamp) + assertEquals(live.tps, confirmed.tps) + assertEquals(live.toolStatus, confirmed.toolStatus) + val refreshedPage = page(20, hydrated) + assertEquals(confirmed.timestamp, refreshedPage.single().timestamp) + + // Missing server timestamps must not put this older occurrence after the UUID alias. + val paged = + mergeTranscriptWithLive(page(10, hydrated), hydrated, chronological = false, preserveLiveIds = true) + assertEquals(listOf("rest-s-10", "uuid-new"), paged.map { it.id }) + assertEquals("", paged.first().reasoningText) + val refreshed = + mergeTranscriptWithLive(page(10, paged), paged, chronological = false, preserveLiveIds = true) + assertEquals(paged, refreshed) + assertEquals("rest-s-20", refreshed.last().restId) + } + } + + @Test + fun cacheAliasesRejectEarlierOccurrencesInEitherPageOrder() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { + val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue" + val live = ChatMessage(id = "uuid", role = role, content = content, timestamp = 20L) + val echo = live.copy(id = "rest-s-20") + val earlier = live.copy(id = "rest-s-10", timestamp = 10L) + for (firstPage in listOf(listOf(live, echo), listOf(echo, live))) { + val cached = dedupeCachedMessages(firstPage) + assertEquals(listOf(live.copy(restId = echo.id)), cached) + val nextPage = dedupeCachedMessages(listOf(earlier) + cached) + assertEquals(listOf(earlier, live.copy(restId = echo.id)), nextPage) + assertEquals(nextPage, dedupeCachedMessages(listOf(echo) + nextPage)) + } + } + } + + @Test + fun cachedPagesPreserveDisplayedKeyAndEnrichAliasInEitherArrivalOrder() { + val live = tool("uuid", timestamp = 20L).copy(toolName = "terminal") + val echo = tool("rest-s-20", timestamp = 20L) + val earlier = tool("rest-s-10", timestamp = 10L) + for ((first, second) in listOf(live to echo, echo to live)) { + val merged = mergeCachedTranscriptPage(listOf(second), listOf(first)) + assertEquals(listOf(live.copy(id = first.id, restId = echo.id)), merged) + val older = mergeCachedTranscriptPage(listOf(earlier), merged) + assertEquals(listOf(earlier.id, first.id), older.map { it.id }) + assertEquals(older, mergeCachedTranscriptPage(listOf(echo, live), older)) + } + assertEquals( + listOf(earlier, live.copy(restId = echo.id)), + mergeCachedTranscriptPage(listOf(earlier, live), listOf(live.copy(restId = echo.id))), + ) + // A page-local dedup must also transfer its alias to an already displayed UUID. + assertEquals( + listOf(live.copy(restId = echo.id)), + mergeCachedTranscriptPage(listOf(echo, live), listOf(live)), + ) + } + + @Test + fun exactAliasIsReservedBeforeUnconfirmedContentAndSurvivesContentChanges() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { + val content = if (role == MessageRole.TOOL) """{"output":"ok"}""" else "continue" + val live = ChatMessage(id = "uuid", role = role, content = content, restId = "rest-s-20") + val unconfirmed = live.copy(id = "uuid-next", restId = null) + val older = live.copy(id = "rest-s-10", restId = null) + val exact = live.copy(id = "rest-s-20", restId = null, content = "updated by server") + val matches = matchTranscriptMessages(listOf(older, exact), listOf(live, unconfirmed)) + assertEquals(listOf(unconfirmed, live), matches) + assertTrue(!sameLogicalMessage(older, live)) + } + } + + @Test + fun olderRowsUseAliasBoundariesEvenWhenLocalTimestampsDisagree() { + val first = tool("uuid-first", timestamp = 100L).copy(restId = "rest-s-4") + val last = tool("uuid-last", timestamp = 1L).copy(restId = "rest-s-10") + val page = listOf(1, 5, 7).map { tool("rest-s-$it", timestamp = 50L) } + + val merged = mergeTranscriptWithLive(page, listOf(first, last), chronological = false, preserveLiveIds = true) + + assertEquals(listOf("rest-s-1", "uuid-first", "rest-s-5", "rest-s-7", "uuid-last"), merged.map { it.id }) + } + + private fun tool( + id: String, + callId: String = "", + timestamp: Long = 1L, + ) = ChatMessage( + id = id, + role = MessageRole.TOOL, + content = """{"output":"ok","exit_code":0}""", + toolCallId = callId, + toolStatus = ToolStatus.COMPLETED, + timestamp = timestamp, + ) + + @Test + fun merge_oneRestEchoCannotConsumeTwoIdenticalLiveResults() { + val first = tool("ws-first", timestamp = 1L) + val second = tool("ws-second", timestamp = 2L) + val echo = tool("rest-session-1", timestamp = 1L) + + val merged = mergeTranscriptWithLive(listOf(echo), listOf(first, second)) + + assertEquals("A result echo may consume at most one live occurrence", 2, merged.size) + assertTrue(merged.any { it.id == second.id }) + } + + @Test + fun cache_oneLiveEchoCannotConsumeTwoPersistedResults() { + val first = tool("rest-session-1", timestamp = 1L) + val second = tool("rest-session-2", timestamp = 2L) + val echo = tool("ws-first", timestamp = 1L) + + val cached = dedupeCachedMessages(listOf(first, echo, second)) + + assertEquals(2, cached.size) + assertTrue(cached.any { it.id == second.id }) + } + + @Test + fun merge_identicalPayloadsWithDifferentCallIdsStayDistinct() { + val first = tool("rest-session-1", callId = "call-first") + val second = tool("ws-second", callId = "call-second") + + val merged = mergeTranscriptWithLive(listOf(first), listOf(second)) + + assertEquals(setOf(first.id, second.id), merged.map { it.id }.toSet()) + } + + @Test + fun merge_largeSettledHistoryRetainsEveryStableId() { + val settled = (1..2_000).map { index -> tool("rest-session-$index", callId = "call-$index") } + val overlappingPage = settled.takeLast(150) + val live = tool("ws-new", callId = "call-new", timestamp = 2L) + + val merged = mergeTranscriptWithLive(overlappingPage, settled + live) + + assertEquals(2_001, merged.size) + assertEquals((settled + live).map { it.id }.toSet(), merged.map { it.id }.toSet()) + } + + @Test + fun olderOverlapUpdatesInPlaceWithoutMovingExistingRows() { + val current = (1..10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) } + val page = current.slice(4..5).map { it.copy(toolName = "terminal") } + + val merged = mergeTranscriptWithLive(page, current, chronological = false) + + assertEquals(current.map { it.id }, merged.map { it.id }) + assertEquals("terminal", merged[4].toolName) + assertEquals("terminal", merged[5].toolName) + } + + @Test + fun olderDisjointAndGapRowsUseServerOrderWhenTimestampsAreEqual() { + val current = listOf(4, 6, 10).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) } + val page = listOf(1, 3, 5, 6, 7).map { tool("rest-s-$it", callId = "call-$it", timestamp = 1L) } + + val merged = mergeTranscriptWithLive(page, current, chronological = false) + + assertEquals(listOf(1, 3, 4, 5, 6, 7, 10).map { "rest-s-$it" }, merged.map { it.id }) + } + + private fun serverTool( + id: Int, + callId: String = "", + ) = com.m57.hermescontrol.data.model.SessionMessage( + id = id, + role = "tool", + content = kotlinx.serialization.json.JsonPrimitive("""{"output":"ok","exit_code":0}"""), + tool_call_id = callId, + ) + + @Test + fun mapper_mixedRoomEchoPrefersRichWsToolOverExactRestId() { + val live = + tool("uuid-1", callId = "call-42").copy( + toolName = "terminal", + content = """{"name":"terminal","result":{"output":"ok","exit_code":0}}""", + ) + val echo = tool("rest-s-42", callId = "call-42") + + val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo)) + + assertEquals(listOf(live.copy(restId = "rest-s-42")), mapped) + } + + @Test + fun mapper_mixedEchoesConsumeIdenticalResultsOnlyOncePerOccurrence() { + val first = tool("uuid-1").copy(toolName = "terminal") + val second = tool("uuid-2").copy(toolName = "terminal") + val cache = listOf(first, second, tool("rest-s-42"), tool("rest-s-43")) + + val mapped = mapServerMessages("s", listOf(serverTool(42), serverTool(43)), 0, true, cache) + + assertEquals( + listOf(first.copy(restId = "rest-s-42"), second.copy(restId = "rest-s-43")), + mapped, + ) + assertEquals(2, mapped.map { it.id }.toSet().size) + } + + @Test + fun mapper_differentCallIdsCannotReuseIdenticalLiveResult() { + val live = tool("uuid-1", callId = "call-41").copy(toolName = "terminal") + val echo = tool("rest-s-42", callId = "call-42") + + val mapped = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(live, echo)) + + assertEquals("rest-s-42", mapped.single().id) + assertEquals("call-42", mapped.single().toolCallId) + assertEquals(null, mapped.single().toolName) + } + + @Test + fun cacheAndRestMergesKeepNumericOrderDespiteNonMonotonicTimestamps() { + val rows = + listOf(9, 10, 100, 200).mapIndexed { index, id -> + ChatMessage( + id = "rest-session-$id", + role = MessageRole.ASSISTANT, + content = "answer-$id", + timestamp = listOf(900L, 100L, 800L, 200L)[index], + ) + } + for (cache in listOf(false, true)) { + for (newestFirst in listOf(false, true)) { + val first = if (newestFirst) rows.drop(2) else rows.take(2) + val second = if (newestFirst) rows.take(2) else rows.drop(2) + val merged = + if (cache) { + mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList())) + } else { + mergeTranscriptWithLive( + second, + mergeTranscriptWithLive(first, emptyList(), preserveLiveIds = true), + chronological = !newestFirst, + preserveLiveIds = true, + ) + } + assertEquals("cache=$cache newestFirst=$newestFirst", rows.map { it.id }, merged.map { it.id }) + } + } + } + + @Test + fun mapperAssignsOneLiveTraceOnlyToNewestEqualContentRestRow() { + for (completion in listOf(null, "completion-200")) { + val live = + ChatMessage( + id = "uuid", + role = MessageRole.ASSISTANT, + content = "Done", + reasoningText = "newest trace", + completionId = completion, + ) + val mapped = + mapServerMessages( + "session", + listOf(assistantRow(100), assistantRow(200)), + 0, + true, + listOf(live), + ) + assertEquals(listOf("", "newest trace"), mapped.map { it.reasoningText }) + assertEquals(listOf(null, completion), mapped.map { it.completionId }) + val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true) + assertEquals(listOf("rest-session-100", "uuid"), merged.map { it.id }) + assertEquals(listOf("", "newest trace"), merged.map { it.reasoningText }) + assertEquals("rest-session-200", merged.last().canonicalRestId) + } + } + + @Test + fun mapperReservesCanonicalAndCompletionReasoningBeforeContentFallback() { + val first = + ChatMessage( + id = "uuid-first", + role = MessageRole.ASSISTANT, + content = "Done", + reasoningText = "first trace", + completionId = "completion-100", + ) + val second = first.copy(id = "uuid-second", reasoningText = "second trace", completionId = "completion-200") + for (canonicalFirst in listOf(false, true)) { + val live = listOf(if (canonicalFirst) first.copy(restId = "rest-session-100") else first, second) + val mapped = + mapServerMessages( + "session", + listOf(assistantRow(100), assistantRow(200)), + 0, + true, + live, + ) + assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText }) + assertEquals(listOf("completion-100", "completion-200"), mapped.map { it.completionId }) + } + } + + @Test + fun mapperUsesNewestReasoningCandidateWhenNoCompletionIdentityExists() { + val first = + ChatMessage(id = "uuid-first", role = MessageRole.ASSISTANT, content = "Done", reasoningText = "old") + val second = first.copy(id = "uuid-second", reasoningText = "new") + val mapped = mapServerMessages("session", listOf(assistantRow(200)), 0, true, listOf(first, second)) + assertEquals("new", mapped.single().reasoningText) + } + + @Test + fun olderPagesCannotStealLiveReasoningInEitherArrivalOrder() { + val live = + ChatMessage( + id = "uuid", + role = MessageRole.ASSISTANT, + content = "Done", + reasoningText = "newest trace", + completionId = "completion-200", + timestamp = 200_000L, + ) + for (newestFirst in listOf(false, true)) { + val order = if (newestFirst) listOf(200, 100) else listOf(100, 200) + var current = listOf(live) + order.forEach { id -> + current = applyServerPage(current, listOf(assistantRow(id)), older = id == 100) + assertEquals("A trace may appear only once after page $id", 1, current.count { it.reasoningText != "" }) + if (id == + 100 + ) { + assertEquals("", current.single { it.canonicalRestId == "rest-session-100" }.reasoningText) + } + } + assertEquals(listOf("rest-session-100", "uuid"), current.map { it.id }) + assertEquals(listOf("", "newest trace"), current.map { it.reasoningText }) + assertEquals(listOf(null, "completion-200"), current.map { it.completionId }) + assertEquals("rest-session-200", current.last().canonicalRestId) + } + // Older paging also forbids text-only reasoning fallback without a completion id. + val older = + mapServerMessages( + "session", + listOf(assistantRow(100)), + 150, + true, + listOf(live.copy(completionId = null)), + isPagingOlder = true, + ) + assertEquals("", older.single().reasoningText) + } + + @Test + fun reasoningOnlyBoundaryRetainsCanonicalRowBeforeAnswerArrives() { + val previous = assistantRow(90, content = "Previous answer") + val reasoning = assistantRow(100, content = "", reasoning = "boundary trace") + val mapped = mapServerMessages("session", listOf(previous, reasoning), 0, true, emptyList()) + assertEquals(listOf("rest-session-90", "rest-session-100"), mapped.map { it.canonicalRestId }) + assertEquals("", mapped.first().reasoningText) + assertEquals("boundary trace", mapped.last().reasoningText) + assertEquals("", mapped.last().content) + } + + @Test + fun splitReasoningAndAnswerSurviveBothArrivalOrdersRefreshAndPagedCacheReload() = + runTest { + val reasoning = assistantRow(100, content = "", reasoning = "boundary trace") + val answer = assistantRow(200) + for (answerFirst in listOf(false, true)) { + val dao = FakeChatMessageDao() + val repository = ChatPersistenceRepository(dao) + var current = emptyList() + val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer) + pages.forEach { row -> + val mapped = + mapServerMessages( + "session", + listOf(row), + 0, + true, + current, + isPagingOlder = answerFirst && row.id == 100, + ) + current = + mergeTranscriptWithLive( + mapped, + current, + chronological = !(answerFirst && row.id == 100), + preserveLiveIds = true, + ) + // Same input that persistHistoryPage writes, including the boundary page before its neighbor. + repository.persistMessages( + mapped.map { it.copy(id = requireNotNull(it.canonicalRestId)) }, + "session", + ) + } + assertBoundaryTranscript(current) + val refreshed = applyServerPage(current, listOf(reasoning, answer)) + assertEquals(current, refreshed) + val latest = repository.loadPage("session", null, 1) + val older = repository.loadPage("session", latest.cursor, 1) + assertEquals(true, latest.hasOlder) + assertEquals(false, older.hasOlder) + for ((first, second) in listOf(latest.messages to older.messages, older.messages to latest.messages)) { + assertBoundaryTranscript( + mergeCachedTranscriptPage(second, mergeCachedTranscriptPage(first, emptyList())), + ) + } + assertEquals(0, dao.fullSessionReads) + assertEquals(listOf(2, 2), dao.pageLimits) + } + } + + @Test + fun reasoningDoesNotCrossUserOrToolBoundaryInEitherArrivalOrder() { + for (role in listOf("user", "tool")) { + val reasoning = assistantRow(100, content = "", reasoning = "before $role") + val boundary = + SessionMessage( + id = 150, + role = role, + content = JsonPrimitive("boundary"), + timestamp = JsonPrimitive(150), + tool_call_id = if (role == "tool") "call-150" else null, + ) + val answer = assistantRow(200) + for (newestFirst in listOf(false, true)) { + val first = if (newestFirst) listOf(boundary, answer) else listOf(reasoning) + val second = if (newestFirst) listOf(reasoning) else listOf(boundary, answer) + val merged = applyServerPage(applyServerPage(emptyList(), first), second, older = newestFirst) + assertEquals( + listOf(100, 150, 200).map { "rest-session-$it" }, + merged.map { it.canonicalRestId }, + ) + assertEquals(listOf("before $role", "", ""), merged.map { it.reasoningText }) + // Mapping all rows together must have the same ownership as separate pages. + assertEquals(merged, applyServerPage(merged, listOf(reasoning, boundary, answer))) + } + } + } + + @Test + fun multipleReasoningOnlyRowsRemainVisibleAcrossToolSteps() { + val rows = + listOf( + assistantRow(100, content = "", reasoning = "first trace"), + SessionMessage( + id = 150, + role = "tool", + content = JsonPrimitive("result"), + timestamp = JsonPrimitive(150), + ), + assistantRow(200, content = "", reasoning = "second trace"), + assistantRow(300), + ) + val mapped = applyServerPage(emptyList(), rows) + assertEquals(listOf("first trace", "second trace"), mapped.map { it.reasoningText }.filter { it.isNotBlank() }) + assertEquals( + listOf("reasoning-rest-session-100", "reasoning-rest-session-200"), + fullBleedItemKeys(groupIntoTurns(mapped)).filter { it.startsWith("reasoning-") }, + ) + assertEquals(1, fullBleedItemKeys(groupIntoTurns(mapped)).count { it.startsWith("prose-") }) + } + + @Test + fun canonicalReasoningAndMatchingLiveAnswerRenderTraceOnlyOnceInEitherArrivalOrder() { + val live = + ChatMessage( + id = "uuid-answer", + role = MessageRole.ASSISTANT, + content = "Done", + reasoningText = "boundary trace", + completionId = "completion-200", + timestamp = 200_000L, + ) + val reasoning = assistantRow(100, content = "", reasoning = "boundary trace") + val answer = assistantRow(200) + for (answerFirst in listOf(false, true)) { + val pages = if (answerFirst) listOf(answer, reasoning) else listOf(reasoning, answer) + var current = listOf(live) + pages.forEach { row -> + current = applyServerPage(current, listOf(row), older = row.id == 100) + } + assertEquals(listOf("rest-session-100", "uuid-answer"), current.map { it.id }) + assertEquals(listOf("boundary trace", ""), current.map { it.reasoningText }) + assertEquals("completion-200", current.last().completionId) + assertEquals("rest-session-200", current.last().canonicalRestId) + assertEquals( + listOf("reasoning-rest-session-100", "prose-uuid-answer"), + fullBleedItemKeys(groupIntoTurns(current)), + ) + } + } + + @Test + fun differentReasoningOnlyRowsCannotMatchByEmptyContent() { + val live = + ChatMessage( + id = "uuid-thinking", + role = MessageRole.ASSISTANT, + content = "", + reasoningText = "new trace", + ) + val canonical = live.copy(id = "rest-session-100", reasoningText = "old trace") + assertTrue(!sameLogicalMessage(canonical, live)) + assertEquals(2, mergeTranscriptWithLive(listOf(canonical), listOf(live), preserveLiveIds = true).size) + assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live)) + } + + private fun assertBoundaryTranscript(messages: List) { + assertEquals(listOf("rest-session-100", "rest-session-200"), messages.map { it.canonicalRestId }) + assertEquals(listOf("boundary trace", ""), messages.map { it.reasoningText }) + assertEquals(listOf("", "Done"), messages.map { it.content }) + assertEquals( + listOf("reasoning-rest-session-100", "prose-rest-session-200"), + fullBleedItemKeys(groupIntoTurns(messages)), + ) + } + + private fun assistantRow( + id: Int, + content: String = "Done", + reasoning: String = "", + ) = SessionMessage( + id = id, + role = "assistant", + content = JsonPrimitive(content), + reasoning = JsonPrimitive(reasoning), + timestamp = JsonPrimitive(id), + ) + + private fun applyServerPage( + current: List, + rows: List, + older: Boolean = false, + ): List = + mergeTranscriptWithLive( + mapServerMessages("session", rows, 0, true, current, isPagingOlder = older), + current, + chronological = !older, + preserveLiveIds = true, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt index 780e2751..bb26ccd7 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPersistenceRepositoryTest.kt @@ -46,6 +46,36 @@ class ChatPersistenceRepositoryTest { org.junit.Assert.assertSame(failure, runCatching { failing.loadMessages("s") }.exceptionOrNull()) } + @Test + fun pagedReadWaitsForLazyProviderAndRetainsCompletionIdentity() = + runTest { + val ready = kotlinx.coroutines.CompletableDeferred() + var calls = 0 + val lazyRepository = + ChatPersistenceRepository { + calls++ + ready.await() + } + assertEquals(0, calls) + val pending = async { lazyRepository.loadPage("s", null, 150) } + kotlinx.coroutines.yield() + assertEquals(1, calls) + org.junit.Assert.assertFalse(pending.isCompleted) + repository.persistMessage( + ChatMessage(id = "rest-s-1", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp"), + "s", + ) + ready.complete(dao) + val row = pending.await().messages.single() + assertEquals("rest-s-1", row.canonicalRestId) + assertEquals("comp", row.completionId) + assertEquals(0, dao.fullSessionReads) + assertEquals(listOf(151), dao.pageLimits) + val failure = IllegalStateException("database unavailable") + val failing = ChatPersistenceRepository { throw failure } + org.junit.Assert.assertSame(failure, runCatching { failing.loadPage("s", null, 150) }.exceptionOrNull()) + } + @Before fun setup() { dao = FakeChatMessageDao() @@ -146,4 +176,123 @@ class ChatPersistenceRepositoryTest { assertEquals("msg-B1", loadedB[0].id) assertEquals("msg-B2", loadedB[1].id) } + + @Test + fun loadPage_usesSeparateInitialAndCursorQueries() = + runTest { + val tracedDao = io.mockk.spyk(dao) + val pagedRepository = ChatPersistenceRepository(tracedDao) + (1..3).forEach { index -> + pagedRepository.persistMessage( + ChatMessage(id = "row-$index", role = MessageRole.USER, content = "$index", timestamp = 10L), + "session", + ) + } + + val first = pagedRepository.loadPage("session", before = null, limit = 2) + val second = pagedRepository.loadPage("session", before = first.cursor, limit = 2) + + assertEquals(listOf("row-2", "row-3"), first.messages.map { it.id }) + assertEquals(listOf("row-1"), second.messages.map { it.id }) + assertEquals(true, first.hasOlder) + assertEquals(false, second.hasOlder) + io.mockk.coVerify(exactly = 1) { tracedDao.getLatestMessagePage("session", 3) } + io.mockk.coVerify(exactly = 1) { tracedDao.getMessagePage("session", 1, 2L, "row-2", 3) } + io.mockk.coVerify(exactly = 0) { tracedDao.getMessagesForSession(any()) } + } + + @Test + fun loadPage_equalTimestampsAndLiveInsertKeepCursorDeterministicAndSessionsIsolated() = + runTest { + val ids = (1..350).map { "rest-session-$it" } + repository.persistMessages( + ids.map { ChatMessage(id = it, role = MessageRole.USER, content = it, timestamp = 10L) }, + "session", + ) + val first = repository.loadPage("session", before = null, limit = 150) + repository.persistMessage( + ChatMessage(id = "new", role = MessageRole.USER, content = "new", timestamp = 20L), + "session", + ) + repository.persistMessage( + ChatMessage(id = "other", role = MessageRole.USER, content = "other", timestamp = 10L), + "other-session", + ) + val second = repository.loadPage("session", before = first.cursor, limit = 150) + val third = repository.loadPage("session", before = second.cursor, limit = 150) + + assertEquals(ids.takeLast(150), first.messages.map { it.id }) + assertEquals(ids, (third.messages + second.messages + first.messages).map { it.id }) + assertEquals(false, third.hasOlder) + assertEquals(listOf(151, 151, 151), dao.pageLimits) + assertEquals(0, dao.fullSessionReads) + assertEquals(ids.toSet() + "new", dao.idsForSession("session")) + assertEquals(setOf("other"), dao.idsForSession("other-session")) + } + + @Test + fun loadPage_nonMonotonicTimestampsFollowServerIdsInEitherArrivalOrder() = + runTest { + val rows = + listOf(9, 10, 100, 200).mapIndexed { index, id -> + ChatMessage( + id = "rest-session-$id", + role = MessageRole.ASSISTANT, + content = "answer-$id", + timestamp = listOf(900L, 100L, 800L, 200L)[index], + ) + } + for (pages in listOf(listOf(rows.take(2), rows.drop(2)), listOf(rows.drop(2), rows.take(2)))) { + repository.clearMessagesForSession("session") + pages.forEach { repository.persistMessages(it, "session") } + val latest = repository.loadPage("session", null, 2) + val older = repository.loadPage("session", latest.cursor, 2) + assertEquals(rows.drop(2).map { it.id }, latest.messages.map { it.id }) + assertEquals(rows.take(2).map { it.id }, older.messages.map { it.id }) + assertEquals(true, latest.hasOlder) + assertEquals(false, older.hasOlder) + } + assertEquals(0, dao.fullSessionReads) + assertEquals(listOf(3, 3, 3, 3), dao.pageLimits) + } + + @Test + fun loadPage_uuidOnlyRowsKeepInsertionOrderAcrossUpdatesAndLiveAppend() = + runTest { + val rows = + listOf("ffffffff", "00000000", "aaaaaaaa").mapIndexed { index, prefix -> + ChatMessage( + id = "$prefix-0000-4000-8000-000000000000", + role = MessageRole.USER, + content = "message-$index", + timestamp = listOf(900L, 100L, 800L)[index], + ) + } + rows.forEach { repository.persistMessage(it, "local") } + repository.persistMessage(rows.first().copy(content = "edited", timestamp = 999L), "local") + val latest = repository.loadPage("local", null, 2) + repository.persistMessage( + ChatMessage( + id = "bbbbbbbb-0000-4000-8000-000000000000", + role = MessageRole.USER, + content = "appended after cursor", + timestamp = 1L, + ), + "local", + ) + val older = repository.loadPage("local", latest.cursor, 2) + assertEquals(rows.drop(1).map { it.id }, latest.messages.map { it.id }) + assertEquals(listOf(rows.first().id), older.messages.map { it.id }) + assertEquals("edited", older.messages.single().content) + assertEquals(false, older.hasOlder) + assertEquals( + "appended after cursor", + repository + .loadPage("local", null, 1) + .messages + .single() + .content, + ) + assertEquals(0, dao.fullSessionReads) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt index eb0c1403..a3a8f5d9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatToolDedupeTest.kt @@ -4,7 +4,6 @@ import com.m57.hermescontrol.data.ws.WsEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -193,7 +192,7 @@ class ChatToolDedupeTest { assertEquals(3, deduped.size) // WS copies win; rest- copies of the same logical message are dropped - assertTrue(deduped.contains(wsTool)) + assertTrue(deduped.contains(wsTool.copy(restId = restTool.id))) assertTrue(deduped.none { it.id == "rest-s-1" }) assertTrue(deduped.none { it.id == "rest-s-2" }) // rest- user kept when no WS copy exists @@ -279,7 +278,7 @@ class ChatToolDedupeTest { val merged = mergeTranscriptWithLive(listOf(rest), listOf(live)) // Preserves cache/live-only user flags such as continuesActiveTurn. - assertSame(live, merged.single()) + assertEquals(live.copy(restId = rest.id), merged.single()) } @Test @@ -551,7 +550,7 @@ class ChatToolDedupeTest { val merged = mergeIncrementalTranscriptPage(incoming, current, "s", 1) assertEquals(listOf("retry", "retry"), merged.map { it.content }) - assertSame(live, merged.last()) + assertEquals(live.copy(restId = "rest-s-1"), merged.last()) } // โ”€โ”€ Issue #842: MCP/web tool rows (raw `` text) โ”€โ”€ @@ -654,6 +653,6 @@ The following content was retrieved from an external source. ) val deduped = dedupeCachedMessages(listOf(wsTool, restTool)) assertEquals(1, deduped.size) - assertEquals(wsTool, deduped.single()) + assertEquals(wsTool.copy(restId = restTool.id), deduped.single()) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 84918f52..0180dfce 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -335,10 +335,21 @@ class ChatViewModelTest { // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Create a ViewModel with the fake repo injected directly. */ - private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = + private fun createViewModel( + startCleanup: Boolean = false, + historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, + ): ChatViewModel = // Both dispatchers injected โ€” a real ioDispatcher would race the // test scheduler (repo writes hop it) and shuffle RPC ordering. - ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher) + ChatViewModel( + app, + startCleanup, + fakeRepo, + fakeSlashUsageStore, + testDispatcher, + testDispatcher, + historyDispatcher, + ) /** * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result, @@ -349,8 +360,9 @@ class ChatViewModelTest { */ private suspend fun TestScope.createViewModelWithSession( startCleanup: Boolean = false, + historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, ): Pair { - val viewModel = createViewModel(startCleanup) + val viewModel = createViewModel(startCleanup, historyDispatcher) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED @@ -5198,6 +5210,824 @@ class ChatViewModelTest { ) } + // Chat paging: deterministic regressions, with no real network or wall-clock timing. + private fun seedPagingCache( + count: Int, + sameTimestamp: Boolean = false, + ): List = + (1..count).map { index -> + val id = "cached-${index.toString().padStart(5, '0')}" + fakeRepo.dao.addMessageDirect( + com.m57.hermescontrol.data.local.ChatMessageEntity( + id = id, + sessionId = "session-456", + role = "user", + content = "Cached message $index", + timestamp = if (sameTimestamp) 1L else index.toLong(), + ), + ) + id + } + + private fun pagingResponse( + rows: IntRange, + offset: Int = 0, + ): retrofit2.Response = + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = + rows.map { index -> + com.m57.hermescontrol.data.model.SessionMessage( + id = index, + role = "assistant", + content = JsonPrimitive("Server message $index"), + timestamp = JsonPrimitive(index), + ) + }, + pagination = + com.m57.hermescontrol.data.model.PaginationInfo( + limit = 150, + offset = offset, + order = "latest", + returned = rows.count(), + ), + ), + ) + + @Test + fun paging_syncNewestRestEcho_preservesLiveIdAndCompletionInOneBubble() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(IntRange.EMPTY) + viewModel.switchSession("session-456") + advanceUntilIdle() + + // A completed live reply has no confirmed REST identity until its echo arrives. + val live = + ChatMessage( + id = "live-reply", + role = MessageRole.ASSISTANT, + content = "Server message 42", + timestamp = 42_000L, + completionId = "comp", + restId = null, + ) + + @Suppress("UNCHECKED_CAST") + val state = + ChatViewModel::class.java + .getDeclaredField("_uiState") + .apply { isAccessible = true } + .get(viewModel) as MutableStateFlow + state.value = viewModel.uiState.value.copy(messages = listOf(live)) + advanceUntilIdle() + assertNull( + viewModel.uiState.value.messages + .single() + .restId, + ) + assertEquals( + "comp", + viewModel.uiState.value.messages + .single() + .completionId, + ) + + coEvery { + api.getSessionMessages("session-456", 150, 0, "latest", any()) + } returns pagingResponse(42..42) + + viewModel.syncCurrentSession() + advanceUntilIdle() + + coVerify(exactly = 2) { + api.getSessionMessages("session-456", 150, 0, "latest", any()) + } + val messages = viewModel.uiState.value.messages + assertEquals("The newest REST echo must confirm the live reply without adding a bubble", 1, messages.size) + val message = messages.single() + assertEquals("Server message 42", message.content) + assertEquals("live-reply", message.id) + assertEquals("rest-session-456-42", message.restId) + assertEquals("comp", message.completionId) + } + + @Test + fun paging_largeCache_initialReadIsBoundedAtDao() = + runTest { + val expectedIds = seedPagingCache(10_000) + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + + viewModel.switchSession("session-456") + advanceUntilIdle() + + assertEquals( + expectedIds.takeLast(150), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertEquals("Loading all rows then taking a suffix is not paging", 0, fakeRepo.dao.fullSessionReads) + assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456")) + assertTrue(viewModel.uiState.value.hasOlderMessages) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun paging_offlinePagesRetainEveryRowWithEqualTimestamps() = + runTest { + val expectedIds = seedPagingCache(350, sameTimestamp = true) + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + viewModel.switchSession("session-456") + advanceUntilIdle() + assertEquals( + expectedIds.takeLast(150), + viewModel.uiState.value.messages + .map { it.id }, + ) + + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals( + expectedIds.takeLast(300), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertTrue(viewModel.uiState.value.hasOlderMessages) + assertFalse(viewModel.uiState.value.isLoadingOlder) + + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals( + expectedIds, + viewModel.uiState.value.messages + .map { it.id }, + ) + assertFalse(viewModel.uiState.value.hasOlderMessages) + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456")) + assertEquals(0, fakeRepo.dao.fullSessionReads) + } + + @Test + fun paging_cacheEchoEnrichesExistingUuidBeforeEarlierRepeatedOccurrencesArrive() = + runTest { + val roles = listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL) + for (index in 1..303) { + val occurrence = + when (index) { + in 1..3 -> index - 1 + in 151..153 -> index - 151 + in 301..303 -> index - 301 + else -> null + } + val role = occurrence?.let { roles[it] } ?: MessageRole.SYSTEM + val id = + when (index) { + in 1..3 -> "rest-session-456-${10 + requireNotNull(occurrence)}" + in 151..153 -> "rest-session-456-${20 + requireNotNull(occurrence)}" + in 301..303 -> "uuid-${requireNotNull(occurrence)}" + else -> "filler-$index" + } + fakeRepo.dao.addMessageDirect( + com.m57.hermescontrol.data.local.ChatMessageEntity( + id = id, + sessionId = "session-456", + role = role.name, + content = + when (role) { + MessageRole.TOOL -> """{"output":"ok"}""" + MessageRole.SYSTEM -> "filler $index" + else -> "continue" + }, + timestamp = index.toLong(), + toolName = if (role == MessageRole.TOOL && index > 300) "terminal" else null, + ), + ) + } + val storedIds = fakeRepo.dao.idsForSession("session-456") + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + viewModel.switchSession("session-456") + advanceUntilIdle() + assertEquals(150, viewModel.uiState.value.messages.size) + + viewModel.loadOlderMessages() + advanceUntilIdle() + for (index in roles.indices) { + val retained = + viewModel.uiState.value.messages + .single { it.id == "uuid-$index" } + assertEquals("rest-session-456-${20 + index}", retained.restId) + } + assertEquals(297, viewModel.uiState.value.messages.size) + + viewModel.loadOlderMessages() + advanceUntilIdle() + val messages = viewModel.uiState.value.messages + assertEquals(300, messages.size) + for (index in roles.indices) { + assertEquals(2, messages.count { it.role == roles[index] }) + assertTrue(messages.any { it.id == "rest-session-456-${10 + index}" }) + assertTrue(messages.any { it.id == "uuid-$index" }) + } + assertEquals("terminal", messages.single { it.id == "uuid-2" }.toolName) + assertEquals(storedIds, fakeRepo.dao.idsForSession("session-456")) + assertFalse(viewModel.uiState.value.hasOlderMessages) + } + + @Test + fun paging_reusedToolPersistsCanonicalKeyWithoutReplacingLiveKey() = + runTest { + fakeRepo.dao.addMessageDirect( + com.m57.hermescontrol.data.local.ChatMessageEntity( + id = "uuid-tool", + sessionId = "session-456", + role = "TOOL", + content = """{"output":"ok"}""", + timestamp = 1L, + toolName = "terminal", + ), + ) + val api = ApiClient.hermesApi + val response = pagingResponse(42..42).body()!! + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns + retrofit2.Response.success( + response.copy( + messages = + response.messages.map { + it.copy( + role = "tool", + content = JsonPrimitive("""{"output":"ok"}"""), + ) + }, + ), + ) + val (viewModel, _) = createViewModelWithSession() + viewModel.switchSession("session-456") + advanceUntilIdle() + + val message = + viewModel.uiState.value.messages + .single() + assertEquals("uuid-tool", message.id) + assertEquals("rest-session-456-42", message.restId) + assertEquals("terminal", message.toolName) + assertEquals(setOf("uuid-tool", "rest-session-456-42"), fakeRepo.dao.idsForSession("session-456")) + } + + @Test + fun paging_emptyServerPageDoesNotHideOlderOfflineHistory() = + runTest { + val api = ApiClient.hermesApi + val expectedIds = seedPagingCache(350) + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(IntRange.EMPTY) + + viewModel.switchSession("session-456") + advanceUntilIdle() + + assertEquals(150, viewModel.uiState.value.messages.size) + assertTrue(viewModel.uiState.value.hasOlderMessages) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(300, viewModel.uiState.value.messages.size) + assertEquals(expectedIds.toSet(), fakeRepo.dao.idsForSession("session-456")) + } + + @Test + fun paging_cacheFirst_serverHydrationDoesNotInflateInitialWindow() = + runTest { + val api = ApiClient.hermesApi + seedPagingCache(10_000) + val response = + CompletableDeferred>() + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + + viewModel.switchSession("session-456") + runCurrent() + val cacheSize = viewModel.uiState.value.messages.size + response.complete(pagingResponse(1..150)) + advanceUntilIdle() + + assertEquals(150, cacheSize) + assertEquals(300, viewModel.uiState.value.messages.size) + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(viewModel.uiState.value.hasOlderMessages) + assertEquals(0, fakeRepo.dao.fullSessionReads) + } + + @Test + fun paging_serverFirst_cacheCursorStillAllowsOfflineHistory() = + runTest { + val api = ApiClient.hermesApi + seedPagingCache(350) + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(IntRange.EMPTY) + + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals(150, viewModel.uiState.value.messages.size) + assertTrue(viewModel.uiState.value.hasOlderMessages) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(300, viewModel.uiState.value.messages.size) + assertFalse(viewModel.uiState.value.isLoadingOlder) + } + + @Test + fun paging_pendingOlderPageIsSuppressedAndFailureAllowsRetry() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(151..300) + viewModel.switchSession("session-456") + advanceUntilIdle() + val pending = + CompletableDeferred>() + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } coAnswers { pending.await() } + + viewModel.loadOlderMessages() + runCurrent() + repeat(5) { viewModel.loadOlderMessages() } + runCurrent() + coVerify(exactly = 1) { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } + assertTrue(viewModel.uiState.value.isLoadingOlder) + pending.complete(retrofit2.Response.error(500, okhttp3.ResponseBody.create(null, "test failure"))) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertTrue(viewModel.uiState.value.hasOlderMessages) + + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns pagingResponse(1..150, offset = 150) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(300, viewModel.uiState.value.messages.size) + assertFalse(viewModel.uiState.value.isLoadingOlder) + } + + @Test + fun paging_oldSessionResponseCannotReplaceNewSession() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(151..300) + coEvery { + api.getSessionMessages("session-other", any(), any(), any(), any()) + } returns pagingResponse(900..900) + viewModel.switchSession("session-456") + advanceUntilIdle() + val pending = + CompletableDeferred>() + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } coAnswers { pending.await() } + + viewModel.loadOlderMessages() + runCurrent() + viewModel.switchSession("session-other") + runCurrent() + pending.complete(pagingResponse(1..150, offset = 150)) + advanceUntilIdle() + + assertEquals("session-other", viewModel.uiState.value.currentSessionId) + assertEquals( + listOf("rest-session-other-900"), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertFalse(viewModel.uiState.value.isLoadingOlder) + } + + /** Holds CPU continuations without sleeping or starting a real thread. */ + private class QueuedHistoryDispatcher : kotlinx.coroutines.CoroutineDispatcher() { + private val queue = ArrayDeque() + val pending: Boolean get() = queue.isNotEmpty() + + override fun dispatch( + context: kotlin.coroutines.CoroutineContext, + block: Runnable, + ) { + queue.addLast(block) + } + + fun runNext() = queue.removeFirst().run() + } + + private fun TestScope.drainHistory(dispatcher: QueuedHistoryDispatcher) { + repeat(100) { + runCurrent() + if (!dispatcher.pending) return + dispatcher.runNext() + } + error("History did not settle after 100 CPU continuations") + } + + @Test + fun paging_liveMessageArrivingBeforeComputedSnapshotCommitSurvives() = + runTest { + val api = ApiClient.hermesApi + val cpu = QueuedHistoryDispatcher() + val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu) + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(1..150) + viewModel.switchSession("session-456") + runCurrent() + // Cache query runs first; hydration has already captured an empty snapshot. + cpu.runNext() + runCurrent() + mockEventsFlow.emit(WsEvent.MessageComplete("Live during merge", "session-456")) + cpu.runNext() + // The queued WS event runs before the computed result can commit on Main. + runCurrent() + drainHistory(cpu) + + val messages = viewModel.uiState.value.messages + assertEquals(151, messages.size) + assertEquals(1, messages.count { it.content == "Live during merge" }) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun paging_sessionSwitchDiscardsQueuedCpuResult() = + runTest { + val api = ApiClient.hermesApi + val cpu = QueuedHistoryDispatcher() + val (viewModel, _) = createViewModelWithSession(historyDispatcher = cpu) + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(1..150) + coEvery { + api.getSessionMessages("session-other", any(), any(), any(), any()) + } returns pagingResponse(900..900) + viewModel.switchSession("session-456") + runCurrent() + cpu.runNext() + runCurrent() + cpu.runNext() + viewModel.switchSession("session-other") + drainHistory(cpu) + + assertEquals("session-other", viewModel.uiState.value.currentSessionId) + assertEquals( + listOf("rest-session-other-900"), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun paging_reconnectInvalidatesPendingOlderRequestAndItsCursor() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(301..450) + viewModel.switchSession("session-456") + advanceUntilIdle() + val oldResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "runtime-456"))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + val pending = + CompletableDeferred>() + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } coAnswers { pending.await() } + viewModel.loadOlderMessages() + runCurrent() + + coEvery { + api.getSessionMessages("session-456", 150, 0, "latest", any()) + } returns pagingResponse(501..650) + mockConnectionStatus.value = ConnectionStatus.RECONNECTING + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertFalse(viewModel.sendMessage("preserved draft")) + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + runCurrent() + pending.complete(pagingResponse(1..150, offset = 150)) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertFalse( + viewModel.uiState.value.messages + .any { it.id == "rest-session-456-1" }, + ) + + mockEventsFlow.emit(WsEvent.RpcResult(oldResumeId, mapOf("session_id" to "stale-runtime"))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + val freshResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(freshResumeId, mapOf("session_id" to "fresh-runtime"))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns pagingResponse(351..500, offset = 150) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.messages + .any { it.id == "rest-session-456-500" }, + ) + coVerify(exactly = 0) { + api.getSessionMessages("session-456", 150, 300, "latest", any()) + } + } + + @Test + fun paging_offlineCacheRemainsReadableAndFreshResumeRestoresSendReadiness() = + runTest { + val ids = seedPagingCache(350) + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + viewModel.switchSession("session-456") + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isSessionReady) + assertFalse(viewModel.sendMessage("draft")) + mockConnectionStatus.value = ConnectionStatus.NO_NETWORK + runCurrent() + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals( + ids.takeLast(300), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertFalse(viewModel.uiState.value.isLoadingOlder) + val api = ApiClient.hermesApi + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(IntRange.EMPTY) + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isSessionReady) + val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456"))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + assertTrue(viewModel.sendMessage("draft")) + advanceUntilIdle() + assertEquals( + 1, + viewModel.uiState.value.messages + .count { it.content == "draft" }, + ) + } + + @Test + fun paging_cacheAndServerCursorsAdvanceIndependently() = + runTest { + val api = ApiClient.hermesApi + val cachedIds = seedPagingCache(350) + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(151..300) + viewModel.switchSession("session-456") + advanceUntilIdle() + repeat(2) { + viewModel.loadOlderMessages() + advanceUntilIdle() + } + coVerify(exactly = 1) { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } + assertEquals( + cachedIds.toSet() + (151..300).map { "rest-session-456-$it" }, + viewModel.uiState.value.messages + .map { it.id } + .toSet(), + ) + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns pagingResponse(1..150, offset = 150) + viewModel.loadOlderMessages() + advanceUntilIdle() + + // The remaining cache page contains only the latest REST rows already on screen. + // This same action must reach the independent server cursor, not stop on those echoes. + coVerify(exactly = 1) { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } + val expectedIds = cachedIds.toSet() + (1..300).map { "rest-session-456-$it" } + assertEquals(650, viewModel.uiState.value.messages.size) + assertEquals( + expectedIds, + viewModel.uiState.value.messages + .map { it.id } + .toSet(), + ) + assertEquals(expectedIds, fakeRepo.dao.idsForSession("session-456")) + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertEquals(0, fakeRepo.dao.fullSessionReads) + assertTrue(fakeRepo.dao.pageLimits.all { it == 151 }) + } + + @Test + fun paging_latestGrowthBetweenPagesAndSyncNeverSkipsOlderRows() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + var total = 450 + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { + val offset = arg(2) + val end = (total - offset).coerceAtLeast(0) + val start = (end - 150).coerceAtLeast(0) + 1 + pagingResponse(start..end, offset) + } + viewModel.switchSession("session-456") + advanceUntilIdle() + viewModel.loadOlderMessages() + advanceUntilIdle() + total = 460 + viewModel.syncCurrentSession() + advanceUntilIdle() + repeat(2) { + viewModel.loadOlderMessages() + advanceUntilIdle() + } + + assertEquals( + (1..460).map { "rest-session-456-$it" }.toSet(), + viewModel.uiState.value.messages + .map { it.id } + .toSet(), + ) + assertFalse(viewModel.uiState.value.hasOlderMessages) + } + + @Test + fun paging_rawHiddenRowsStillAdvanceServerCursor() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + val response = pagingResponse(1..150).body()!! + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns + retrofit2.Response.success( + response.copy(messages = response.messages.map { it.copy(content = JsonPrimitive("")) }), + ) + viewModel.switchSession("session-456") + advanceUntilIdle() + assertTrue(viewModel.uiState.value.hasOlderMessages) + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns pagingResponse(IntRange.EMPTY, offset = 150) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertFalse(viewModel.uiState.value.hasOlderMessages) + coVerify(exactly = 1) { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } + } + + @Test + fun paging_cacheReadExceptionSettlesAndAllowsRetry() = + runTest { + val ids = seedPagingCache(350) + stubSession456Rests(success = false) + fakeRepo.dao.beforeRead = { error("Synthetic Room read failure") } + val (viewModel, _) = createViewModelWithSession() + viewModel.switchSession("session-456") + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(viewModel.uiState.value.hasOlderMessages) + fakeRepo.dao.beforeRead = {} + viewModel.loadOlderMessages() + advanceUntilIdle() + + assertEquals( + ids.takeLast(150), + viewModel.uiState.value.messages + .map { it.id }, + ) + assertFalse(viewModel.uiState.value.isLoadingOlder) + } + + @Test + fun paging_mapperExceptionSettlesAndRetriesSameServerOffset() = + runTest { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(151..300) + viewModel.switchSession("session-456") + advanceUntilIdle() + val invalid = pagingResponse(1..150, 150).body()!! + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns retrofit2.Response.success(invalid.copy(messages = invalid.messages.map { it.copy(id = null) })) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isLoadingOlder) + assertTrue(viewModel.uiState.value.hasOlderMessages) + coEvery { + api.getSessionMessages("session-456", 150, 150, "latest", any()) + } returns pagingResponse(1..150, 150) + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals(300, viewModel.uiState.value.messages.size) + } + + private suspend fun TestScope.assertOverlappingHistoryPageKeepsOrder(equalTimestamps: Boolean) { + val api = ApiClient.hermesApi + val (viewModel, _) = createViewModelWithSession() + (1..350).forEach { index -> + fakeRepo.dao.addMessageDirect( + com.m57.hermescontrol.data.local.ChatMessageEntity( + id = "rest-session-456-$index", + sessionId = "session-456", + role = "ASSISTANT", + content = "Server message $index", + timestamp = if (equalTimestamps) 1_000L else index * 1_000L, + ), + ) + } + coEvery { api.getSessionMessages("session-456", any(), any(), any(), any()) } coAnswers { + val offset = arg(2) + val end = 350 - offset + val response = pagingResponse((end - 149).coerceAtLeast(1)..end, offset).body()!! + retrofit2.Response.success( + if (equalTimestamps) { + response.copy(messages = response.messages.map { it.copy(timestamp = JsonPrimitive(1)) }) + } else { + response + }, + ) + } + viewModel.switchSession("session-456") + advanceUntilIdle() + repeat(2) { + viewModel.loadOlderMessages() + advanceUntilIdle() + } + val before = + viewModel.uiState.value.messages + .map { it.id } + assertEquals((1..350).map { "rest-session-456-$it" }.toSet(), before.toSet()) + assertEquals(350, before.size) + coVerify(exactly = 1) { api.getSessionMessages("session-456", any(), any(), any(), any()) } + // Local history is exhausted. Both subsequent server pages overlap it completely. + repeat(2) { + viewModel.loadOlderMessages() + advanceUntilIdle() + assertEquals( + before, + viewModel.uiState.value.messages + .map { it.id }, + ) + } + assertFalse(viewModel.uiState.value.hasOlderMessages) + coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 150, "latest", any()) } + coVerify(exactly = 1) { api.getSessionMessages("session-456", 150, 300, "latest", any()) } + } + + @Test + fun paging_cacheExhaustedOverlappingServerPageKeepsExistingOrder() = + runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = false) } + + @Test + fun paging_cacheExhaustedOverlapWithEqualTimestampsKeepsExistingOrder() = + runTest { assertOverlappingHistoryPageKeepsOrder(equalTimestamps = true) } + // โ”€โ”€ Attachment open (issue #724) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt new file mode 100644 index 00000000..0df28900 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys +import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns +import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex +import org.junit.Assert.assertEquals +import org.junit.Test + +class FullBleedItemKeysTest { + @Test + fun keysIncludeReasoningToolsAndSystemButSkipEmptyProse() { + val turns = + groupIntoTurns( + listOf( + ChatMessage("u", MessageRole.USER, "question"), + ChatMessage("empty", MessageRole.ASSISTANT, ""), + ChatMessage("t", MessageRole.TOOL, "output"), + ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"), + ChatMessage("s", MessageRole.SYSTEM, "event"), + ), + ) + assertEquals(listOf("user-u", "reasoning-a", "tool-t", "prose-a", "sys-s"), fullBleedItemKeys(turns)) + assertEquals(mapOf("u" to 0, "a" to 3), messageIdToLazyIndex(turns)) + assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2)) + } + + @Test + fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { + val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") + val older = + (0..149).map { + ChatMessage("old-$it", MessageRole.ASSISTANT, "answer", reasoningText = "thinking") + } + assertEquals(listOf("tool-live-tool"), fullBleedItemKeys(groupIntoTurns(listOf(tool)))) + val keys = fullBleedItemKeys(groupIntoTurns(older + tool)) + assertEquals("reasoning-old-0", keys.first()) + assertEquals(151, keys.indexOf("tool-live-tool")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt index 12fe98ca..b06cbaba 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt @@ -104,6 +104,172 @@ class ReadNotificationReviewRegressionTest { assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId }) } + @Test + fun pagingMergePreservesBothIdentitiesAndDoesNotConsumeOlderRepeatedReply() { + val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp") + val newest = listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))) + val mapped = mapServerMessages("session", newest, 0, true, listOf(live)) + val hydrated = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true) + assertEquals("ws", hydrated.single().id) + assertEquals("rest-session-200", hydrated.single().restId) + assertEquals("comp", hydrated.single().completionId) + val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done"))) + val page = mapServerMessages("session", older, 150, true, hydrated, isPagingOlder = true) + val merged = mergeTranscriptWithLive(page, hydrated, chronological = false, preserveLiveIds = true) + assertEquals(listOf("rest-session-100", "ws"), merged.map { it.id }) + assertEquals(listOf(null, "comp"), merged.map { it.completionId }) + assertEquals("rest-session-200", merged.last().restId) + // A later refresh of ONLY the old row must not reassign the confirmed UUID. + assertNull(mapServerMessages("session", older, 0, true, hydrated).single().completionId) + assertEquals("comp", mapServerMessages("session", newest, 0, true, hydrated).single().completionId) + } + + @Test + fun olderPageAndCacheMergeCannotClaimUnconfirmedNotificationByText() { + val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp") + val older = listOf(SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done"))) + val page = mapServerMessages("session", older, 150, true, listOf(live), isPagingOlder = true) + val merged = mergeTranscriptWithLive(page, listOf(live), chronological = false, preserveLiveIds = true) + assertEquals(2, merged.size) + assertNull(merged.single { it.id == "rest-session-100" }.completionId) + assertNull(merged.single { it.id == "ws" }.restId) + assertEquals(2, mergeCachedTranscriptPage(page, listOf(live)).size) + } + + @Test + fun repeatedCompletionMatchesStayOnTheSameLiveKeysAfterMerge() { + val live = + listOf( + ChatMessage(id = "old-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "old"), + ChatMessage(id = "new-ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "new"), + ) + val page = + mapServerMessages( + "session", + listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))), + 0, + true, + live, + ) + val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true) + assertEquals("rest-session-200", merged.single { it.id == "new-ws" }.restId) + assertNull(merged.single { it.id == "old-ws" }.restId) + assertEquals("new", merged.single { it.id == "new-ws" }.completionId) + } + + @Test + fun durableTargetWinsOverRepeatedLiveTextEvenWithLegacyPositions() { + ReplyNotificationTracker.setTargetForTest( + ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 100), + ) + val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp") + val rows = + listOf( + SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")), + SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")), + ) + for (latest in listOf(true, false)) { + val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context) + assertEquals(listOf("comp", null), mapped.map { it.completionId }) + val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true) + assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId) + assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId) + } + } + + @Test + fun exactCacheAndRestOverlapsRetainCompletionWhenPreferredCopyHasNone() { + val live = + ChatMessage( + id = "ws", + role = MessageRole.ASSISTANT, + content = "Done", + restId = "rest-session-200", + completionId = "comp", + ) + val rest = live.copy(id = "rest-session-200", restId = null, completionId = null) + assertEquals("comp", dedupeCachedMessages(listOf(live, rest)).single().completionId) + assertEquals("comp", mergeCachedTranscriptPage(listOf(rest), listOf(live)).single().completionId) + assertEquals("comp", mergeTranscriptWithLive(listOf(rest), listOf(live)).single().completionId) + val restored = rest.copy(completionId = "comp").toEntity("session").toUiModel() + val richWithoutCompletion = live.copy(completionId = null) + val merged = mergeCachedTranscriptPage(listOf(restored), listOf(richWithoutCompletion)).single() + assertEquals("ws", merged.id) + assertEquals("rest-session-200", merged.restId) + assertEquals("comp", merged.completionId) + } + + @Test + fun durableHydrationFoldsMixedCacheEchoIntoRichLiveReply() { + ReplyNotificationTracker.setTargetForTest( + ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = 200), + ) + val live = ChatMessage(id = "ws", role = MessageRole.ASSISTANT, content = "Done", completionId = "comp") + val echo = live.copy(id = "rest-session-200", completionId = null) + val current = listOf(echo, live) + val mapped = + mapServerMessages( + "session", + listOf(SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done"))), + 0, + true, + current, + ) + val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single() + assertEquals("ws", merged.id) + assertEquals("rest-session-200", merged.restId) + assertEquals("comp", merged.completionId) + } + + @Test + fun cachedNewestDuplicateWithoutDurableIdKeepsCompletionOnNewestLiveReply() { + // #129 review: a metadata-free cached echo must not redirect the newest WS completion. + ReplyNotificationTracker.setTargetForTest( + ReplyNotificationTarget("default", "session", "comp", 1L, serverMessageId = null), + ) + val cachedNewest = + ChatMessage( + id = "rest-session-200", + role = MessageRole.ASSISTANT, + content = "Done", + completionId = null, + ) + val live = + ChatMessage( + id = "ws", + role = MessageRole.ASSISTANT, + content = "Done", + completionId = "comp", + restId = null, + ) + val current = listOf(cachedNewest, live) + val history = + listOf( + SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")), + SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")), + ) + val mapped = + mapServerMessages( + sessionId = "session", + messages = history, + offset = 0, + latestPaging = true, + liveMessages = current, + isPagingOlder = false, + context = context, + ) + val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true) + + assertNull(mapped.single { it.id == "rest-session-100" }.completionId) + assertEquals("comp", mapped.single { it.id == "rest-session-200" }.completionId) + assertEquals(2, merged.size) + assertEquals(setOf("rest-session-100", "ws"), merged.map { it.id }.toSet()) + assertEquals(2, merged.count { it.content == "Done" }) + assertNull(merged.single { it.id == "rest-session-100" }.completionId) + assertEquals("rest-session-200", merged.single { it.id == "ws" }.restId) + assertEquals("comp", merged.single { it.id == "ws" }.completionId) + } + @Test fun viewingOldCachedTextMustNotClearNewReplyNotification() { postTarget("Done") @@ -383,7 +549,11 @@ class ReadNotificationReviewRegressionTest { SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")), ) val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false) - assertEquals("comp-reasoning-media", mapped.single().completionId) + assertEquals(listOf("rest-session-1", "rest-session-2"), mapped.map { it.canonicalRestId }) + assertEquals("thinking step", mapped.first().reasoningText) + assertEquals("", mapped.first().content) + assertNull(mapped.first().completionId) + assertEquals("comp-reasoning-media", mapped.last().completionId) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetchTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetchTest.kt new file mode 100644 index 00000000..69afacde --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatHistoryPrefetchTest.kt @@ -0,0 +1,71 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatHistoryPrefetchTest { + @Test + fun shortUpwardDragCanReachMovementThresholdDuringFling() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(30, 5f, userInput = true, canLoad = true)) + assertTrue(policy.onScroll(25, 30f, userInput = false, canLoad = true)) + assertFalse(policy.onScroll(10, 30f, userInput = false, canLoad = true)) + } + + @Test + fun upwardFlingCanReachPrefetchThresholdAfterDragEnds() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true)) + assertFalse(policy.onScroll(40, 40f, userInput = false, canLoad = true)) + assertTrue(policy.onScroll(25, 40f, userInput = false, canLoad = true)) + assertFalse(policy.onScroll(10, 40f, userInput = false, canLoad = true)) + policy.endGesture() + assertFalse(policy.onScroll(5, 40f, userInput = false, canLoad = true)) + } + + @Test + fun reversingDirectionDisarmsPreviousUpwardIntent() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(70, 30f, userInput = true, canLoad = true)) + assertFalse(policy.onScroll(50, -40f, userInput = true, canLoad = true)) + assertFalse(policy.onScroll(25, 40f, userInput = false, canLoad = true)) + } + + @Test + fun initialTopAndProgrammaticMovementDoNotFetch() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(0, 0f, userInput = true, canLoad = true)) + assertFalse(policy.onScroll(0, 100f, userInput = false, canLoad = true)) + assertFalse(policy.onScroll(0, -100f, userInput = true, canLoad = true)) + } + + @Test + fun prefetchStartsTwentyFiveRowsBeforeTopAfterMeaningfulMovement() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(30, 30f, userInput = true, canLoad = true)) + assertTrue(policy.onScroll(25, 1f, userInput = true, canLoad = true)) + } + + @Test + fun initialTopNeedsDeliberateGestureAndRequestsOnlyOnce() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(0, 10f, userInput = true, canLoad = true)) + assertTrue(policy.onScroll(0, 15f, userInput = true, canLoad = true)) + repeat(100) { + assertFalse(policy.onScroll(0, 50f, userInput = true, canLoad = true)) + } + policy.endGesture() + assertTrue(policy.onScroll(0, 25f, userInput = true, canLoad = true)) + } + + @Test + fun loadingAndExhaustionSuppressRequestsAndNextGestureCanRetry() { + val policy = ChatHistoryPrefetch() + assertFalse(policy.onScroll(20, 50f, userInput = true, canLoad = false)) + policy.endGesture() + assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true)) + policy.endGesture() + assertTrue(policy.onScroll(20, 25f, userInput = true, canLoad = true)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt index 36be9b7f..bc49b346 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeChatMessageDao.kt @@ -2,29 +2,80 @@ package com.m57.hermescontrol.ui.chat.fakes import com.m57.hermescontrol.data.local.ChatMessageDao import com.m57.hermescontrol.data.local.ChatMessageEntity +import com.m57.hermescontrol.data.local.canonicalMessageOrder import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap /** * In-memory [ChatMessageDao] for use in tests. - * Uses [ConcurrentHashMap] for thread safety โ€” no manual synchronization needed. + * Paging mirrors the DAO's numeric cursor; writes use its default order allocation methods. */ class FakeChatMessageDao : ChatMessageDao { private val messages: ConcurrentMap = ConcurrentHashMap() + private val ordering = compareBy { it.sortGroup }.thenBy { it.sortOrder }.thenBy { it.id } + + private var insertionSequence = 0L + + var fullSessionReads = 0 + private set + + var beforeRead: suspend () -> Unit = {} + override suspend fun sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId } - override suspend fun getMessagesForSession(sessionId: String): List = - messages.values + override suspend fun getMessagesForSession(sessionId: String): List { + fullSessionReads++ + beforeRead() + return messages.values .filter { it.sessionId == sessionId } - .sortedBy { it.timestamp } - - override suspend fun upsert(message: ChatMessageEntity) { - messages[message.id] = message + .sortedWith(ordering) } - override suspend fun upsertAll(messageList: List) { - messageList.forEach { messages[it.id] = it } + val pageLimits = mutableListOf() + + override suspend fun getLatestMessagePage( + sessionId: String, + limit: Int, + ): List { + pageLimits += limit + beforeRead() + return messages.values + .filter { it.sessionId == sessionId } + .sortedWith(ordering.reversed()) + .take(limit) + } + + override suspend fun getMessagePage( + sessionId: String, + beforeGroup: Int, + beforeOrder: Long, + beforeId: String, + limit: Int, + ): List { + pageLimits += limit + beforeRead() + return messages.values + .filter { + it.sessionId == sessionId && + ( + it.sortGroup < beforeGroup || + ( + it.sortGroup == beforeGroup && + (it.sortOrder < beforeOrder || (it.sortOrder == beforeOrder && it.id < beforeId)) + ) + ) + }.sortedWith(ordering.reversed()) + .take(limit) + } + + override suspend fun getMessage(id: String): ChatMessageEntity? = messages[id] + + override suspend fun nextLocalOrder(): Long = insertionSequence + 1 + + override suspend fun writeMessage(message: ChatMessageEntity) { + if (message.id !in messages) insertionSequence++ + messages[message.id] = message } override suspend fun deleteMessagesForSession(sessionId: String) { @@ -33,14 +84,25 @@ class FakeChatMessageDao : ChatMessageDao { /** Direct access for test setup โ€” bypasses the suspend modifier. */ fun addMessageDirect(message: ChatMessageEntity) { - messages[message.id] = message + val existing = messages[message.id] + if (existing == null) insertionSequence++ + val order = canonicalMessageOrder(message.restId ?: message.id, message.sessionId) + messages[message.id] = + message.copy( + sortGroup = if (order != null) 0 else 1, + sortOrder = order ?: existing?.sortOrder ?: insertionSequence, + ) } /** Reset all stored messages. */ fun clear() { messages.clear() + insertionSequence = 0L } + fun idsForSession(sessionId: String): Set = + messages.values.filter { it.sessionId == sessionId }.mapTo(mutableSetOf()) { it.id } + /** Returns the number of stored messages. */ fun count(): Int = messages.size } From 2e6ae95fe0ccbd50e69eb6c3d1e9488adde55002 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:57:42 +0300 Subject: [PATCH 06/18] feat(#1216): add cron run history (#1226) --- .../data/model/CronRunHistory.kt | 37 ++++ .../data/remote/HermesApiService.kt | 7 + .../hermescontrol/ui/cron/CronJobsScreen.kt | 159 ++++++++++++++++++ .../ui/cron/CronJobsViewModel.kt | 114 ++++++++++++- app/src/main/res/values/strings.xml | 12 ++ .../data/remote/HermesApiServiceTest.kt | 102 +++++++++++ .../remote/ProfileScopeInterceptorTest.kt | 14 ++ .../ui/cron/CronJobsViewModelTest.kt | 74 ++++++++ 8 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt new file mode 100644 index 00000000..50ad8521 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CronRunHistoryResponse( + val runs: List = emptyList(), + val limit: Int = 20, +) + +/** + * One Hermes Agent cron execution. + * + * The backend exposes cron runs using the same enriched row shape as + * `/api/sessions`, then annotates each row with its owning profile and + * active/archive state. Only [id] is required; older backend rows can omit any + * of the enrichment fields without making the whole history unusable. + */ +@Serializable +data class CronRun( + val id: String, + val source: String? = null, + val model: String? = null, + val title: String? = null, + val started_at: Double? = null, + val ended_at: Double? = null, + val last_active: Double? = null, + val is_active: Boolean = false, + val archived: Boolean = false, + val message_count: Int? = null, + val tool_call_count: Int? = null, + val input_tokens: Int? = null, + val output_tokens: Int? = null, + val preview: String? = null, + val parent_session_id: String? = null, + val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 4157489c..41057877 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse @@ -339,6 +340,12 @@ interface HermesApiService : KanbanApiService { @GET("api/cron/jobs") suspend fun getCronJobs(): Response> + @GET("api/cron/jobs/{id}/runs") + suspend fun getCronJobRuns( + @Path("id") id: String, + @Query("limit") limit: Int = 20, + ): Response + @POST("api/cron/jobs/{id}/pause") suspend fun pauseCronJob( @Path("id") id: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index ddbc7235..b006d543 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -18,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -32,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -49,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -59,6 +63,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -75,6 +80,10 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.util.CronExpressionFormatter +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable fun CronJobsScreen( @@ -224,6 +233,15 @@ fun CronJobsScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { + IconButton( + onClick = { viewModel.openRunHistory(job) }, + ) { + Icon( + imageVector = Icons.Filled.History, + contentDescription = stringResource(R.string.cron_action_history), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton( onClick = { viewModel.openEditJobDialog(job.id) }, ) { @@ -341,6 +359,14 @@ fun CronJobsScreen( ) } + if (state.runHistoryState.isOpen) { + CronRunHistoryDialog( + state = state.runHistoryState, + onRetry = viewModel::retryRunHistory, + onDismiss = viewModel::closeRunHistory, + ) + } + // โ”€โ”€ Delete Confirm Dialog โ”€โ”€ state.deleteTarget?.let { job -> AlertDialog( @@ -361,6 +387,139 @@ fun CronJobsScreen( } } +@Composable +private fun CronRunHistoryDialog( + state: CronRunHistoryState, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val spacing = LocalSpacing.current + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.cron_history_title, state.jobName), + style = MaterialTheme.typography.titleLarge, + ) + }, + text = { + when { + state.isLoading -> { + LoadingState( + modifier = Modifier.height(240.dp), + subtitle = stringResource(R.string.cron_history_loading), + ) + } + + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage, + onRetry = onRetry, + modifier = Modifier.height(240.dp), + ) + } + + state.runs.isEmpty() -> { + EmptyState( + icon = Icons.Filled.History, + title = stringResource(R.string.cron_history_empty_title), + subtitle = stringResource(R.string.cron_history_empty_desc), + modifier = Modifier.height(240.dp), + ) + } + + else -> { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + items(state.runs, key = { it.id }) { run -> + CronRunRow(run) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cron_history_close)) + } + }, + ) +} + +@Composable +private fun CronRunRow(run: CronRun) { + val spacing = LocalSpacing.current + val (statusText, statusType) = + when { + run.is_active -> stringResource(R.string.cron_history_status_active) to StatusBadgeType.SUCCESS + run.archived -> stringResource(R.string.cron_history_status_archived) to StatusBadgeType.NEUTRAL + run.ended_at != null -> stringResource(R.string.cron_history_status_completed) to StatusBadgeType.INFO + else -> stringResource(R.string.cron_history_status_inactive) to StatusBadgeType.NEUTRAL + } + val startedAt = formatCronRunTime(run.started_at) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = spacing.xs), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = startedAt ?: run.id, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + StatusBadge(text = statusText, status = statusType) + } + if (!run.profile.isNullOrBlank()) { + Text( + text = stringResource(R.string.cron_history_profile, run.profile), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (run.message_count != null) { + Text( + text = stringResource(R.string.cron_history_messages, run.message_count), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!run.preview.isNullOrBlank()) { + Text( + text = run.preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun formatCronRunTime(epochSeconds: Double?): String? { + if (epochSeconds == null) return null + return runCatching { + Instant + .ofEpochMilli((epochSeconds * 1000).toLong()) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) + }.getOrNull() +} + @Composable fun CronJobEditorDialog( state: CronJobEditorState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index 27821eac..b7b8e9c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ChangeEvents import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange -import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -32,6 +32,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +private const val CRON_RUN_HISTORY_LIMIT = 20 + data class CronJobsUiState( val isLoading: Boolean = false, val jobs: List = emptyList(), @@ -41,6 +43,16 @@ data class CronJobsUiState( val deleteTarget: CronJob? = null, // Editor state val editorState: CronJobEditorState = CronJobEditorState(), + val runHistoryState: CronRunHistoryState = CronRunHistoryState(), +) + +data class CronRunHistoryState( + val isOpen: Boolean = false, + val jobId: String? = null, + val jobName: String = "", + val isLoading: Boolean = false, + val runs: List = emptyList(), + val errorMessage: String? = null, ) data class CronJobEditorState( @@ -100,10 +112,13 @@ class CronJobsViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null + private var runHistoryJob: Job? = null internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val jobsCache = SwrCache>() fun clearScopeOwnedState() { + runHistoryJob?.cancel() + runHistoryJob = null _uiState.update { it.copy( isLoading = false, @@ -111,6 +126,7 @@ class CronJobsViewModel : errorMessage = null, deleteTarget = null, editorState = CronJobEditorState(), + runHistoryState = CronRunHistoryState(), ) } } @@ -155,6 +171,100 @@ class CronJobsViewModel : ) } + fun openRunHistory(job: CronJob) { + runHistoryJob?.cancel() + _uiState.update { + it.copy( + runHistoryState = + CronRunHistoryState( + isOpen = true, + jobId = job.id, + jobName = job.name, + isLoading = true, + ), + ) + } + loadRunHistory(job.id) + } + + fun retryRunHistory() { + val jobId = _uiState.value.runHistoryState.jobId ?: return + _uiState.update { + it.copy( + runHistoryState = + it.runHistoryState.copy( + isLoading = true, + errorMessage = null, + ), + ) + } + loadRunHistory(jobId) + } + + fun closeRunHistory() { + runHistoryJob?.cancel() + runHistoryJob = null + _uiState.update { it.copy(runHistoryState = CronRunHistoryState()) } + } + + private fun loadRunHistory(jobId: String) { + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + runHistoryJob = + viewModelScope.launch { + val result = + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getCronJobRuns( + id = jobId, + limit = CRON_RUN_HISTORY_LIMIT, + ) + } + } + val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + val history = _uiState.value.runHistoryState + if (!history.isOpen || + history.jobId != jobId || + (requestScope != null && currentScope != requestScope) + ) { + return@launch + } + + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + runHistoryState = + it.runHistoryState.copy( + isLoading = false, + runs = result.data.runs, + errorMessage = null, + ), + ) + } + } + + is NetworkResult.Failure -> { + val message = + if (result.error is NetworkError.Http && result.error.code == 404) { + "Run history is unavailable on this Hermes Agent version." + } else { + "Failed to load run history: ${result.error.message}" + } + _uiState.update { + it.copy( + runHistoryState = + it.runHistoryState.copy( + isLoading = false, + runs = emptyList(), + errorMessage = message, + ), + ) + } + } + } + } + } + fun pauseCronJob(id: String) { val originalJobs = _uiState.value.jobs val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 374f6406..ed803776 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1148,6 +1148,7 @@ Pause Resume Run + Run history Edit Add cron job Edit Cron Job @@ -1188,6 +1189,17 @@ You have unsaved changes. Discard them? Delete job? Delete \"%1$s\"? This can\'t be undone. + %1$s runs + Loading run history + No runs yet + Recent executions for this job will appear here. + Active + Archived + Completed + Inactive + Profile: %1$s + Messages: %1$d + Close No achievements yet diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt index a037dd9b..c8b16601 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt @@ -99,6 +99,108 @@ class HermesApiServiceTest { assertEquals("GET", recordedRequest.method) } + @Test + fun testGetCronJobRuns_requestsBoundedHistoryAndParsesResponse() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "runs": [ + { + "id": "cron_job_1_1700000000", + "source": "cron", + "model": "gpt", + "title": "Backup", + "started_at": 1700000000.5, + "ended_at": 1700000020.0, + "last_active": 1700000020.0, + "is_active": false, + "archived": true, + "message_count": 2, + "tool_call_count": 1, + "input_tokens": 123, + "output_tokens": 45, + "preview": "done", + "profile": "worker" + } + ], + "limit": 20 + } + """.trimIndent(), + ), + ) + + val response = apiService.getCronJobRuns("job_1", limit = 20) + + assertTrue(response.isSuccessful) + val run = response.body()?.runs?.single() + assertNotNull(run) + assertEquals("cron_job_1_1700000000", run?.id) + assertEquals("worker", run?.profile) + assertEquals(true, run?.archived) + assertEquals(2, run?.message_count) + assertEquals("done", run?.preview) + + val recordedRequest = mockWebServer.takeRequest() + assertEquals("/api/cron/jobs/job_1/runs?limit=20", recordedRequest.path) + assertEquals("GET", recordedRequest.method) + } + + @Test + fun testGetCronJobRuns_emptyResponse() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"runs":[],"limit":20}"""), + ) + + val response = apiService.getCronJobRuns("job_1") + + assertTrue(response.isSuccessful) + assertTrue(response.body()?.runs?.isEmpty() == true) + } + + @Test + fun testGetCronJobRuns_missingOptionalFieldsUsesDefaults() = + runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"runs":[{"id":"cron_job_1_1"}],"limit":20}"""), + ) + + val run = + apiService + .getCronJobRuns("job_1") + .body() + ?.runs + ?.single() + + assertNotNull(run) + assertNull(run?.started_at) + assertEquals(false, run?.is_active) + assertEquals(false, run?.archived) + } + + @Test + fun testGetCronJobRuns_missingRequiredIdThrows() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"runs":[{"started_at":1.0}],"limit":20}"""), + ) + + assertThrows(kotlinx.serialization.SerializationException::class.java) { + runTest { + apiService.getCronJobRuns("job_1") + } + } + } + @Test fun testPauseCronJob_sendsPostRequest() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index 97a5a730..829ddcbc 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -125,6 +125,20 @@ class ProfileScopeInterceptorTest { } } + @Test + fun cronRunHistory_preservesLimitAndFollowsActiveProfileAcrossSwitches() { + val client = clientFor("alpha") + + val alphaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") + assertEquals("alpha", alphaUrl.queryParameter("profile")) + assertEquals("20", alphaUrl.queryParameter("limit")) + + AuthManager.setActiveProfileId("beta") + val betaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") + assertEquals("beta", betaUrl.queryParameter("profile")) + assertEquals("20", betaUrl.queryParameter("limit")) + } + @Test fun skillsEndpoint_isScoped() { val client = clientFor("alpha") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index fd1cc2c4..be16d085 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJobFireError +import com.m57.hermescontrol.data.model.CronRun +import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -19,6 +21,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -125,6 +128,77 @@ class CronJobsViewModelTest { assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions) } + @Test + fun `openRunHistory loads bounded recent runs`() { + coEvery { mockApi.getCronJobRuns("j1", 20) } returns + Response.success( + CronRunHistoryResponse( + runs = + listOf( + CronRun( + id = "cron_j1_1", + started_at = 1_700_000_000.0, + is_active = true, + profile = "default", + ), + ), + limit = 20, + ), + ) + val vm = createViewModel() + + vm.openRunHistory(CronJob(id = "j1", name = "Morning")) + settle() + + val history = vm.uiState.value.runHistoryState + assertTrue(history.isOpen) + assertFalse(history.isLoading) + assertEquals("j1", history.jobId) + assertEquals("Morning", history.jobName) + assertEquals(listOf("cron_j1_1"), history.runs.map { it.id }) + coVerify(exactly = 1) { mockApi.getCronJobRuns("j1", 20) } + } + + @Test + fun `openRunHistory explains unsupported backend`() { + coEvery { mockApi.getCronJobRuns("j1", 20) } returns + Response.error(404, "{}".toResponseBody()) + val vm = createViewModel() + + vm.openRunHistory(CronJob(id = "j1", name = "Morning")) + settle() + + val history = vm.uiState.value.runHistoryState + assertFalse(history.isLoading) + assertTrue(history.runs.isEmpty()) + assertTrue(history.errorMessage.orEmpty().contains("unavailable")) + } + + @Test + fun `scope reset cancels in flight run history and clears rows`() { + val response = CompletableDeferred>() + coEvery { mockApi.getCronJobRuns("j1", 20) } coAnswers { response.await() } + val vm = createViewModel() + + vm.openRunHistory(CronJob(id = "j1", name = "Morning")) + testDispatcher.scheduler.runCurrent() + vm.clearScopeOwnedState() + response.complete( + Response.success( + CronRunHistoryResponse( + runs = listOf(CronRun(id = "cron_j1_stale", profile = "old-profile")), + ), + ), + ) + settle() + + val history = vm.uiState.value.runHistoryState + assertFalse(history.isOpen) + assertFalse(history.isLoading) + assertTrue(history.runs.isEmpty()) + assertNull(history.jobId) + } + @Test fun `selectBlueprint seeds slot defaults`() { val vm = createViewModel() From 16967c7e918e7e3663faa61b9411173ebb9e8676 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:29:06 +0300 Subject: [PATCH 07/18] feat(#1217): add compacted-session timeline navigation (#1227) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 2 + .../data/model/SessionMessagesResponse.kt | 14 + .../data/model/SessionTimelineResponse.kt | 53 ++++ .../data/remote/HermesApiService.kt | 18 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 116 +++++-- .../ui/chat/ChatServerMessageMapper.kt | 12 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 277 ++++++++++++++++ .../chat/components/ChatScrollController.kt | 8 +- .../ui/chat/components/ChatTimelineSheet.kt | 220 +++++++++++++ app/src/main/res/values/strings.xml | 13 + .../HermesApiServiceMockWebServerTest.kt | 130 ++++++++ .../ui/chat/ChatViewModelTest.kt | 295 ++++++++++++++++++ 12 files changed, 1124 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 505fd402..5f04473b 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -59,6 +59,7 @@ class ChatScreenTest { val mockViewModel = mockk(relaxed = true) every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() + every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow โ€” a relaxed // mock proxy would crash the cast inside collectAsStateWithLifecycle. // A real controller (never started here) stays invisible. @@ -105,6 +106,7 @@ class ChatScreenTest { val mockViewModel = mockk(relaxed = true) every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() + every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() every { mockViewModel.actionProgress } returns ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index c05002a3..9ea2521a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -42,6 +42,12 @@ data class SessionMessage( val reasoning: JsonElement? = null, val reasoning_text: JsonElement? = null, val tool_call_id: String? = null, + /** + * User-visible projection supplied by the backend for rows whose physical + * [content] is model-facing scaffolding (notably compaction summaries). + * Prefer this for rendering whenever present. + */ + val display_content: JsonElement? = null, /** * Timeline-marker tag (backend NS-656 lineage, issue #904): markers like * `model_switch` / `personality_switch` / `auto_continue` ride as @@ -85,6 +91,14 @@ data class SessionMessage( else -> content.toString() } + val displayContentText: String? + get() = + when (display_content) { + is JsonPrimitive -> display_content.content + null -> null + else -> display_content.toString() + } + val reasoningText: String get() = when (val r = reasoning ?: reasoning_text) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt new file mode 100644 index 00000000..d9d22cbc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt @@ -0,0 +1,53 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** Lightweight prompt index returned by GET /api/sessions/{id}/timeline. */ +@Serializable +data class SessionTimelineResponse( + val session_id: String? = null, + val profile: String? = null, + val entries: List = emptyList(), + val pagination: SessionTimelinePagination = SessionTimelinePagination(), +) + +@Serializable +data class SessionTimelineEntry( + /** Stable representative row id. Pass this exact value to /messages/around. */ + val row_id: Int, + val preview: String = "", + val timestamp: JsonElement? = null, +) + +@Serializable +data class SessionTimelinePagination( + val limit: Int? = null, + val after_row_id: Int? = null, + val returned: Int? = null, + val total: Int? = null, + val has_more: Boolean = false, + /** Backend cursor. It is not interchangeable with the last returned row_id. */ + val next_cursor: Int? = null, +) + +/** Bounded chronological display page returned by GET /api/sessions/{id}/messages/around. */ +@Serializable +data class SessionMessagesAroundResponse( + val session_id: String? = null, + val profile: String? = null, + val messages: List = emptyList(), + val pagination: SessionMessagesAroundPagination = SessionMessagesAroundPagination(), +) + +@Serializable +data class SessionMessagesAroundPagination( + val row_id: Int? = null, + val limit: Int? = null, + val returned: Int? = null, + val order: String? = null, + val offset: Int = 0, + val total: Int? = null, + val has_older: Boolean = false, + val has_newer: Boolean = false, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 41057877..8be894c1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -102,10 +102,12 @@ import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.SessionDetailResponse import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse +import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionSearchResponse import com.m57.hermescontrol.data.model.SessionStatsResponse +import com.m57.hermescontrol.data.model.SessionTimelineResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.SkillContentResponse @@ -209,6 +211,22 @@ interface HermesApiService : KanbanApiService { @Query("profile") profile: String? = null, ): Response + @GET("api/sessions/{id}/timeline") + suspend fun getSessionTimeline( + @Path("id", encoded = true) sessionId: String, + @Query("profile") profile: String? = null, + @Query("limit") limit: Int = 500, + @Query("after_row_id") afterRowId: Int = 0, + ): Response + + @GET("api/sessions/{id}/messages/around") + suspend fun getSessionMessagesAround( + @Path("id", encoded = true) sessionId: String, + @Query("row_id") rowId: Int, + @Query("profile") profile: String? = null, + @Query("limit") limit: Int = 120, + ): Response + @GET("api/sessions/stats") suspend fun getSessionStats(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 49d7cfc9..daa29ed1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog @@ -86,12 +87,14 @@ import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner +import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy +import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay @@ -149,6 +152,7 @@ fun ChatScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() + val timelineState by viewModel.timelineState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() @@ -156,6 +160,7 @@ fun ChatScreen( // Snapshot-backed search state โ€” read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState + val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -275,6 +280,7 @@ fun ChatScreen( // (issue #682). Replaces the old item-count heuristic that ignored the // streaming tail. LaunchedEffect( + timelineState.isHistorical, state.messages, streamingState.streamingMessage, streamingState.isThinking, @@ -282,6 +288,10 @@ fun ChatScreen( state.todos, state.clarifyRequest, ) { + if (timelineState.isHistorical) { + scrollController.pauseFollowing() + return@LaunchedEffect + } scrollController.onTailChanged( tailKey = tailContentKey( @@ -294,9 +304,15 @@ fun ChatScreen( messageCount = state.messages.size, ) } - val showScrollToBottom by remember { + LaunchedEffect(timelineState.historyAnchorRowId) { + if (timelineState.isHistorical && displayedMessages.isNotEmpty()) { + scrollController.pauseFollowing() + listState.scrollToItem(0) + } + } + val showScrollToBottom by remember(timelineState.isHistorical, displayedMessages) { derivedStateOf { - scrollController.showFab(state.messages.isNotEmpty()) + !timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty()) } } var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -406,7 +422,8 @@ fun ChatScreen( !showSubagentInspectionSheet && state.btwState == null && viewingImage == null && - !connectorsState.isVisible + !connectorsState.isVisible && + !timelineState.isOpen // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -509,6 +526,22 @@ fun ChatScreen( expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }, ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_action_timeline)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.History, + contentDescription = null, + ) + }, + enabled = state.currentSessionId != null, + onClick = { + showSessionMenu = false + viewModel.openTimeline() + }, + modifier = Modifier.testTag("chat_menu_timeline"), + ) + // Chat search lives in this overflow menu (moved from the top bar) DropdownMenuItem( text = { @@ -533,6 +566,7 @@ fun ChatScreen( showSessionMenu = false viewModel.toggleSearch() }, + enabled = !timelineState.isHistorical, modifier = Modifier.testTag("chat_menu_search"), ) @@ -623,6 +657,17 @@ fun ChatScreen( } } + if (timelineState.isHistorical) { + ChatHistoryWindowBanner( + hasOlder = timelineState.historyHasOlder, + hasNewer = timelineState.historyHasNewer, + onReturnToLatest = { + viewModel.returnToLatestMessages() + scrollController.jumpToBottom() + }, + ) + } + Box( modifier = Modifier @@ -659,37 +704,44 @@ fun ChatScreen( // Full-bleed chat renderer (issue #866) โ€” the single chat // surface since the bubble renderer was removed. FullBleedChatList( - messages = state.messages, - streamingState = streamingState, - isAgentTyping = state.isAgentTyping, + messages = displayedMessages, + streamingState = if (timelineState.isHistorical) StreamingState() else streamingState, + isAgentTyping = state.isAgentTyping && !timelineState.isHistorical, searchState = searchState, - typingEffectEnabled = state.typingEffectEnabled, + typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical, typingEffectDelayMs = state.typingEffectDelayMs, messageStatsEnabled = state.messageStatsEnabled, showUserMessageTokens = state.showUserMessageTokens, showAssistantMessageTokens = state.showAssistantMessageTokens, showTokensPerSecond = state.showTokensPerSecond, maxToolCallsPerTurn = state.maxToolCallsPerTurn, - isLoading = state.isLoading, - isLoadingOlder = state.isLoadingOlder, - hasOlderMessages = state.hasOlderMessages, - pagingSessionId = state.currentSessionId, + isLoading = state.isLoading && !timelineState.isHistorical, + isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical, + hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical, + pagingSessionId = + state.currentSessionId?.let { currentSessionId -> + if (timelineState.isHistorical) { + currentSessionId + ":history:" + timelineState.historyAnchorRowId + } else { + currentSessionId + } + }, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, viewModel = viewModel, - clarifyRequest = state.clarifyRequest, + clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical }, onRespondClarify = viewModel::respondToClarify, onRespondClarifyBatch = viewModel::respondToClarifyBatch, onDismissClarify = viewModel::dismissClarify, - vaultUnlockPrompt = state.vaultUnlockPrompt, + vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical }, onRespondVaultUnlock = viewModel::respondToVaultUnlock, onDismissVaultUnlock = viewModel::dismissVaultUnlock, - vaultSaveLoginPrompt = state.vaultSaveLoginPrompt, + vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical }, onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, - vaultCodePrompt = state.vaultCodePrompt, + vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical }, onRespondVaultCode = viewModel::respondToVaultCode, onDismissVaultCode = viewModel::dismissVaultCode, onSaveAttachment = onSaveAttachment, @@ -699,14 +751,18 @@ fun ChatScreen( ) // Loading overlay - ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null) + ChatLoadingOverlay( + isLoading = state.isLoading && state.resumeError == null && !timelineState.isHistorical, + ) // Resume-exhausted overlay โ€” explicit error + Retry instead // of an infinite spinner (desktop parity). - ChatResumeErrorOverlay( - errorMessage = state.resumeError, - onRetry = viewModel::retryResumeSession, - ) + if (!timelineState.isHistorical) { + ChatResumeErrorOverlay( + errorMessage = state.resumeError, + onRetry = viewModel::retryResumeSession, + ) + } // Scroll-to-bottom FAB (issue #682): shows while follow is // paused and renders the unseen-message badge. @@ -718,10 +774,12 @@ fun ChatScreen( // Reaction heartsanimation (purely cosmetic โ€” fades out // automatically after the ViewModel clears the state) - key(state.reactionTriggerId) { - ReactionHeartsOverlay( - reactionKind = state.reactionKind, - ) + if (!timelineState.isHistorical) { + key(state.reactionTriggerId) { + ReactionHeartsOverlay( + reactionKind = state.reactionKind, + ) + } } } @@ -754,7 +812,7 @@ fun ChatScreen( isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, - isSessionReady = state.isSessionReady, + isSessionReady = state.isSessionReady && !timelineState.isHistorical, sessionPreparationFailed = state.resumeError != null, commandCatalog = state.commandCatalog, slashUsageCounts = state.slashUsageCounts, @@ -790,6 +848,14 @@ fun ChatScreen( ) } + ChatTimelineSheet( + state = timelineState, + onDismiss = viewModel::closeTimeline, + onJump = viewModel::jumpToTimelineEntry, + onLoadMore = viewModel::loadMoreTimeline, + onRetry = viewModel::retryTimeline, + ) + if (showReloginDialog) { ReloginDialog( onDismiss = { showReloginDialog = false }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index f4b24abc..5a7ce54d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -21,6 +21,7 @@ internal fun mapServerMessages( latestPaging: Boolean, liveMessages: List, isPagingOlder: Boolean = false, + stableRowIds: Boolean = latestPaging, context: android.content.Context? = null, ): List { val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id } @@ -41,7 +42,7 @@ internal fun mapServerMessages( } fun restIdAt(index: Int): String = - if (latestPaging) { + if (stableRowIds) { "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}" } else { "rest-$sessionId-${offset + index}" @@ -79,7 +80,7 @@ internal fun mapServerMessages( if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue val m = messages[i] if (m.role?.lowercase() in listOf("user", "system", "tool")) continue - val rawContent = m.contentText + val rawContent = m.displayContentText ?: m.contentText if (rawContent.isBlank()) continue val canonicalContent = if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim() @@ -107,13 +108,14 @@ internal fun mapServerMessages( assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList() for (index in messages.indices.reversed()) { val row = messages[index] - if (reasoningSources[index] != null || row.contentText.isBlank() || + val displayContent = row.displayContentText ?: row.contentText + if (reasoningSources[index] != null || displayContent.isBlank() || row.role?.lowercase() in listOf("user", "system", "tool") || liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex ) { continue } - val content = HostMediaExtractor.strip(row.contentText).trim() + val content = HostMediaExtractor.strip(displayContent).trim() val match = remaining.indexOfLast { it.content.trim() == content } if (match >= 0) reasoningSources[index] = remaining.removeAt(match) } @@ -142,7 +144,7 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.contentText + val rawContent = msg.displayContentText ?: msg.contentText val rowReasoning = msg.reasoningText.ifBlank { if (role == MessageRole.ASSISTANT) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 09a0faa1..36e38374 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot @@ -59,6 +60,8 @@ import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" private const val MESSAGE_PAGE_SIZE = 150 +private const val TIMELINE_PAGE_SIZE = 500 +private const val HISTORY_WINDOW_SIZE = 120 private val REASONING_EFFORT_LEVELS = setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") @@ -238,6 +241,23 @@ data class ChatUiState( val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED } +data class ChatTimelineState( + val isOpen: Boolean = false, + val entries: List = emptyList(), + val isLoading: Boolean = false, + val hasMore: Boolean = false, + val nextCursor: Int? = null, + val errorMessage: String? = null, + val jumpingRowId: Int? = null, + val windowErrorMessage: String? = null, + val historyMessages: List? = null, + val historyAnchorRowId: Int? = null, + val historyHasOlder: Boolean = false, + val historyHasNewer: Boolean = false, +) { + val isHistorical: Boolean get() = historyMessages != null +} + data class SessionUi( val id: String, val title: String, @@ -455,6 +475,13 @@ class ChatViewModel( private var olderJob: Job? = null private var syncJob: Job? = null private val historyFetchMutex = Mutex() + private var timelineJob: Job? = null + private var historyWindowJob: Job? = null + private var timelineRequestSequence = 0L + private var activeTimelineRequestSequence = 0L + private var historyWindowRequestSequence = 0L + private var activeHistoryWindowRequestSequence = 0L + private val _timelineState = MutableStateFlow(ChatTimelineState()) // โ”€โ”€ Session resume recovery (desktop parity) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Bounded auto-retry with exponential backoff, mirroring the desktop's @@ -465,6 +492,7 @@ class ChatViewModel( private var resumeRetryAttempt = 0 private var resumeRetryJob: Job? = null val streamingState: StateFlow = _streamingState.asStateFlow() + val timelineState: StateFlow = _timelineState.asStateFlow() /** Tracks the auto-clear coroutine for reaction animations. */ private var reactionClearJob: Job? = null @@ -660,6 +688,7 @@ class ChatViewModel( syncJob?.cancel() cacheJob?.cancel() isSyncingMessages = false + resetTimelineState() cancelResumeRetry() _uiState.update { it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false) @@ -2899,6 +2928,245 @@ class ChatViewModel( fun getCurrentModelCapabilities(): ModelCapabilities? = modelSwitchDelegate.getCurrentModelCapabilities() + fun openTimeline() { + if (_uiState.value.currentSessionId == null || _timelineState.value.isOpen) return + searchDelegate.clearSearch() + _timelineState.update { + it.copy( + isOpen = true, + entries = emptyList(), + isLoading = false, + hasMore = false, + nextCursor = null, + errorMessage = null, + windowErrorMessage = null, + ) + } + loadTimelinePage(reset = true) + } + + fun closeTimeline() { + timelineJob?.cancel() + historyWindowJob?.cancel() + activeTimelineRequestSequence = ++timelineRequestSequence + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.update { + it.copy( + isOpen = false, + isLoading = false, + jumpingRowId = null, + ) + } + } + + fun retryTimeline() { + if (!_timelineState.value.isOpen) return + _timelineState.update { it.copy(windowErrorMessage = null) } + loadTimelinePage(reset = true) + } + + fun loadMoreTimeline() { + val state = _timelineState.value + if (!state.isOpen || state.isLoading || !state.hasMore || state.nextCursor == null) return + loadTimelinePage(reset = false) + } + + private fun loadTimelinePage(reset: Boolean) { + val sessionId = _uiState.value.currentSessionId ?: return + val generation = sessionGeneration + val profile = AuthManager.activeProfileId.value + val cursor = if (reset) 0 else _timelineState.value.nextCursor ?: return + val requestSequence = ++timelineRequestSequence + activeTimelineRequestSequence = requestSequence + timelineJob?.cancel() + _timelineState.update { + it.copy( + isLoading = true, + errorMessage = null, + entries = if (reset) emptyList() else it.entries, + hasMore = if (reset) false else it.hasMore, + nextCursor = if (reset) null else it.nextCursor, + ) + } + val valid = { + generation == sessionGeneration && + sessionId == _uiState.value.currentSessionId && + profile == AuthManager.activeProfileId.value && + requestSequence == activeTimelineRequestSequence + } + timelineJob = + viewModelScope.launch { + try { + val result = + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getSessionTimeline( + sessionId = sessionId, + profile = profile, + limit = TIMELINE_PAGE_SIZE, + afterRowId = cursor, + ) + } + } + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val page = result.data + val nextCursor = page.pagination.next_cursor + _timelineState.update { current -> + if (!valid()) { + current + } else { + val entries = + (if (reset) page.entries else current.entries + page.entries) + .distinctBy { it.row_id } + current.copy( + entries = entries, + hasMore = page.pagination.has_more && nextCursor != null, + nextCursor = nextCursor, + errorMessage = null, + ) + } + } + } + + is NetworkResult.Failure -> { + _timelineState.update { + if (valid()) { + it.copy(errorMessage = "Failed to load timeline: ${result.error.message}") + } else { + it + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + _timelineState.update { it.copy(errorMessage = "Failed to load timeline: ${e.message}") } + } + } finally { + if (valid()) _timelineState.update { it.copy(isLoading = false) } + } + } + } + + fun jumpToTimelineEntry(rowId: Int) { + if (rowId <= 0) return + val sessionId = _uiState.value.currentSessionId ?: return + val generation = sessionGeneration + val profile = AuthManager.activeProfileId.value + val requestSequence = ++historyWindowRequestSequence + activeHistoryWindowRequestSequence = requestSequence + historyWindowJob?.cancel() + _timelineState.update { + it.copy( + jumpingRowId = rowId, + windowErrorMessage = null, + ) + } + val valid = { + generation == sessionGeneration && + sessionId == _uiState.value.currentSessionId && + profile == AuthManager.activeProfileId.value && + requestSequence == activeHistoryWindowRequestSequence + } + historyWindowJob = + viewModelScope.launch { + try { + val result = + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getSessionMessagesAround( + sessionId = sessionId, + rowId = rowId, + profile = profile, + limit = HISTORY_WINDOW_SIZE, + ) + } + } + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val response = result.data + if (response.messages.size > HISTORY_WINDOW_SIZE) { + _timelineState.update { + it.copy(windowErrorMessage = "History response exceeded the page limit.") + } + return@launch + } + val page = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = response.pagination.offset, + latestPaging = false, + liveMessages = _uiState.value.messages, + isPagingOlder = true, + stableRowIds = true, + context = getApplication(), + ) + } + if (!valid()) return@launch + val targetId = "rest-$sessionId-$rowId" + if (page.none { it.id == targetId || it.canonicalRestId == targetId }) { + _timelineState.update { + it.copy(windowErrorMessage = "The selected prompt is no longer available.") + } + return@launch + } + if (!valid()) return@launch + _timelineState.update { + it.copy( + isOpen = false, + historyMessages = page, + historyAnchorRowId = rowId, + historyHasOlder = response.pagination.has_older, + historyHasNewer = response.pagination.has_newer, + windowErrorMessage = null, + ) + } + } + + is NetworkResult.Failure -> { + _timelineState.update { + if (valid()) { + it.copy(windowErrorMessage = "Failed to load message: ${result.error.message}") + } else { + it + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + _timelineState.update { it.copy(windowErrorMessage = "Failed to load message: ${e.message}") } + } + } finally { + if (valid()) _timelineState.update { it.copy(jumpingRowId = null) } + } + } + } + + fun returnToLatestMessages() { + historyWindowJob?.cancel() + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.update { + it.copy( + jumpingRowId = null, + windowErrorMessage = null, + historyMessages = null, + historyAnchorRowId = null, + historyHasOlder = false, + historyHasNewer = false, + ) + } + } + fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return @@ -2972,6 +3240,14 @@ class ChatViewModel( _uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) } } + private fun resetTimelineState() { + timelineJob?.cancel() + historyWindowJob?.cancel() + activeTimelineRequestSequence = ++timelineRequestSequence + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.value = ChatTimelineState() + } + private suspend fun readCachedPage( sessionId: String, generation: Long, @@ -3174,6 +3450,7 @@ class ChatViewModel( hydrationJob?.cancel() olderJob?.cancel() syncJob?.cancel() + resetTimelineState() activeHydrationRequestSequence = 0L cacheCursor = null cacheHasOlder = false diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index 20e76b3d..60a6c506 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -158,11 +158,11 @@ class ChatScrollController( * item count still hasn't caught up (rare, heavy layout), wait briefly * via snapshotFlow on the layout info. */ - private suspend fun scrollToBottomAwaitingLayout() { + private suspend fun scrollToBottomAwaitingLayout(animated: Boolean = false) { // Yield twice: first for recomposition, second for layout. yield() yield() - listState.scrollToBottom(animated = false) + listState.scrollToBottom(animated = animated) // If we're still not at the bottom after the first scroll (e.g. a // new item was laid out between the scroll and now), do one more pass. if (!listState.isAtBottom(bottomPixelTolerance)) { @@ -171,7 +171,7 @@ class ChatScrollController( snapshotFlow { listState.layoutInfo.totalItemsCount } .first { it > 0 } } - listState.scrollToBottom(animated = false) + listState.scrollToBottom(animated = animated) } } @@ -182,7 +182,7 @@ class ChatScrollController( scope.launch { isProgrammaticScroll = true try { - listState.scrollToBottom(animated = animated) + scrollToBottomAwaitingLayout(animated = animated) } finally { isProgrammaticScroll = false } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt new file mode 100644 index 00000000..c3526c83 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt @@ -0,0 +1,220 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ChatTimelineState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatTimelineSheet( + state: ChatTimelineState, + onDismiss: () -> Unit, + onJump: (Int) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, +) { + if (!state.isOpen) return + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.chat_timeline_title), + modifier = Modifier.padding(horizontal = 20.dp), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.chat_timeline_subtitle), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val error = state.windowErrorMessage ?: state.errorMessage + if (error != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = error, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + if (!state.isLoading && state.jumpingRowId == null) { + TextButton(onClick = onRetry) { + Text( + stringResource( + if (state.entries.isEmpty()) { + R.string.chat_timeline_retry + } else { + R.string.chat_timeline_refresh + }, + ), + ) + } + } + } + } + + when { + state.isLoading && state.entries.isEmpty() -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 160.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + state.entries.isEmpty() && state.errorMessage == null -> { + Text( + text = stringResource(R.string.chat_timeline_empty), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + state.entries.isNotEmpty() -> { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 520.dp), + ) { + items( + items = state.entries, + key = { it.row_id }, + ) { entry -> + ListItem( + headlineContent = { + Text( + text = + entry.preview.ifBlank { + stringResource(R.string.chat_timeline_prompt_fallback) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + if (state.jumpingRowId == entry.row_id) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + }, + modifier = + Modifier.clickable( + enabled = state.jumpingRowId == null, + onClick = { onJump(entry.row_id) }, + ), + ) + HorizontalDivider() + } + + if (state.hasMore || (state.isLoading && state.entries.isNotEmpty())) { + item(key = "timeline-load-more") { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + TextButton(onClick = onLoadMore) { + Text(stringResource(R.string.chat_timeline_load_more)) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +fun ChatHistoryWindowBanner( + hasOlder: Boolean, + hasNewer: Boolean, + onReturnToLatest: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.chat_history_window_title), + style = MaterialTheme.typography.labelLarge, + ) + val rangeMessage = + when { + hasOlder && hasNewer -> R.string.chat_history_window_more_both + hasNewer -> R.string.chat_history_window_more_newer + hasOlder -> R.string.chat_history_window_more_older + else -> null + } + if (rangeMessage != null) { + Text( + text = stringResource(rangeMessage), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + ) + } + } + TextButton(onClick = onReturnToLatest) { + Text(stringResource(R.string.chat_history_window_latest)) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed803776..4045ab92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -161,6 +161,19 @@ Bot chats are one continuous conversation โ€” compacting instead. For a throwaway session, create a standard chat. Search Close search + Timeline + Conversation timeline + Jump to a prompt without loading the full transcript. + No prompts yet. + Earlier prompt + Load more + Retry + Refresh + Viewing earlier messages + Back to latest + Earlier messages exist before this window. + Newer messages exist beyond this bounded window. + More messages exist before and after this bounded window. Reconnect Re-login Re-login to Hermes diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index c2ba8202..a221a389 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -9,6 +9,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After 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 @@ -468,6 +469,135 @@ class HermesApiServiceMockWebServerTest { ) } + @Test + fun getSessionTimeline_sendsCursorAndParsesFinalPageWithUnknownFields() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "session_id": "desktop/session", + "profile": "work", + "future_top_level_field": "ignored", + "entries": [ + { + "row_id": 91, + "preview": "First compacted prompt", + "timestamp": 1718000000, + "future_entry_field": {"anything": true} + } + ], + "pagination": { + "limit": 500, + "after_row_id": 77, + "returned": 1, + "total": 1, + "has_more": false, + "next_cursor": null, + "future_pagination_field": "ignored" + } + } + """.trimIndent(), + ), + ) + + val response = + api.getSessionTimeline( + sessionId = "desktop/session", + profile = "work", + limit = 500, + afterRowId = 77, + ) + + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(91, body!!.entries.single().row_id) + assertEquals("First compacted prompt", body.entries.single().preview) + assertFalse(body.pagination.has_more) + assertNull(body.pagination.next_cursor) + assertEquals( + "/api/sessions/desktop/session/timeline?profile=work&limit=500&after_row_id=77", + mockServer.takeRequest().path, + ) + } + + @Test + fun getSessionMessagesAround_sendsStableRowAndPreservesChronologicalOrder() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "session_id": "desktop/session", + "profile": "work", + "messages": [ + { + "id": 42, + "role": "user", + "content": "[model-facing compaction summary]", + "display_content": "target" + }, + {"id": 43, "role": "assistant", "content": "answer"} + ], + "pagination": { + "row_id": 42, + "limit": 120, + "returned": 2, + "order": "oldest", + "offset": 7, + "total": 100, + "has_older": true, + "has_newer": true, + "future_field": 123 + } + } + """.trimIndent(), + ), + ) + + val response = + api.getSessionMessagesAround( + sessionId = "desktop/session", + rowId = 42, + profile = "work", + limit = 120, + ) + + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(listOf(42, 43), body!!.messages.map { it.id }) + assertEquals("target", body.messages.first().displayContentText) + assertEquals("oldest", body.pagination.order) + assertEquals(7, body.pagination.offset) + assertEquals(true, body.pagination.has_older) + assertEquals(true, body.pagination.has_newer) + assertEquals( + "/api/sessions/desktop/session/messages/around?row_id=42&profile=work&limit=120", + mockServer.takeRequest().path, + ) + } + + @Test + fun getSessionMessagesAround_missingPromptReturns404() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(404) + .setBody("""{"detail":"Prompt not found"}"""), + ) + + val response = api.getSessionMessagesAround(sessionId = "session-1", rowId = 999) + + assertFalse(response.isSuccessful) + assertEquals(404, response.code()) + } + @Test fun getOAuthProviders_parsesList() = runBlocking { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 0180dfce..a624c929 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -12,7 +12,12 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination +import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse +import com.m57.hermescontrol.data.model.SessionTimelineEntry +import com.m57.hermescontrol.data.model.SessionTimelinePagination +import com.m57.hermescontrol.data.model.SessionTimelineResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.GatewayFile import com.m57.hermescontrol.data.remote.GatewayFileClient @@ -202,6 +207,28 @@ class ChatViewModelTest { assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421")) } + @Test + fun mapServerMessages_prefersCompactionDisplayContent() { + val mapped = + mapServerMessages( + sessionId = "session-1", + messages = + listOf( + SessionMessage( + id = 42, + role = "user", + content = JsonPrimitive("[model-facing compaction summary]"), + display_content = JsonPrimitive("The original user prompt"), + ), + ), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ) + + assertEquals("The original user prompt", mapped.single().content) + } + @Test fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() { val authored = @@ -6601,6 +6628,274 @@ class ChatViewModelTest { every { AuthManager.activeProfileId } returns MutableStateFlow("default") } + @Test + fun timelinePagesUseBackendCursorAndStopOnFinalPage() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 0) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 10, preview = "first")), + pagination = + SessionTimelinePagination( + limit = 500, + after_row_id = 0, + returned = 1, + total = 2, + has_more = true, + next_cursor = 123, + ), + ), + ) + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 123) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 200, preview = "second")), + pagination = + SessionTimelinePagination( + limit = 500, + after_row_id = 123, + returned = 1, + total = 2, + has_more = false, + next_cursor = null, + ), + ), + ) + + viewModel.openTimeline() + advanceUntilIdle() + + assertEquals( + listOf(10), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + assertEquals(123, viewModel.timelineState.value.nextCursor) + assertTrue(viewModel.timelineState.value.hasMore) + + viewModel.loadMoreTimeline() + advanceUntilIdle() + + assertEquals( + listOf(10, 200), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + assertFalse(viewModel.timelineState.value.hasMore) + assertNull(viewModel.timelineState.value.nextCursor) + coVerify(exactly = 1) { api.getSessionTimeline(sessionId, "default", 500, 123) } + } + + @Test + fun timelineJumpUsesBoundedHistoryWindowWithoutReplacingLiveTail() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val persistedBeforeJump = fakeRepo.dao.count() + val liveMessages = viewModel.uiState.value.messages + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = + listOf( + SessionMessage(id = 42, role = "user", content = JsonPrimitive("target")), + SessionMessage(id = 43, role = "assistant", content = JsonPrimitive("answer")), + ), + pagination = + SessionMessagesAroundPagination( + row_id = 42, + limit = 120, + returned = 2, + order = "oldest", + offset = 7, + total = 100, + has_older = true, + has_newer = true, + ), + ), + ) + + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + + val timeline = viewModel.timelineState.value + assertTrue(timeline.isHistorical) + assertEquals(42, timeline.historyAnchorRowId) + assertTrue(timeline.historyHasOlder) + assertTrue(timeline.historyHasNewer) + assertEquals( + listOf("rest-$sessionId-42", "rest-$sessionId-43"), + timeline.historyMessages!!.map { it.id }, + ) + assertEquals(listOf("target", "answer"), timeline.historyMessages.map { it.content }) + assertEquals(liveMessages, viewModel.uiState.value.messages) + // Direct-address history is a replaceable display window, not a + // contiguous live-cache page. Persisting it would create fake gaps. + assertEquals(persistedBeforeJump, fakeRepo.dao.count()) + + viewModel.returnToLatestMessages() + assertFalse(viewModel.timelineState.value.isHistorical) + assertNull(viewModel.timelineState.value.historyMessages) + assertFalse(viewModel.timelineState.value.historyHasOlder) + assertFalse(viewModel.timelineState.value.historyHasNewer) + } + + @Test + fun missingTimelineRowKeepsCurrentHistoryWindow() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("target"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ) + coEvery { + api.getSessionMessagesAround(sessionId, 999, "default", 120) + } returns + retrofit2.Response.error( + 404, + """{"detail":"Prompt not found"}""".toResponseBody(), + ) + + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + val originalWindow = viewModel.timelineState.value.historyMessages + + viewModel.jumpToTimelineEntry(999) + advanceUntilIdle() + + assertEquals(42, viewModel.timelineState.value.historyAnchorRowId) + assertEquals(originalWindow, viewModel.timelineState.value.historyMessages) + assertNotNull(viewModel.timelineState.value.windowErrorMessage) + } + + @Test + fun retryTimelineClearsWindowErrorBeforeRefreshing() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 0) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 42, preview = "target")), + pagination = SessionTimelinePagination(has_more = false), + ), + ) + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.error( + 404, + """{"detail":"Prompt not found"}""".toResponseBody(), + ) + + viewModel.openTimeline() + advanceUntilIdle() + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + + assertNotNull(viewModel.timelineState.value.windowErrorMessage) + + viewModel.retryTimeline() + + assertNull(viewModel.timelineState.value.windowErrorMessage) + advanceUntilIdle() + assertNull(viewModel.timelineState.value.windowErrorMessage) + assertEquals( + listOf(42), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + coVerify(exactly = 2) { api.getSessionTimeline(sessionId, "default", 500, 0) } + } + + @Test + fun profileSwitchRejectsLateTimelineJumpResponse() = + runTest { + val profileFlow = MutableStateFlow("default") + every { AuthManager.activeProfileId } returns profileFlow + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val response = CompletableDeferred>() + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } coAnswers { + response.await() + } + + viewModel.jumpToTimelineEntry(42) + runCurrent() + profileFlow.value = "other" + mockSwitchFlow.emit("other") + runCurrent() + response.complete( + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ), + ) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.currentSessionId) + assertFalse(viewModel.timelineState.value.isOpen) + assertNull(viewModel.timelineState.value.historyMessages) + assertNull(viewModel.timelineState.value.historyAnchorRowId) + } + + @Test + fun sessionSwitchRejectsLateTimelineJumpResponse() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val response = CompletableDeferred>() + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } coAnswers { + response.await() + } + + viewModel.jumpToTimelineEntry(42) + runCurrent() + viewModel.switchSession("session-other") + runCurrent() + response.complete( + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ), + ) + runCurrent() + + assertEquals("session-other", viewModel.uiState.value.currentSessionId) + assertFalse(viewModel.timelineState.value.isOpen) + assertNull(viewModel.timelineState.value.historyMessages) + assertNull(viewModel.timelineState.value.historyAnchorRowId) + } + /** * The REST high-watermark must be read BEFORE prompt.submit leaves the * device. Capturing it afterwards lets a fast turn persist its assistant row From bf53905a93255235ad3a84ca2978611b9df480f8 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:53:24 +0300 Subject: [PATCH 08/18] feat(#1219): support canonical model capability metadata (#1228) --- .../data/model/ModelInfoResponse.kt | 1 + .../hermescontrol/data/model/ModelOption.kt | 52 +++++++ .../data/remote/HermesApiService.kt | 4 +- .../ui/chat/ChatModelSwitchDelegate.kt | 38 ++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 3 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 4 +- .../components/KanbanModelOverrideEditor.kt | 5 +- .../m57/hermescontrol/ui/model/ModelScreen.kt | 15 +- .../hermescontrol/ui/model/ModelViewModel.kt | 65 ++++++++- .../components/ModelCapabilitySummary.kt | 36 +++++ .../ui/model/components/ModelPickerDialog.kt | 24 +--- .../ModelCapabilitiesSerializationTest.kt | 130 ++++++++++++++++++ .../ui/chat/ChatModelSwitchDelegateTest.kt | 37 +++++ .../ui/model/ModelViewModelTest.kt | 80 +++++++++++ 14 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt index 18351c08..bfb12b97 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelInfoResponse.kt @@ -27,4 +27,5 @@ data class ModelInfoResponse( val config_context_length: Long? = null, /** Effective context window actually in use (max of auto/config + caps). */ val effective_context_length: Long? = null, + val capabilities: ModelCapabilities? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt index 1023322c..7745c462 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelOption.kt @@ -26,8 +26,60 @@ data class ModelCapabilities( val fast: Boolean? = null, val reasoning: Boolean? = null, val can_disable_reasoning: Boolean? = null, + val supports_tools: Boolean? = null, + val supports_vision: Boolean? = null, + val supports_reasoning: Boolean? = null, + val context_window: Long? = null, + val max_output_tokens: Long? = null, + val model_family: String? = null, ) +val ModelCapabilities.reasoningSupport: Boolean? + get() = supports_reasoning ?: reasoning + +fun ModelCapabilities?.mergedWithCanonical(canonical: ModelCapabilities?): ModelCapabilities? { + if (canonical == null) return this + val catalog = this + return ModelCapabilities( + fast = catalog?.fast, + reasoning = catalog?.reasoning, + can_disable_reasoning = catalog?.can_disable_reasoning, + supports_tools = canonical.supports_tools ?: catalog?.supports_tools, + supports_vision = canonical.supports_vision ?: catalog?.supports_vision, + supports_reasoning = canonical.supports_reasoning ?: catalog?.supports_reasoning, + context_window = canonical.context_window ?: catalog?.context_window, + max_output_tokens = canonical.max_output_tokens ?: catalog?.max_output_tokens, + model_family = canonical.model_family ?: catalog?.model_family, + ) +} + +fun List.withModelInfoCapabilities(info: ModelInfoResponse?): List { + val providerSlug = info?.provider?.takeIf { it.isNotBlank() } ?: return this + val modelName = info.model?.takeIf { it.isNotBlank() } ?: return this + val canonical = info.capabilities ?: return this + var changed = false + val enriched = + map { provider -> + if ( + provider.slug.equals(providerSlug, ignoreCase = true) && + provider.models.orEmpty().contains(modelName) + ) { + changed = true + provider.copy( + capabilities = + provider.capabilities.orEmpty() + + ( + modelName to + (provider.capabilities?.get(modelName).mergedWithCanonical(canonical) ?: canonical) + ), + ) + } else { + provider + } + } + return if (changed) enriched else this +} + @Serializable data class PinnedModel( val providerSlug: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8be894c1..38d95160 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -300,7 +300,9 @@ interface HermesApiService : KanbanApiService { ): Response @GET("api/model/info") - suspend fun getModelInfo(): Response + suspend fun getModelInfo( + @Query("profile") profile: String? = null, + ): Response @GET("api/system/stats") suspend fun getSystemStats(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 1667f98b..78b75d7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -3,8 +3,11 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.mergedWithCanonical +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall @@ -77,6 +80,9 @@ class ChatModelSwitchDelegate( private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value private var modelOptionsJob: Job? = null private var modelOptionsJobScope: DataScope? = null + private var modelInfoCapabilities: ModelCapabilities? = null + private var modelInfoModel: String? = null + private var modelInfoScope: DataScope? = null fun attachScopeObserver(externalScope: CoroutineScope) { externalScope.launch { @@ -89,6 +95,9 @@ class ChatModelSwitchDelegate( cachedModelOptions = emptyList() cachedModelOptionsScope = newScope fastRejectedModels.clear() + modelInfoCapabilities = null + modelInfoModel = null + modelInfoScope = null if (modelOptionsJobScope != newScope) { modelOptionsJob?.cancel() @@ -469,6 +478,9 @@ class ChatModelSwitchDelegate( reasoningOpSequence++ reasoningJob?.cancel() reasoningJob = null + modelInfoCapabilities = null + modelInfoModel = null + modelInfoScope = null uiState.update { it.copy( pendingReasoningLevel = null, @@ -492,7 +504,7 @@ class ChatModelSwitchDelegate( // Capability validation if (enforceCapabilities) { val caps = uiState.value.currentModelCapabilities - if (caps?.reasoning == false) return + if (caps?.reasoningSupport == false) return if (level == "none" && caps?.can_disable_reasoning == false) return } @@ -578,10 +590,26 @@ class ChatModelSwitchDelegate( modelName: String, ): ModelCapabilities? { if (cachedModelOptionsScope != dataScopeFlow.value) return null - return cachedModelOptions - .find { it.slug == providerSlug } - ?.capabilities - ?.get(modelName) + val catalog = + cachedModelOptions + .find { it.slug == providerSlug } + ?.capabilities + ?.get(modelName) + val infoMatches = + modelInfoScope == dataScopeFlow.value && + (modelInfoModel == "$providerSlug/$modelName" || modelInfoModel?.substringAfter('/') == modelName) + val overlay = if (infoMatches) modelInfoCapabilities else null + return catalog.mergedWithCanonical(overlay) + } + + fun applyModelInfo( + info: ModelInfoResponse, + scope: DataScope?, + ) { + modelInfoCapabilities = info.capabilities + modelInfoModel = info.provider?.let { "$it/${info.model.orEmpty()}" } + modelInfoScope = scope + syncCurrentModelCapabilities() } fun getCurrentModelCapabilities(): ModelCapabilities? { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index daa29ed1..330203a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -81,6 +81,7 @@ import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.notification.NotificationHelper @@ -840,7 +841,7 @@ fun ChatScreen( onModelTap = { viewModel.openModelPicker() }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, - supportsReasoning = state.currentModelCapabilities?.reasoning, + supportsReasoning = state.currentModelCapabilities?.reasoningSupport, fastMode = state.fastMode, fastSupported = state.currentModelCapabilities?.fast == true, isFastModeChanging = state.isFastModeChanging, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 36e38374..ab556a0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider @@ -2344,7 +2345,7 @@ class ChatViewModel( if (value in REASONING_EFFORT_LEVELS) { if (parsed.scopeName != "global") { val caps = _uiState.value.currentModelCapabilities - if (caps?.reasoning == false) { + if (caps?.reasoningSupport == false) { addAssistantMessage("Reasoning is not supported for the current model.") return } @@ -3988,6 +3989,7 @@ class ChatViewModel( // another model (e.g. Solar, Gemini), the profile-level model/info describes // a different model and must never poison the session's context window. if (isMatchingModel(currentModel, info)) { + modelSwitchDelegate.applyModelInfo(info, AuthManager.currentDataScope()) info.effective_context_length ?: info.auto_context_length ?: info.config_context_length diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt index a96aff66..af8a6631 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.ui.kanban.KanbanModelOverride import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @@ -129,7 +130,7 @@ fun KanbanModelOverrideEditor( .firstOrNull { it.slug == override.provider } ?.capabilities ?.get(override.model) - val reasoningSupported = selectedCapabilities?.reasoning != false + val reasoningSupported = selectedCapabilities?.reasoningSupport != false val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning) Row( @@ -180,7 +181,7 @@ fun KanbanModelOverrideEditor( override.copy( provider = provider, model = model, - effort = if (capabilities?.reasoning == false) "" else override.effort, + effort = if (capabilities?.reasoningSupport == false) "" else override.effort, ), ) showPickerDialog = false diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index aed0803d..7a993d89 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -67,6 +67,7 @@ import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.model.components.MoaConfigDialog +import com.m57.hermescontrol.ui.model.components.ModelCapabilitySummary import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @OptIn(ExperimentalMaterial3Api::class) @@ -874,19 +875,7 @@ private fun ProviderCard( // the row vertically instead of pushing // the check/pin buttons off-screen. ) - val hint = - when { - caps?.reasoning == false -> "no reasoning" - caps?.can_disable_reasoning == false -> "reasoning always on" - else -> null - } - if (hint != null) { - Text( - text = hint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ModelCapabilitySummary(caps) } Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 7d99de54..8e3a549b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -3,20 +3,25 @@ package com.m57.hermescontrol.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.model.AuxiliaryTaskAssignment import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.ModelAssignmentRequest +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.UpdateProfileModelRequest +import com.m57.hermescontrol.data.model.withModelInfoCapabilities import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.ModelOptionsRepository import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,6 +50,7 @@ data class ModelUiState( val isLoading: Boolean = false, val catalogLoading: Boolean = false, val providers: List = emptyList(), + val modelInfo: ModelInfoResponse? = null, val activeProfile: ProfileInfo? = null, val errorMessage: String? = null, val toastMessage: String? = null, @@ -71,12 +77,19 @@ data class ModelUiState( class ModelViewModel( private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared, + private val getCurrentScope: () -> DataScope? = { AuthManager.currentDataScope() }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val getModelInfoCall: suspend (String?) -> NetworkResult = { profile -> + safeApiCall { ApiClient.hermesApi.getModelInfo(profile) } + }, ) : ViewModel(), ToastHost { constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository)) private val _uiState = MutableStateFlow(ModelUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var modelInfoJob: Job? = null + private var modelInfoScope: DataScope? = null init { _uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) } @@ -84,9 +97,18 @@ class ModelViewModel( catalogStore.state.collect { catalogState -> // Empty state is meaningful: it is how ModelCatalogStore // invalidates the previous DataScope. Never ignore it. + val observedScope = catalogState.scope ?: getCurrentScope() + val scopeChanged = modelInfoScope != observedScope + if (scopeChanged) { + modelInfoJob?.cancel() + modelInfoJob = null + modelInfoScope = observedScope + } _uiState.update { + val modelInfo = if (scopeChanged) null else it.modelInfo it.copy( - providers = catalogState.providers, + providers = catalogState.providers.withModelInfoCapabilities(modelInfo), + modelInfo = modelInfo, catalogLoading = catalogState.isRefreshing, ) } @@ -96,6 +118,7 @@ class ModelViewModel( fun loadAll(refresh: Boolean = false) { _uiState.update { it.copy(isLoading = true, catalogLoading = true, errorMessage = null) } + loadModelCapabilities() viewModelScope.launch { // Phase 1: Launch fast lightweight calls first (profiles, aux, moa) val activeProfileDeferred = @@ -199,6 +222,7 @@ class ModelViewModel( /** Fast refresh after model assignments/updates without re-fetching full provider catalog. */ fun loadQuick() { + loadModelCapabilities(clearExisting = true) viewModelScope.launch { val activeProfileDeferred = async(Dispatchers.IO) { @@ -254,6 +278,45 @@ class ModelViewModel( } } + fun loadModelCapabilities(clearExisting: Boolean = false) { + val requestScope = getCurrentScope() + if (modelInfoScope != requestScope) { + modelInfoJob?.cancel() + modelInfoScope = requestScope + _uiState.update { + it.copy( + providers = catalogStore.state.value.providers, + modelInfo = null, + ) + } + } else if (clearExisting) { + _uiState.update { + it.copy( + providers = catalogStore.state.value.providers, + modelInfo = null, + ) + } + } + + modelInfoJob?.cancel() + if (requestScope == null) return + modelInfoJob = + viewModelScope.launch(ioDispatcher) { + val result = getModelInfoCall(requestScope.activeProfileId) + if (getCurrentScope() != requestScope) return@launch + if (result is NetworkResult.Success) { + _uiState.update { + it.copy( + providers = + catalogStore.state.value.providers + .withModelInfoCapabilities(result.data), + modelInfo = result.data, + ) + } + } + } + } + /** Open the main model picker dialog. */ fun openMainModelPicker() { _uiState.update { it.copy(showMainModelPicker = true) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt new file mode 100644 index 00000000..88343b48 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelCapabilitySummary.kt @@ -0,0 +1,36 @@ +package com.m57.hermescontrol.ui.model.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.reasoningSupport + +@Composable +fun ModelCapabilitySummary(capabilities: ModelCapabilities?) { + if (capabilities == null) return + val labels = + buildList { + capabilities.supports_tools?.let { add(if (it) "Tools" else "No tools") } + capabilities.supports_vision?.let { add(if (it) "Vision" else "No vision") } + capabilities.reasoningSupport?.let { add(if (it) "Reasoning" else "no reasoning") } + if (capabilities.reasoningSupport == true && capabilities.can_disable_reasoning == false) { + add("reasoning always on") + } + capabilities.context_window?.let { add("${it / 1000}K ctx") } + capabilities.max_output_tokens?.let { add("${it / 1000}K output") } + capabilities.model_family?.takeIf { it.isNotBlank() }?.let { add(it) } + } + if (labels.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = labels.joinToString(" ยท "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt index 4bb075fb..52b4c0ca 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.ui.common.LoadingState @@ -213,8 +214,7 @@ fun ModelPickerDialog( null }, onClick = { onSelect(pinned.providerSlug, pinned.modelName) }, - canDisableReasoning = caps?.can_disable_reasoning, - supportsReasoning = caps?.reasoning, + capabilities = caps, ) } } @@ -294,8 +294,7 @@ fun ModelPickerDialog( null }, onClick = { onSelect(provider.slug, model) }, - canDisableReasoning = caps?.can_disable_reasoning, - supportsReasoning = caps?.reasoning, + capabilities = caps, ) } } @@ -315,8 +314,7 @@ private fun ModelItemCard( onPinToggle: (() -> Unit)?, onClick: () -> Unit, modifier: Modifier = Modifier, - canDisableReasoning: Boolean? = null, - supportsReasoning: Boolean? = null, + capabilities: ModelCapabilities? = null, ) { Surface( modifier = @@ -348,19 +346,7 @@ private fun ModelItemCard( // No maxLines/ellipsis: a long model name wraps so the full // name stays readable while the pin button keeps its spot. ) - val hint = - when { - supportsReasoning == false -> "no reasoning" - canDisableReasoning == false -> "reasoning always on" - else -> null - } - if (hint != null) { - Text( - text = hint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ModelCapabilitySummary(capabilities) } if (onPinToggle != null) { IconButton( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt new file mode 100644 index 00000000..b8a1d860 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelCapabilitiesSerializationTest.kt @@ -0,0 +1,130 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ModelCapabilitiesSerializationTest { + private val json = OkHttpProvider.json + + @Test + fun fullCapabilities_decodeAllNullableFields() { + val value = + json.decodeFromString( + """ + {"model":"gpt-5","provider":"openai","capabilities":{ + "supports_tools":false,"supports_vision":true,"supports_reasoning":true, + "context_window":128000,"max_output_tokens":4096,"model_family":"gpt-5" + }} + """.trimIndent(), + ) + + assertEquals(false, value.capabilities?.supports_tools) + assertEquals(true, value.capabilities?.supports_vision) + assertEquals(true, value.capabilities?.reasoningSupport) + assertEquals(128000L, value.capabilities?.context_window) + assertEquals(4096L, value.capabilities?.max_output_tokens) + assertEquals("gpt-5", value.capabilities?.model_family) + } + + @Test + fun partialMissingAndNullCapabilities_preserveUnknown() { + val partial = json.decodeFromString("""{"capabilities":{"supports_vision":false}}""") + val missing = json.decodeFromString("{}") + val explicitNull = json.decodeFromString("""{"capabilities":null}""") + + assertEquals(false, partial.capabilities?.supports_vision) + assertNull(partial.capabilities?.supports_tools) + assertNull(partial.capabilities?.reasoningSupport) + assertNull(missing.capabilities) + assertNull(explicitNull.capabilities) + } + + @Test + fun canonicalReasoning_takesPrecedenceOverLegacyField() { + val value = + json.decodeFromString( + """ + {"providers":[{"slug":"test","name":"Test","capabilities":{ + "model":{"reasoning":true,"supports_reasoning":false} + }}]} + """.trimIndent(), + ) + + assertEquals( + false, + value.providers + .single() + .capabilities + ?.get("model") + ?.reasoningSupport, + ) + } + + @Test + fun modelInfoCapabilities_enrichOnlyMatchingCatalogModel() { + val catalog = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5", "gpt-4o"), + capabilities = + mapOf( + "gpt-5" to ModelCapabilities(fast = true, reasoning = true), + "gpt-4o" to ModelCapabilities(fast = false, reasoning = false), + ), + ), + ) + val info = + ModelInfoResponse( + model = "gpt-5", + provider = "openai", + capabilities = + ModelCapabilities( + supports_tools = false, + supports_vision = true, + supports_reasoning = true, + context_window = 128000, + max_output_tokens = 4096, + model_family = "gpt-5", + ), + ) + + val enriched = catalog.withModelInfoCapabilities(info) + + val active = enriched.single().capabilities?.get("gpt-5") + assertEquals(true, active?.fast) + assertEquals(false, active?.supports_tools) + assertEquals(true, active?.supports_vision) + assertEquals(128000L, active?.context_window) + assertEquals("gpt-5", active?.model_family) + assertEquals( + ModelCapabilities(fast = false, reasoning = false), + enriched.single().capabilities?.get("gpt-4o"), + ) + } + + @Test + fun modelInfoCapabilities_doNotBleedIntoDifferentProviderOrModel() { + val catalog = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5"), + capabilities = mapOf("gpt-5" to ModelCapabilities(reasoning = true)), + ), + ) + val otherProfileInfo = + ModelInfoResponse( + model = "claude-sonnet-4-5", + provider = "anthropic", + capabilities = ModelCapabilities(supports_vision = true), + ) + + assertEquals(catalog, catalog.withModelInfoCapabilities(otherProfileInfo)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index f8ee5ecb..b4d7da88 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.ws.WsMethods import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -175,6 +177,41 @@ class ChatModelSwitchDelegateTest { scopeObserverJob.cancel() } + @Test + fun modelInfoCapabilities_mergeWithCatalogAndClearOnScopeSwitch() = + testScope.runTest { + val scopeObserverJob = kotlinx.coroutines.Job() + val observerScope = kotlinx.coroutines.CoroutineScope(testDispatcher + scopeObserverJob) + delegate.attachScopeObserver(observerScope) + delegate.preloadModelOptions() + advanceUntilIdle() + uiState.update { it.copy(currentSessionModel = "openai/gpt-4o") } + + delegate.applyModelInfo( + ModelInfoResponse( + provider = "openai", + model = "gpt-4o", + capabilities = + ModelCapabilities( + supports_tools = false, + supports_vision = true, + supports_reasoning = true, + ), + ), + initialDataScope, + ) + + assertEquals(true, uiState.value.currentModelCapabilities?.fast) + assertEquals(false, uiState.value.currentModelCapabilities?.supports_tools) + assertEquals(true, uiState.value.currentModelCapabilities?.supports_vision) + + dataScope.value = initialDataScope.copy(activeProfileId = "work") + runCurrent() + + assertNull(uiState.value.currentModelCapabilities) + scopeObserverJob.cancel() + } + @Test fun closeAndReopenDuringPreload_doesNotDuplicateOrReopenAfterDismiss() = testScope.runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt index 19fce4d9..5358e1d3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/model/ModelViewModelTest.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.model import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.DataScope +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelInfoResponse import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -206,6 +208,84 @@ class ModelViewModelTest { ) } + @Test + fun `model info enriches active model and is cleared on profile switch`() = + runTest(testDispatcher) { + var currentScope = + DataScope( + connectionProfileId = "conn-1", + baseUrl = "http://localhost:9119", + activeProfileId = "default", + inMemoryAuthGeneration = 1L, + ) + val response = + ModelOptionsResponse( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-5"), + capabilities = mapOf("gpt-5" to ModelCapabilities(reasoning = true)), + ), + ), + ) + val store = + ModelCatalogStore( + repository = + ModelOptionsRepository( + connected = { false }, + rest = { NetworkResult.Success(response) }, + ), + getCurrentScope = { currentScope }, + scope = this, + ) + val requestedProfiles = mutableListOf() + val viewModel = + ModelViewModel( + catalogStore = store, + getCurrentScope = { currentScope }, + ioDispatcher = testDispatcher, + getModelInfoCall = { profile -> + requestedProfiles += profile + NetworkResult.Success( + ModelInfoResponse( + provider = "openai", + model = "gpt-5", + capabilities = ModelCapabilities(supports_vision = true), + ), + ) + }, + ) + + store.ensureLoaded() + viewModel.loadModelCapabilities() + advanceUntilIdle() + + assertEquals(listOf("default"), requestedProfiles) + assertEquals( + true, + viewModel.uiState.value.providers + .single() + .capabilities + ?.get("gpt-5") + ?.supports_vision, + ) + + currentScope = currentScope.copy(activeProfileId = "work") + store.onScopeChanged(currentScope) + store.ensureLoaded() + advanceUntilIdle() + + assertNull( + viewModel.uiState.value.providers + .single() + .capabilities + ?.get("gpt-5") + ?.supports_vision, + ) + } + @Test fun `pinModel shows toast when max cap reached`() { val viewModel = createViewModel() From f34292aa837cec88ebbfcb49eac5e13a414095af Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:20:44 +0300 Subject: [PATCH 09/18] feat(#1218): support backend-owned connector setup operations (#1229) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 4 + .../components/ConnectionSetupSheetTest.kt | 268 +++++++++++++++ .../data/model/ConnectionOperationModels.kt | 75 +++++ .../data/ws/ConnectionOperationParser.kt | 114 +++++++ .../m57/hermescontrol/data/ws/EventParser.kt | 13 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 9 + .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../chat/ChatConnectionOperationDelegate.kt | 269 +++++++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 60 +++- .../hermescontrol/ui/chat/ChatViewModel.kt | 68 +++- .../ui/chat/ChatWsEventReducer.kt | 4 + .../chat/components/ConnectionSetupSheet.kt | 293 ++++++++++++++++ app/src/main/res/values-ar/strings.xml | 13 + app/src/main/res/values-ko/strings.xml | 13 + app/src/main/res/values-zh/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + .../ws/ConnectionOperationEventParserTest.kt | 83 +++++ .../data/ws/ConnectionOperationParserTest.kt | 169 ++++++++++ .../ChatConnectionOperationDelegateTest.kt | 314 ++++++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 72 ++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 9 +- 21 files changed, 1872 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 5f04473b..2b4b9731 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -60,6 +60,8 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.connectionOperationState } returns + MutableStateFlow(ConnectionOperationUiState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow โ€” a relaxed // mock proxy would crash the cast inside collectAsStateWithLifecycle. // A real controller (never started here) stays invisible. @@ -107,6 +109,8 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.connectionOperationState } returns + MutableStateFlow(ConnectionOperationUiState()).asStateFlow() every { mockViewModel.actionProgress } returns ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt new file mode 100644 index 00000000..d8f4ab7b --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt @@ -0,0 +1,268 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState +import com.m57.hermescontrol.ui.chat.ConnectionPendingAction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ConnectionSetupSheetTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun fieldsKeepBackendOrder_defaultsAreSafe_andRequiredInputGatesConnect() { + var response: Triple, Boolean>? = null + val target = + target( + requiredEnv = + listOf( + env("REGION", required = false, defaultValue = "us-east"), + env( + "API_TOKEN", + required = true, + secret = true, + defaultValue = "issue1218-secret-canary", + ), + ), + ) + composeTestRule.setContent { + ConnectionSetupContent( + state = state(target), + onRespond = { name, values, approved -> response = Triple(name, values, approved) }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + val region = composeTestRule.onNodeWithTag("connection_env_REGION") + val token = composeTestRule.onNodeWithTag("connection_env_API_TOKEN") + region.assertIsDisplayed().assertTextContains("us-east") + token.assertIsDisplayed() + composeTestRule.onNodeWithText("issue1218-secret-canary").assertDoesNotExist() + assertTrue(region.fetchSemanticsNode().boundsInRoot.top < token.fetchSemanticsNode().boundsInRoot.top) + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled() + + token.performTextInput("typed-secret") + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsEnabled().performClick() + + assertEquals("github", response?.first) + assertEquals(mapOf("REGION" to "us-east", "API_TOKEN" to "typed-secret"), response?.second) + assertEquals(true, response?.third) + } + + @Test + fun pendingAction_disablesMutatingControls() { + var calls = 0 + val operation = operation(target()) + composeTestRule.setContent { + ConnectionSetupContent( + state = + ConnectionOperationUiState( + operation = operation, + pendingAction = ConnectionPendingAction.Respond("op-a", "github", true, 1L), + ), + onRespond = { _, _, _ -> calls++ }, + onContinue = { calls++ }, + onOpenBrowser = { _, _ -> calls++ }, + ) + } + + composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_skip").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsNotEnabled() + composeTestRule.onNodeWithTag("connection_setup_pending").assertIsDisplayed() + assertEquals(0, calls) + } + + @Test + fun browserAction_usesTheActiveUnresolvedTarget() { + var opened: Pair? = null + val connected = target(name = "already-done", state = ConnectionTargetState.CONNECTED) + val waiting = + target( + name = "slack", + state = ConnectionTargetState.INITIATED, + connectUrl = "https://example.com/oauth?state=abc", + ) + composeTestRule.setContent { + ConnectionSetupContent( + state = ConnectionOperationUiState(operation = operation(connected, waiting)), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { operationId, url -> opened = operationId to url }, + ) + } + + composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_open_browser").performClick() + assertEquals("op-a" to "https://example.com/oauth?state=abc", opened) + } + + @Test + fun unsupportedTarget_degradesToSafeSkipAndContinueActions() { + var skipped = false + var continued = false + composeTestRule.setContent { + ConnectionSetupContent( + state = state(target(state = ConnectionTargetState.UNKNOWN)), + onRespond = { _, _, approved -> skipped = !approved }, + onContinue = { continued = true }, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").performClick() + composeTestRule.onNodeWithTag("connection_setup_continue").performClick() + assertTrue(skipped) + assertTrue(continued) + } + + @Test + fun authorizedWithoutTools_showsDiscoveryErrorAndContinueOnly() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + target( + state = ConnectionTargetState.CONNECTED, + discoveryError = "No compatible tools were discovered", + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Authorized; tools unavailable").assertIsDisplayed() + composeTestRule.onNodeWithText("No compatible tools were discovered").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed() + } + + @Test + fun discoveryError_doesNotHideALaterPendingTarget() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + ConnectionOperationUiState( + operation = + operation( + target( + name = "github", + state = ConnectionTargetState.CONNECTED, + discoveryError = "No compatible tools were discovered", + ), + target(name = "slack"), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed() + composeTestRule.onNodeWithText("Authorized; tools unavailable").assertDoesNotExist() + } + + @Test + fun resolvedTarget_canStillSettleTheOperation() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + target( + state = ConnectionTargetState.CONNECTED, + tools = listOf("search", "fetch"), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("This connection step is complete").assertIsDisplayed() + composeTestRule.onNodeWithText("Tools available: 2").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + } + + private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState = + ConnectionOperationUiState(operation = operation(target)) + + private fun operation(vararg targets: ConnectionOperationTarget): ConnectionOperationSnapshot = + ConnectionOperationSnapshot( + sessionId = "session-a", + opId = "op-a", + seq = 1L, + deadlineAt = 2_000_000_000.0, + timeoutSeconds = 300.0, + toolCallId = "tool-a", + settled = false, + settledBy = null, + targets = targets.toList(), + ) + + private fun target( + name: String = "github", + state: ConnectionTargetState = ConnectionTargetState.PENDING, + connectUrl: String? = null, + discoveryError: String? = null, + requiredEnv: List = emptyList(), + tools: List = emptyList(), + ): ConnectionOperationTarget = + ConnectionOperationTarget( + name = name, + kind = ConnectionTargetKind.CONNECTOR, + action = ConnectionTargetAction.AUTHORIZE, + state = state, + detail = null, + instructions = null, + discoveryError = discoveryError, + connectUrl = connectUrl, + connectionId = null, + attempt = null, + requiredEnv = requiredEnv, + tools = tools, + hint = null, + ) + + private fun env( + name: String, + required: Boolean, + secret: Boolean = false, + defaultValue: String? = null, + ): ConnectionEnvField = + ConnectionEnvField( + name = name, + required = required, + secret = secret, + defaultValue = defaultValue, + prompt = null, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt new file mode 100644 index 00000000..064ca0b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.util.ConnectorUrlValidator + +/** Backend-owned connector setup operation snapshot. Secrets are intentionally absent. */ +data class ConnectionOperationSnapshot( + val sessionId: String?, + val opId: String, + val seq: Long, + val deadlineAt: Double, + val timeoutSeconds: Double?, + val toolCallId: String?, + val settled: Boolean, + val settledBy: String?, + val targets: List, +) + +data class ConnectionOperationTarget( + val name: String, + val kind: ConnectionTargetKind, + val action: ConnectionTargetAction, + val state: ConnectionTargetState, + val detail: String?, + val instructions: String?, + val discoveryError: String?, + val connectUrl: String?, + val connectionId: String?, + val attempt: String?, + val requiredEnv: List, + val tools: List, + val hint: String?, +) { + /** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */ + val safeConnectUrl: String? + get() = connectUrl?.takeIf(ConnectorUrlValidator::isValidHttpsUrl) + + override fun toString(): String = + "ConnectionOperationTarget(" + + "name=$name, kind=$kind, action=$action, state=$state, detail=$detail, " + + "instructions=$instructions, discoveryError=$discoveryError, " + + "connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " + + "connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " + + "tools=$tools, hint=$hint)" +} + +data class ConnectionEnvField( + val name: String, + val required: Boolean, + val secret: Boolean, + val defaultValue: String?, + val prompt: String?, +) + +enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN } + +enum class ConnectionTargetAction { + AUTHORIZE, + CONNECT, + ENABLE, + INSTALL, + RECONNECT, + UNKNOWN, +} + +enum class ConnectionTargetState { + PENDING, + INITIATED, + CONNECTED, + SKIPPED, + FAILED, + EXPIRED, + UNAVAILABLE, + NOT_CONNECTED, + UNKNOWN, +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt new file mode 100644 index 00000000..b9748882 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -0,0 +1,114 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState + +/** Tolerant parser for backend-owned connector operation snapshots. */ +object ConnectionOperationParser { + fun parse( + payload: Map, + sessionId: String? = null, + ): ConnectionOperationSnapshot? { + val opId = (payload["op_id"] as? String)?.trim() ?: return null + val seq = payload["seq"].toLongOrNull() ?: return null + val deadlineAt = payload["deadline_at"].toDoubleOrNull() ?: return null + val targets = + (payload["targets"] as? List<*>) + ?.mapNotNull { parseTarget(it as? Map<*, *>) } + ?: return null + if (opId.isBlank() || seq < 0L || !deadlineAt.isFinite() || deadlineAt <= 0.0 || targets.isEmpty()) { + return null + } + return ConnectionOperationSnapshot( + sessionId = (sessionId ?: payload["session_id"] as? String)?.takeIf { it.isNotBlank() }, + opId = opId, + seq = seq, + deadlineAt = deadlineAt, + timeoutSeconds = + payload["timeout_seconds"] + .toDoubleOrNull() + ?.takeIf { it.isFinite() && it > 0.0 }, + toolCallId = payload["tool_call_id"] as? String, + settled = payload["settled"] as? Boolean ?: false, + settledBy = payload["settled_by"] as? String, + targets = targets, + ) + } + + private fun parseTarget(raw: Map<*, *>?): ConnectionOperationTarget? { + val map = raw ?: return null + val name = (map["name"] as? String)?.trim() ?: return null + if (name.isBlank()) return null + return ConnectionOperationTarget( + name = name, + kind = + enumValue( + map["kind"] as? String, + ConnectionTargetKind.values(), + ConnectionTargetKind.UNKNOWN, + ), + action = + enumValue( + map["action"] as? String, + ConnectionTargetAction.values(), + ConnectionTargetAction.UNKNOWN, + ), + state = + enumValue( + map["state"] as? String, + ConnectionTargetState.values(), + ConnectionTargetState.UNKNOWN, + ), + detail = map["detail"] as? String, + instructions = map["instructions"] as? String, + discoveryError = map["discovery_error"] as? String, + connectUrl = map["connect_url"] as? String, + connectionId = map["connection_id"] as? String, + attempt = map["attempt"] as? String, + requiredEnv = + (map["required_env"] as? List<*>) + ?.mapNotNull { parseEnv(it as? Map<*, *>) } + ?: emptyList(), + tools = (map["tools"] as? List<*>)?.filterIsInstance() ?: emptyList(), + hint = map["hint"] as? String, + ) + } + + private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? { + val map = raw ?: return null + val name = (map["name"] as? String)?.trim() ?: return null + if (name.isBlank()) return null + val secret = map["secret"] as? Boolean ?: false + return ConnectionEnvField( + name = name, + required = map["required"] as? Boolean ?: false, + secret = secret, + defaultValue = if (secret) null else map["default"] as? String, + prompt = map["prompt"] as? String, + ) + } + + private inline fun > enumValue( + raw: String?, + values: Array, + fallback: T, + ): T = values.firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: fallback + + private fun Any?.toLongOrNull(): Long? = + when (this) { + is Number -> this.toLong().takeIf { this.toDouble() == it.toDouble() } + is String -> this.toLongOrNull() + else -> null + } + + private fun Any?.toDoubleOrNull(): Double? = + when (this) { + is Number -> this.toDouble() + is String -> this.toDoubleOrNull() + else -> null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index b78d4824..99639aa4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -75,6 +75,19 @@ object EventParser { val sessionId = params["session_id"] as? String ?: payload?.get("session_id") as? String return when (eventType) { + "connection.request", "connection.update" -> { + val snapshot = payload?.let { ConnectionOperationParser.parse(it, sessionId) } + // Operation payloads may carry credential defaults and OAuth URLs. + // Never retain the raw frame when a malformed operation degrades to Unknown. + if (snapshot == null) { + WsEvent.Unknown("") + } else if (eventType == "connection.request") { + WsEvent.ConnectionRequest(snapshot) + } else { + WsEvent.ConnectionUpdate(snapshot) + } + } + "gateway.ready" -> { WsEvent.GatewayReady(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 131693c5..e5cf6c26 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.ui.chat.TodoItem /** Parsed WebSocket events emitted by [HermesWsClient]. */ @@ -191,6 +192,14 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + data class ConnectionRequest( + val snapshot: ConnectionOperationSnapshot, + ) : WsEvent() + + data class ConnectionUpdate( + val snapshot: ConnectionOperationSnapshot, + ) : WsEvent() + // โ”€โ”€ Status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ data class StatusUpdate( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 2d585be5..93e3c448 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -101,6 +101,9 @@ object WsMethods { // โ”€โ”€ Connectors (issue #1091) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const val CONNECTION_RESPOND = "connection.respond" + const val CONNECTORS_OPERATION_WAKE = "connectors.operation.wake" + /** List available third-party connectors and authorization state for a session. */ const val CONNECTORS_LIST = "connectors.list" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt new file mode 100644 index 00000000..7d03246f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -0,0 +1,269 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class ConnectionPendingAction { + abstract val opId: String + abstract val observedSeq: Long + + data class Respond( + override val opId: String, + val target: String, + val approved: Boolean, + override val observedSeq: Long, + ) : ConnectionPendingAction() + + data class Continue( + override val opId: String, + override val observedSeq: Long, + ) : ConnectionPendingAction() + + data class Wake( + override val opId: String, + override val observedSeq: Long, + ) : ConnectionPendingAction() +} + +data class ConnectionOperationError( + val message: String, + val retryable: Boolean = true, +) + +data class ConnectionOperationUiState( + val operation: ConnectionOperationSnapshot? = null, + val pendingAction: ConnectionPendingAction? = null, + val error: ConnectionOperationError? = null, +) + +fun interface ConnectionOperationRequester { + suspend fun request( + method: String, + params: Map, + ): Any? +} + +internal class ConnectionResumeCheckpoint( + internal val generation: Long, + internal val operationId: String?, + internal val seq: Long?, +) + +/** Session-scoped authoritative reducer for backend-owned connector operations. */ +class ChatConnectionOperationDelegate( + private val requester: ConnectionOperationRequester, +) { + private val _state = MutableStateFlow(ConnectionOperationUiState()) + val state: StateFlow = _state.asStateFlow() + private var sessionId: String? = null + private var generation = 0L + private val settled = LinkedHashSet() + + @Synchronized + fun reset(newSessionId: String? = null) { + generation++ + sessionId = newSessionId + settled.clear() + _state.value = ConnectionOperationUiState() + } + + /** Rebinds the same stored chat to its current runtime session after create/resume/reconnect. */ + @Synchronized + fun bindSession(newSessionId: String) { + if (newSessionId.isNotBlank()) sessionId = newSessionId + } + + @Synchronized + internal fun resumeCheckpoint(): ConnectionResumeCheckpoint = + ConnectionResumeCheckpoint( + generation = generation, + operationId = _state.value.operation?.opId, + seq = _state.value.operation?.seq, + ) + + fun acceptRequest(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = true) + + fun acceptUpdate(snapshot: ConnectionOperationSnapshot): Boolean = accept(snapshot, allowReplacement = false) + + @Synchronized + private fun accept( + snapshot: ConnectionOperationSnapshot, + allowReplacement: Boolean, + ): Boolean { + if (!matchesSession(snapshot)) return false + if (settled.contains(snapshot.opId)) return false + val current = _state.value.operation + if (current != null && current.opId == snapshot.opId && snapshot.seq <= current.seq) return false + if (current != null && current.opId != snapshot.opId && !allowReplacement) return false + val pending = _state.value.pendingAction + val keepPending = + pending != null && + pending.opId == snapshot.opId && + snapshot.seq <= pending.observedSeq + _state.value = + ConnectionOperationUiState( + operation = snapshot, + pendingAction = pending.takeIf { keepPending }, + ) + if (snapshot.settled) markSettled(snapshot.opId) + return true + } + + /** Applies a resume snapshot only if it cannot regress a live event received after resume began. */ + @Synchronized + internal fun reconcileResume( + snapshot: ConnectionOperationSnapshot?, + checkpoint: ConnectionResumeCheckpoint, + ): Boolean { + if (checkpoint.generation != generation) return false + val current = _state.value.operation + val unchanged = current?.opId == checkpoint.operationId && current?.seq == checkpoint.seq + if (snapshot == null) { + if (unchanged) { + _state.value = ConnectionOperationUiState() + return true + } + return false + } + if (!unchanged && current?.opId != snapshot.opId) return false + return accept(snapshot, allowReplacement = unchanged) + } + + suspend fun respond( + target: String, + env: Map, + approved: Boolean, + ) { + val current = _state.value.operation ?: return + val targetSnapshot = current.targets.firstOrNull { it.name == target } ?: return + val safeEnv = + if (approved) { + val allowed = targetSnapshot.requiredEnv.mapTo(HashSet()) { it.name } + env.filterKeys(allowed::contains) + } else { + emptyMap() + } + val snapshot = + begin( + ConnectionPendingAction.Respond( + opId = current.opId, + target = target, + approved = approved, + observedSeq = current.seq, + ), + ) ?: return + val answer = + buildMap { + put("name", target) + put("status", if (approved) "approved" else "skipped") + if (safeEnv.isNotEmpty()) put("env", safeEnv) + } + dispatch( + method = WsMethods.CONNECTION_RESPOND, + params = + mapOf( + "session_id" to requireSessionId(), + "op_id" to snapshot.opId, + "result" to mapOf("targets" to listOf(answer)), + ), + ) + } + + suspend fun continueOperation() { + val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return + dispatch( + method = WsMethods.CONNECTION_RESPOND, + params = + mapOf( + "session_id" to requireSessionId(), + "op_id" to snapshot.opId, + "result" to mapOf("settled_by" to "continue"), + ), + ) + } + + suspend fun wake(expectedOpId: String) { + val current = _state.value.operation ?: return + if (current.opId != expectedOpId) return + val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return + dispatch( + method = WsMethods.CONNECTORS_OPERATION_WAKE, + params = mapOf("session_id" to requireSessionId(), "op_id" to snapshot.opId), + clearOnSuccess = true, + unknownOperationSettles = true, + ) + } + + private suspend fun dispatch( + method: String, + params: Map, + clearOnSuccess: Boolean = false, + unknownOperationSettles: Boolean = false, + ) { + val action = _state.value.pendingAction + val actionGeneration = generation + try { + requester.request(method, params) + // Success only acknowledges receipt. Keep the exactly-once lock until + // a newer authoritative snapshot advances the operation sequence. + if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) { + _state.value = _state.value.copy(pendingAction = null) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { + if (generation == actionGeneration && _state.value.pendingAction == action) { + action?.opId?.let(::markSettled) + } + } else if (generation == actionGeneration && _state.value.pendingAction == action) { + _state.value = + _state.value.copy( + pendingAction = null, + error = ConnectionOperationError("request_failed"), + ) + } + } + } + + @Synchronized + private fun begin(action: ConnectionPendingAction): ConnectionOperationSnapshot? { + val current = _state.value.operation ?: return null + if (sessionId.isNullOrBlank() || + _state.value.pendingAction != null || + current.opId != action.opId || + current.seq != action.observedSeq + ) { + return null + } + _state.value = _state.value.copy(pendingAction = action, error = null) + return current + } + + private fun matchesSession(snapshot: ConnectionOperationSnapshot): Boolean = + !sessionId.isNullOrBlank() && snapshot.sessionId == sessionId + + private fun requireSessionId(): String = checkNotNull(sessionId?.takeIf { it.isNotBlank() }) + + private fun currentOp(): String = + _state.value.operation + ?.opId + .orEmpty() + + private fun currentSeq(): Long = _state.value.operation?.seq ?: -1L + + private fun markSettled(opId: String) { + settled += opId + while (settled.size > MAX_SETTLED_IDS) settled.remove(settled.first()) + _state.value = ConnectionOperationUiState() + } + + private companion object { + const val MAX_SETTLED_IDS = 32 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 330203a2..ed645214 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -96,6 +96,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet +import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay @@ -158,6 +159,7 @@ fun ChatScreen( val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle() + val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle() // Snapshot-backed search state โ€” read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState @@ -166,6 +168,8 @@ fun ChatScreen( val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } + var connectionBrowserOperationId by remember { mutableStateOf(null) } + var connectionBrowserDeparted by remember { mutableStateOf(false) } DisposableEffect(lifecycleOwner) { val observer = @@ -175,15 +179,27 @@ fun ChatScreen( if (browserAuthInFlight) { browserAuthDeparted = true } + if (connectionBrowserOperationId != null) { + connectionBrowserDeparted = true + } connectorsViewModel.onPause() } Lifecycle.Event.ON_RESUME -> { connectorsViewModel.onResume() - if (browserAuthInFlight && browserAuthDeparted) { + val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted + val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } + if (legacyBrowserReturned || operationId != null) { + ExternalActivityLifecycleGuard.externalActivityReturned() + } + if (legacyBrowserReturned) { browserAuthInFlight = false browserAuthDeparted = false - ExternalActivityLifecycleGuard.externalActivityReturned() + } + if (operationId != null) { + connectionBrowserOperationId = null + connectionBrowserDeparted = false + viewModel.wakeConnectionOperation(operationId) } } @@ -199,11 +215,18 @@ fun ChatScreen( .firstOrNull() val isChangingConfigs = activity?.isChangingConfigurations == true if (!isChangingConfigs) { + val externalActivityOutstanding = + (browserAuthInFlight && browserAuthDeparted) || + (connectionBrowserOperationId != null && connectionBrowserDeparted) + if (externalActivityOutstanding) { + ExternalActivityLifecycleGuard.externalActivityReturned() + } if (browserAuthInFlight && browserAuthDeparted) { browserAuthInFlight = false browserAuthDeparted = false - ExternalActivityLifecycleGuard.externalActivityReturned() } + connectionBrowserOperationId = null + connectionBrowserDeparted = false connectorsViewModel.onPause() connectorsViewModel.hide() } @@ -424,7 +447,8 @@ fun ChatScreen( state.btwState == null && viewingImage == null && !connectorsState.isVisible && - !timelineState.isOpen + !timelineState.isOpen && + connectionOperationState.operation == null // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -967,6 +991,34 @@ fun ChatScreen( ) } + if (connectionOperationState.operation != null) { + ConnectionSetupSheet( + state = connectionOperationState, + onRespond = viewModel::respondToConnection, + onContinue = viewModel::continueConnectionOperation, + onOpenBrowser = { operationId, url -> + if (ConnectorUrlValidator.isValidHttpsUrl(url)) { + try { + launchExternalActivity { + connectionBrowserOperationId = operationId + connectionBrowserDeparted = false + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } catch (_: Exception) { + connectionBrowserOperationId = null + connectionBrowserDeparted = false + scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) } + } + } + }, + onDismiss = viewModel::continueConnectionOperation, + ) + } + SessionIntegrationsSheet( uiState = connectorsState, sessionId = connectorsState.sessionId, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ab556a0d..fef5ec71 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -25,6 +25,7 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog +import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent @@ -401,8 +402,14 @@ class ChatViewModel( ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) + private val connectionOperationDelegate = + ChatConnectionOperationDelegate { method, params -> + HermesWsClient.request(method, params).await() + } + // โ”€โ”€ Internal state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private val _uiState = MutableStateFlow(ChatUiState()) + val connectionOperationState: StateFlow = connectionOperationDelegate.state private val _streamingState = MutableStateFlow(StreamingState()) @@ -413,6 +420,7 @@ class ChatViewModel( val generation: Long, val resumeSequence: Long = 0L, val sessionId: String? = null, + val connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) private val sessionRequestById = ConcurrentHashMap() @@ -842,6 +850,14 @@ class ChatViewModel( return } + if (event is WsEvent.ConnectionRequest) { + if (!isCurrentSession(event.snapshot.sessionId)) return + connectionOperationDelegate.acceptRequest(event.snapshot) + } else if (event is WsEvent.ConnectionUpdate) { + if (!isCurrentSession(event.snapshot.sessionId)) return + connectionOperationDelegate.acceptUpdate(event.snapshot) + } + // Flush any throttled reasoning before a state transition so the // finalized/orphan message carries the latest reasoning text. when (event) { @@ -1272,7 +1288,18 @@ class ChatViewModel( } } + @Suppress("UNCHECKED_CAST") private fun handleSessionInfo(info: Map?) { + // session.resume/session.info carries the open operation so a mobile client + // that missed connection.request can reconstruct the backend-authoritative card. + val pendingConnection = info?.get("pending_connection") as? Map + if (pendingConnection != null) { + ConnectionOperationParser + .parse( + pendingConnection, + runtimeSessionId ?: _uiState.value.currentSessionId, + )?.let(connectionOperationDelegate::acceptRequest) + } // Session info pushed by backend when config changes // (model switch, reasoning level, etc.) if (info != null) { @@ -1385,6 +1412,7 @@ class ChatViewModel( } val storageId = resultMap["stored_session_id"] as? String ?: runtimeId runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) // The gateway persists the row lazily on the first prompt โ€” // do not resume this key until presence is confirmed. sessionHasServerPresence = false @@ -1449,6 +1477,7 @@ class ChatViewModel( isLoading = false, ) runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) resumedGeneration = generation ActiveSessionHolder.set(runtimeId, storageId) sessionHasServerPresence = false @@ -1488,6 +1517,7 @@ class ChatViewModel( return } runtimeSessionId = runtimeId + connectionOperationDelegate.bindSession(runtimeId) // Resume succeeded โ€” the gateway confirmed the DB row. sessionHasServerPresence = true val sessionId = @@ -1577,6 +1607,16 @@ class ChatViewModel( ) } val activeSessionId = runtimeSessionId ?: sessionId + // Reconnect replay: the backend-owned connector operation is + // authoritative and uses the same full snapshot as the live + // connection.request event. Feed it through the seq guard so + // a late resume response cannot regress a newer live update. + val pendingConnection = resultMap["pending_connection"] as? Map + val pendingSnapshot = + pendingConnection?.let { ConnectionOperationParser.parse(it, activeSessionId) } + request?.connectionCheckpoint?.let { checkpoint -> + connectionOperationDelegate.reconcileResume(pendingSnapshot, checkpoint) + } if (activeSessionId != null) approvalsDelegate.replayPendingApproval(activeSessionId) } @@ -3436,6 +3476,7 @@ class ChatViewModel( isLoading: Boolean, ): Long { val generation = ++sessionGeneration + connectionOperationDelegate.reset() cancelResumeRetry() contextUsageJob?.cancel() contextUsageJob = null @@ -3519,6 +3560,7 @@ class ChatViewModel( _uiState.update { it.copy(isSessionReady = false) } val requestSequence = ++resumeRequestSequence activeResumeRequestSequence = requestSequence + val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val profile = AuthManager.activeProfileId.value val params = mutableMapOf( @@ -3539,6 +3581,7 @@ class ChatViewModel( generation = generation, resumeSequence = requestSequence, sessionId = sessionId, + connectionCheckpoint = connectionCheckpoint, ) }, ) @@ -4432,8 +4475,15 @@ class ChatViewModel( generation: Long, resumeSequence: Long = 0L, sessionId: String? = null, + connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) { - sessionRequestById[id] = SessionRequest(generation, resumeSequence, sessionId) + sessionRequestById[id] = + SessionRequest( + generation = generation, + resumeSequence = resumeSequence, + sessionId = sessionId, + connectionCheckpoint = connectionCheckpoint, + ) trackRequest(id, method) } @@ -4492,6 +4542,22 @@ class ChatViewModel( // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + fun respondToConnection( + target: String, + env: Map, + approved: Boolean, + ) { + viewModelScope.launch { connectionOperationDelegate.respond(target, env, approved) } + } + + fun continueConnectionOperation() { + viewModelScope.launch { connectionOperationDelegate.continueOperation() } + } + + fun wakeConnectionOperation(operationId: String) { + viewModelScope.launch { connectionOperationDelegate.wake(operationId) } + } + override fun onCleared() { super.onCleared() subagentsDelegate.closeSubagentTranscript() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 53c1b1da..4f9088cd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -176,6 +176,10 @@ object ChatWsEventReducer { is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState) + is WsEvent.ConnectionRequest, + is WsEvent.ConnectionUpdate, + -> ReducerResult(state, streamingState) + is WsEvent.Unknown -> onUnknown(state, streamingState) // SessionInfo is a no-op in the original code diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt new file mode 100644 index 00000000..63b3b8e5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -0,0 +1,293 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.ui.chat.ConnectionOperationUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSetupSheet( + state: ConnectionOperationUiState, + onRespond: (String, Map, Boolean) -> Unit, + onContinue: () -> Unit, + onOpenBrowser: (operationId: String, url: String) -> Unit, + onDismiss: () -> Unit, +) { + if (state.operation == null) return + ModalBottomSheet( + onDismissRequest = { + if (state.pendingAction == null) onDismiss() + }, + modifier = Modifier.testTag("connection_setup_sheet"), + ) { + ConnectionSetupContent( + state = state, + onRespond = onRespond, + onContinue = onContinue, + onOpenBrowser = onOpenBrowser, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +internal fun ConnectionSetupContent( + state: ConnectionOperationUiState, + onRespond: (String, Map, Boolean) -> Unit, + onContinue: () -> Unit, + onOpenBrowser: (operationId: String, url: String) -> Unit, + modifier: Modifier = Modifier, +) { + val operation = state.operation ?: return + val target = + operation.targets.firstOrNull { !isTerminalTarget(it) } + ?: operation.targets.firstOrNull { !it.discoveryError.isNullOrBlank() } + ?: operation.targets.lastOrNull() + ?: return + val targetIndex = operation.targets.indexOf(target) + val values = + remember(operation.opId, target.name) { + mutableStateMapOf().apply { + target.requiredEnv.forEach { field -> + this[field.name] = if (field.secret) "" else field.defaultValue.orEmpty() + } + } + } + LaunchedEffect(target.requiredEnv) { + val currentNames = target.requiredEnv.mapTo(HashSet()) { it.name } + values.keys + .toList() + .filterNot(currentNames::contains) + .forEach(values::remove) + target.requiredEnv.forEach { field -> + values.putIfAbsent(field.name, if (field.secret) "" else field.defaultValue.orEmpty()) + } + } + val missingRequired = target.requiredEnv.firstOrNull { it.required && values[it.name].orEmpty().isBlank() } + val pending = state.pendingAction != null + val safeUrl = target.safeConnectUrl + + Column( + modifier = + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.connection_setup_target_title, target.name), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.testTag("connection_setup_title"), + ) + Text( + text = + stringResource( + R.string.connection_setup_progress, + targetIndex + 1, + operation.targets.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + target.instructions?.takeIf { it.isNotBlank() }?.let { instructions -> + Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + when { + target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> { + Text( + text = stringResource(R.string.connection_setup_authorized_no_tools), + style = MaterialTheme.typography.titleMedium, + ) + Text(target.discoveryError, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + isTerminalTarget(target) -> { + Text( + text = stringResource(R.string.connection_setup_target_resolved), + style = MaterialTheme.typography.titleMedium, + ) + if (target.tools.isNotEmpty()) { + Text( + text = stringResource(R.string.connection_setup_tools_available, target.tools.size), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + target.state == ConnectionTargetState.INITIATED && safeUrl != null -> { + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = safeUrl, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("connection_setup_browser_url"), + ) + OutlinedButton( + onClick = { onOpenBrowser(operation.opId, safeUrl) }, + enabled = !pending, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_setup_open_browser"), + ) { + Text(stringResource(R.string.connection_setup_open_browser)) + } + } + + target.state == ConnectionTargetState.UNKNOWN || + (target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> { + Text( + text = stringResource(R.string.connection_setup_unsupported), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + target.requiredEnv.forEach { field -> + OutlinedTextField( + value = values[field.name].orEmpty(), + onValueChange = { values[field.name] = it }, + enabled = !pending, + label = { Text(field.prompt ?: field.name) }, + visualTransformation = + if (field.secret) PasswordVisualTransformation() else VisualTransformation.None, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_env_${field.name}"), + ) + } + target.detail?.takeIf { it.isNotBlank() }?.let { detail -> + Text( + text = detail, + color = + if (target.state == ConnectionTargetState.FAILED) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + missingRequired?.let { field -> + Text( + text = stringResource(R.string.connection_setup_required, field.prompt ?: field.name), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("connection_setup_validation"), + ) + } + Button( + onClick = { + val env = target.requiredEnv.associate { field -> field.name to values[field.name].orEmpty() } + onRespond(target.name, env, true) + }, + enabled = !pending && missingRequired == null && target.state != ConnectionTargetState.UNKNOWN, + modifier = + Modifier + .fillMaxWidth() + .testTag("connection_setup_connect"), + ) { + Text(stringResource(R.string.connection_setup_connect)) + } + } + } + + state.error?.let { + Text( + text = stringResource(R.string.connection_setup_request_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("connection_setup_error"), + ) + } + if (pending) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("connection_setup_pending"), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.connection_setup_pending)) + } + } + ConnectionSetupActions( + target = target, + pending = pending, + onSkip = { onRespond(target.name, emptyMap(), false) }, + onContinue = onContinue, + ) + } +} + +@Composable +private fun ConnectionSetupActions( + target: ConnectionOperationTarget, + pending: Boolean, + onSkip: () -> Unit, + onContinue: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!isTerminalTarget(target)) { + OutlinedButton( + onClick = onSkip, + enabled = !pending, + modifier = + Modifier + .weight(1f) + .testTag("connection_setup_skip"), + ) { + Text(stringResource(R.string.connection_setup_skip)) + } + } + OutlinedButton( + onClick = onContinue, + enabled = !pending, + modifier = + Modifier + .weight(1f) + .testTag("connection_setup_continue"), + ) { + Text(stringResource(R.string.connection_setup_continue)) + } + } +} + +private fun isTerminalTarget(target: ConnectionOperationTarget): Boolean = + target.state == ConnectionTargetState.CONNECTED || + target.state == ConnectionTargetState.SKIPPED || + target.state == ConnectionTargetState.EXPIRED || + target.state == ConnectionTargetState.UNAVAILABLE diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 80f5cb59..69e49508 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1514,4 +1514,17 @@ ุชุดุบูŠู„ ุงู„ููŠุฏูŠูˆ ุฌุงุฑู ุชุฌู‡ูŠุฒ ุงู„ุฌู„ุณุฉโ€ฆ ุงู„ุฌู„ุณุฉ ุบูŠุฑ ุฌุงู‡ุฒุฉ โ€” ุฃุนุฏ ุงู„ู…ุญุงูˆู„ุฉ ู„ู„ู…ุชุงุจุนุฉ + ุฅุนุฏุงุฏ %1$s + %1$d ู…ู† %2$d + ุชู… ุงู„ุชููˆูŠุถุŒ ู„ูƒู† ุงู„ุฃุฏูˆุงุช ุบูŠุฑ ู…ุชุงุญุฉ + ุงูƒุชู…ู„ุช ุฎุทูˆุฉ ุงู„ุงุชุตุงู„ ู‡ุฐู‡ + ุงู„ุฃุฏูˆุงุช ุงู„ู…ุชุงุญุฉ: %1$d + ุฎุทูˆุฉ ุงู„ุฅุนุฏุงุฏ ู‡ุฐู‡ ุบูŠุฑ ู…ุฏุนูˆู…ุฉ ููŠ ุฅุตุฏุงุฑ ุงู„ุชุทุจูŠู‚ ุงู„ุญุงู„ูŠ. + ุงุชุตุงู„ + ุชุฎุทูŠ + ู…ุชุงุจุนุฉ + ูุชุญ ููŠ ุงู„ู…ุชุตูุญ + %1$s ู…ุทู„ูˆุจ + ุชุนุฐุฑ ุฅุฑุณุงู„ ุฑุฏ ุงู„ุฅุนุฏุงุฏ. ุญุงูˆู„ ู…ุฑุฉ ุฃุฎุฑู‰. + ุจุงู†ุชุธุงุฑ Hermesโ€ฆ \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ee1048c0..adbf0164 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1197,4 +1197,17 @@ ๋น„๋””์˜ค ์žฌ์ƒ ์„ธ์…˜ ์ค€๋น„ ์ค‘โ€ฆ ์„ธ์…˜์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š” + %1$s ์„ค์ • + %2$d๊ฐœ ์ค‘ %1$d๊ฐœ + ์ธ์ฆ๋˜์—ˆ์ง€๋งŒ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค + ์ด ์—ฐ๊ฒฐ ๋‹จ๊ณ„๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋„๊ตฌ: %1$d + ํ˜„์žฌ ์•ฑ ๋ฒ„์ „์—์„œ๋Š” ์ด ์„ค์ • ๋‹จ๊ณ„๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + ์—ฐ๊ฒฐ + ๊ฑด๋„ˆ๋›ฐ๊ธฐ + ๊ณ„์† + ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด๊ธฐ + %1$s์€(๋Š”) ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค + ์„ค์ • ์‘๋‹ต์„ ๋ณด๋‚ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”. + Hermes๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ค‘โ€ฆ \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 5cbae308..dc9370f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1467,4 +1467,17 @@ ๆ’ญๆ”พ่ง†้ข‘ ๆญฃๅœจๅ‡†ๅค‡ไผš่ฏโ€ฆ ไผš่ฏๆœชๅฐฑ็ปช๏ผŒ่ฏท้‡่ฏ•ไปฅ็ปง็ปญ + ่ฎพ็ฝฎ%1$s + ็ฌฌ%1$d้กน๏ผŒๅ…ฑ%2$d้กน + ๅทฒๆŽˆๆƒ๏ผŒไฝ†ๅทฅๅ…ทไธๅฏ็”จ + ๆญค่ฟžๆŽฅๆญฅ้ชคๅทฒๅฎŒๆˆ + ๅฏ็”จๅทฅๅ…ท๏ผš%1$d + ๅฝ“ๅ‰ๅบ”็”จ็‰ˆๆœฌไธๆ”ฏๆŒๆญค่ฎพ็ฝฎๆญฅ้ชคใ€‚ + ่ฟžๆŽฅ + ่ทณ่ฟ‡ + ็ปง็ปญ + ๅœจๆต่งˆๅ™จไธญๆ‰“ๅผ€ + %1$sไธบๅฟ…ๅกซ้กน + ๆ— ๆณ•ๅ‘้€่ฎพ็ฝฎๅ“ๅบ”๏ผŒ่ฏท้‡่ฏ•ใ€‚ + ๆญฃๅœจ็ญ‰ๅพ… Hermesโ€ฆ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4045ab92..2f8e0e58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1707,4 +1707,17 @@ Play video Preparing sessionโ€ฆ Session not ready โ€” retry to continue + Set up %1$s + %1$d of %2$d + Authorized; tools unavailable + This connection step is complete + Tools available: %1$d + This setup step is not supported by this app version. + Connect + Skip + Continue + Open in browser + %1$s is required + Couldnโ€™t send the setup response. Try again. + Waiting for Hermesโ€ฆ diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt new file mode 100644 index 00000000..e1c7771e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationEventParserTest.kt @@ -0,0 +1,83 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionOperationEventParserTest { + @Test + fun connectionRequest_usesEnvelopeSessionAndPayloadSequence() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.request", + "session_id" to "runtime-session", + "seq" to 999, + "payload" to operationPayload(seq = 4L), + ), + ) + + assertTrue(event is WsEvent.ConnectionRequest) + val snapshot = (event as WsEvent.ConnectionRequest).snapshot + assertEquals("runtime-session", snapshot.sessionId) + assertEquals(4L, snapshot.seq) + } + + @Test + fun connectionUpdate_parsesFullSettledSnapshot() { + val event = + EventParser.parseParams( + mapOf( + "type" to "connection.update", + "session_id" to "runtime-session", + "payload" to + operationPayload(seq = 5L) + + mapOf( + "settled" to true, + "settled_by" to "all_resolved", + "future_field" to "ignored", + ), + ), + ) + + assertTrue(event is WsEvent.ConnectionUpdate) + val snapshot = (event as WsEvent.ConnectionUpdate).snapshot + assertTrue(snapshot.settled) + assertEquals("all_resolved", snapshot.settledBy) + } + + @Test + fun malformedOperation_degradesWithoutRetainingRawFrame() { + val event = + EventParser.parseParams( + params = + mapOf( + "type" to "connection.request", + "session_id" to "runtime-session", + "payload" to operationPayload(seq = 1L) - "op_id", + ), + rawJson = "issue1218-secret-canary", + ) + + assertTrue(event is WsEvent.Unknown) + assertEquals("", (event as WsEvent.Unknown).raw) + } + + private fun operationPayload(seq: Long): Map = + mapOf( + "op_id" to "op-1", + "seq" to seq, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 120.0, + "tool_call_id" to "tool-1", + "targets" to + listOf( + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "pending", + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt new file mode 100644 index 00000000..132ac68a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +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 ConnectionOperationParserTest { + @Test + fun parseCompleteSnapshot_preservesBackendOrderAndMetadata() { + val snapshot = ConnectionOperationParser.parse(completePayload(), "runtime-session") + + assertNotNull(snapshot) + snapshot!! + assertEquals("runtime-session", snapshot.sessionId) + assertEquals("op-1218", snapshot.opId) + assertEquals(7L, snapshot.seq) + assertEquals(2, snapshot.targets.size) + val target = snapshot.targets.first() + assertEquals("reports", target.name) + assertEquals(ConnectionTargetKind.MCP, target.kind) + assertEquals(ConnectionTargetAction.INSTALL, target.action) + assertEquals(ConnectionTargetState.PENDING, target.state) + assertEquals("install-2", target.attempt) + assertEquals("Use a workspace token", target.hint) + assertEquals(listOf("REGION", "API_TOKEN"), target.requiredEnv.map { it.name }) + assertEquals("us-east", target.requiredEnv[0].defaultValue) + assertNull(target.requiredEnv[1].defaultValue) + assertTrue(target.requiredEnv[1].secret) + assertEquals(listOf("search", "export"), target.tools) + } + + @Test + fun unknownFieldsAndEnumValues_degradeWithoutDroppingUsableTarget() { + val payload = + completePayload().toMutableMap().apply { + put("future_operation_field", mapOf("nested" to true)) + put( + "targets", + listOf( + targetPayload().toMutableMap().apply { + put("kind", "future-kind") + put("action", "future-action") + put("state", "future-state") + put("future_target_field", "ignored") + }, + ), + ) + } + + val target = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets.single() + + assertEquals(ConnectionTargetKind.UNKNOWN, target.kind) + assertEquals(ConnectionTargetAction.UNKNOWN, target.action) + assertEquals(ConnectionTargetState.UNKNOWN, target.state) + } + + @Test + fun malformedIdentityOrDeadline_isRejected() { + assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() - "seq", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() - "deadline_at", "runtime-session")) + assertNull(ConnectionOperationParser.parse(completePayload() + ("seq" to 1.5), "runtime-session")) + assertNull( + ConnectionOperationParser.parse(completePayload() + ("deadline_at" to Double.NaN), "runtime-session"), + ) + assertNull( + ConnectionOperationParser.parse(completePayload() + ("targets" to emptyList()), "runtime-session"), + ) + } + + @Test + fun secretDefaultsAndOAuthUrls_areNeverExposedByModels() { + val canary = "issue1218-secret-canary" + val payload = + completePayload() + + ( + "targets" to + listOf( + targetPayload() + + ( + "connect_url" to + "https://example.com/oauth?token=$canary" + ) + + ( + "required_env" to + listOf( + mapOf( + "name" to "API_TOKEN", + "required" to true, + "secret" to true, + "default" to canary, + ), + ) + ), + ) + ) + + val snapshot = ConnectionOperationParser.parse(payload, "runtime-session")!! + + assertNull( + snapshot.targets + .single() + .requiredEnv + .single() + .defaultValue, + ) + assertFalse(snapshot.toString().contains(canary)) + assertTrue(snapshot.toString().contains("[REDACTED]")) + } + + private fun completePayload(): Map = + mapOf( + "op_id" to "op-1218", + "seq" to 7L, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 300.0, + "tool_call_id" to "tool-1", + "settled" to false, + "targets" to + listOf( + targetPayload(), + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "initiated", + ), + ), + ) + + private fun targetPayload(): Map = + mapOf( + "name" to "reports", + "kind" to "mcp", + "action" to "install", + "state" to "pending", + "detail" to "Credentials required", + "instructions" to "Enter the service credentials", + "discovery_error" to null, + "connect_url" to null, + "connection_id" to "vendor-account-1", + "attempt" to "install-2", + "hint" to "Use a workspace token", + "required_env" to + listOf( + mapOf( + "name" to "REGION", + "required" to false, + "secret" to false, + "default" to "us-east", + "prompt" to "Region", + ), + mapOf( + "name" to "API_TOKEN", + "required" to true, + "secret" to true, + "default" to "must-not-be-retained", + "prompt" to "API token", + ), + ), + "tools" to listOf("search", "export"), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt new file mode 100644 index 00000000..107e8086 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -0,0 +1,314 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.ConnectionEnvField +import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot +import com.m57.hermescontrol.data.model.ConnectionOperationTarget +import com.m57.hermescontrol.data.model.ConnectionTargetAction +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatConnectionOperationDelegateTest { + @Test + fun requestAndUpdate_areStrictlyOrderedAndCorrelated() { + val delegate = delegate() + delegate.reset("session-a") + + assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-old", seq = 99L))) + assertEquals( + "op-a", + delegate.state.value.operation + ?.opId, + ) + assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L))) + assertEquals( + 2L, + delegate.state.value.operation + ?.seq, + ) + + assertTrue(delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L))) + assertFalse(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 3L))) + assertEquals( + "op-b", + delegate.state.value.operation + ?.opId, + ) + } + + @Test + fun wrongSessionAndSettledOperation_areNeverReopened() { + val delegate = delegate() + delegate.reset("session-a") + + assertFalse(delegate.acceptRequest(snapshot(sessionId = "session-b"))) + assertTrue(delegate.acceptRequest(snapshot(opId = "op-a", seq = 1L))) + assertTrue(delegate.acceptUpdate(snapshot(opId = "op-a", seq = 2L, settled = true))) + assertNull(delegate.state.value.operation) + assertFalse(delegate.acceptRequest(snapshot(opId = "op-a", seq = 3L))) + } + + @Test + fun resumeReconciliation_cannotRegressOrClearNewerLiveRequest() { + val delegate = delegate() + delegate.reset("session-a") + delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L)) + val checkpoint = delegate.resumeCheckpoint() + + delegate.acceptRequest(snapshot(opId = "op-b", seq = 1L)) + + assertFalse(delegate.reconcileResume(null, checkpoint)) + assertFalse(delegate.reconcileResume(snapshot(opId = "op-a", seq = 2L), checkpoint)) + assertEquals( + "op-b", + delegate.state.value.operation + ?.opId, + ) + } + + @Test + fun absentResumeSnapshot_clearsOnlyUnchangedOperation() { + val delegate = delegate() + delegate.reset("session-a") + delegate.acceptRequest(snapshot(opId = "op-a", seq = 2L)) + val checkpoint = delegate.resumeCheckpoint() + + assertTrue(delegate.reconcileResume(null, checkpoint)) + assertNull(delegate.state.value.operation) + } + + @Test + fun approvePayload_isExactAndSecretsNeverEnterUiState() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot(requiredEnv = listOf(envField("TOKEN", secret = true)))) + val canary = "issue1218-secret-canary" + + delegate.respond( + target = "github", + env = mapOf("TOKEN" to canary, "UNDECLARED" to "drop-me"), + approved = true, + ) + + assertEquals(1, requester.calls.size) + val call = requester.calls.single() + assertEquals(WsMethods.CONNECTION_RESPOND, call.first) + assertEquals("session-a", call.second["session_id"]) + assertEquals("op-a", call.second["op_id"]) + @Suppress("UNCHECKED_CAST") + val result = call.second["result"] as Map + + @Suppress("UNCHECKED_CAST") + val answer = (result["targets"] as List>).single() + assertEquals("github", answer["name"]) + assertEquals("approved", answer["status"]) + assertEquals(mapOf("TOKEN" to canary), answer["env"]) + assertFalse( + delegate.state.value + .toString() + .contains(canary), + ) + assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond) + } + + @Test + fun rpcSuccessKeepsExactlyOnceLock_untilNewerSnapshotArrives() = + runTest { + val requester = RecordingRequester() + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot(seq = 1L)) + + delegate.respond("github", emptyMap(), approved = false) + delegate.respond("github", emptyMap(), approved = false) + + assertEquals(1, requester.calls.size) + assertEquals( + ConnectionTargetState.PENDING, + delegate.state.value.operation + ?.targets + ?.single() + ?.state, + ) + assertTrue(delegate.state.value.pendingAction is ConnectionPendingAction.Respond) + + delegate.acceptUpdate(snapshot(seq = 2L, state = ConnectionTargetState.PENDING)) + assertNull(delegate.state.value.pendingAction) + delegate.respond("github", emptyMap(), approved = false) + assertEquals(2, requester.calls.size) + } + + @Test + fun skipAndContinuePayloads_matchBackendContract() = + runTest { + val skipRequester = RecordingRequester() + val skipDelegate = delegate(skipRequester) + skipDelegate.reset("session-a") + skipDelegate.acceptRequest(snapshot()) + skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) + + @Suppress("UNCHECKED_CAST") + val skipResult = skipRequester.calls.single().second["result"] as Map + + @Suppress("UNCHECKED_CAST") + val skipAnswer = (skipResult["targets"] as List>).single() + assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) + + val continueRequester = RecordingRequester() + val continueDelegate = delegate(continueRequester) + continueDelegate.reset("session-a") + continueDelegate.acceptRequest(snapshot()) + continueDelegate.continueOperation() + + assertEquals( + mapOf("settled_by" to "continue"), + continueRequester.calls.single().second["result"], + ) + } + + @Test + fun rpcFailureUnlocksWithSanitizedError() = + runTest { + val requester = RecordingRequester(failure = IllegalStateException("secret backend detail")) + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.respond("github", emptyMap(), approved = false) + + assertNull(delegate.state.value.pendingAction) + assertEquals( + "request_failed", + delegate.state.value.error + ?.message, + ) + assertFalse( + delegate.state.value + .toString() + .contains("secret backend detail"), + ) + } + + @Test + fun lateOldGenerationFailure_cannotMutateResetState() = + runTest { + val gate = CompletableDeferred() + val requester = + ConnectionOperationRequester { _, _ -> + gate.await() + throw IllegalStateException("old failure") + } + val delegate = ChatConnectionOperationDelegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + val action = launch { delegate.respond("github", emptyMap(), approved = false) } + runCurrent() + delegate.reset("session-b") + gate.complete(Unit) + action.join() + + assertNull(delegate.state.value.operation) + assertNull(delegate.state.value.error) + } + + @Test + fun wakeUnknownOperation_isHarmlessSettledRace() = + runTest { + val requester = + RecordingRequester( + failure = HermesWsClient.HermesRpcException("gone", code = 4004), + ) + val delegate = delegate(requester) + delegate.reset("session-a") + delegate.acceptRequest(snapshot()) + + delegate.wake("op-a") + + assertNull(delegate.state.value.operation) + assertNull(delegate.state.value.error) + } + + private fun delegate(requester: RecordingRequester = RecordingRequester()): ChatConnectionOperationDelegate = + ChatConnectionOperationDelegate(requester) + + private fun snapshot( + sessionId: String = "session-a", + opId: String = "op-a", + seq: Long = 1L, + state: ConnectionTargetState = ConnectionTargetState.PENDING, + settled: Boolean = false, + requiredEnv: List = emptyList(), + ): ConnectionOperationSnapshot = + ConnectionOperationSnapshot( + sessionId = sessionId, + opId = opId, + seq = seq, + deadlineAt = 2_000_000_000.0, + timeoutSeconds = 300.0, + toolCallId = "tool-a", + settled = settled, + settledBy = if (settled) "all_resolved" else null, + targets = + listOf( + ConnectionOperationTarget( + name = "github", + kind = ConnectionTargetKind.CONNECTOR, + action = ConnectionTargetAction.AUTHORIZE, + state = state, + detail = null, + instructions = null, + discoveryError = null, + connectUrl = null, + connectionId = null, + attempt = null, + requiredEnv = requiredEnv, + tools = emptyList(), + hint = null, + ), + ), + ) + + private fun envField( + name: String, + secret: Boolean, + ): ConnectionEnvField = + ConnectionEnvField( + name = name, + required = true, + secret = secret, + defaultValue = null, + prompt = null, + ) + + private class RecordingRequester( + private val failure: Throwable? = null, + ) : ConnectionOperationRequester { + val calls = mutableListOf>>() + + override suspend fun request( + method: String, + params: Map, + ): Any? { + calls += method to params + failure?.let { throw it } + return Unit + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index a624c929..e1faf600 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.GatewayFileResult import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator +import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.JsonRpcError @@ -411,6 +412,77 @@ class ChatViewModelTest { return Pair(viewModel, "session-123") } + @Test + fun connectionOperation_liveEventRoutesAndSessionResetClearsIt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val snapshot = connectionOperationSnapshot(sessionId = sessionId) + + mockEventsFlow.emit(WsEvent.ConnectionRequest(snapshot)) + advanceUntilIdle() + assertEquals( + "op-1218", + viewModel.connectionOperationState.value.operation + ?.opId, + ) + + viewModel.createNewSession() + advanceUntilIdle() + assertNull(viewModel.connectionOperationState.value.operation) + + mockEventsFlow.emit(WsEvent.ConnectionUpdate(snapshot.copy(seq = 2L))) + advanceUntilIdle() + assertNull(viewModel.connectionOperationState.value.operation) + } + + @Test + fun connectionOperation_sessionResumeRestoresPendingSnapshot() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.switchSession("stored-session") + advanceUntilIdle() + val resumeRequestId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + + mockEventsFlow.emit( + WsEvent.RpcResult( + resumeRequestId, + mapOf( + "session_id" to "runtime-session", + "pending_connection" to connectionOperationPayload(), + ), + ), + ) + advanceUntilIdle() + + val operation = viewModel.connectionOperationState.value.operation + assertEquals("runtime-session", operation?.sessionId) + assertEquals("op-1218", operation?.opId) + } + + private fun connectionOperationSnapshot( + sessionId: String, + seq: Long = 1L, + ) = checkNotNull(ConnectionOperationParser.parse(connectionOperationPayload(seq), sessionId)) + + private fun connectionOperationPayload(seq: Long = 1L): Map = + mapOf( + "op_id" to "op-1218", + "seq" to seq, + "deadline_at" to 2_000_000_000.0, + "timeout_seconds" to 300.0, + "tool_call_id" to "tool-1218", + "targets" to + listOf( + mapOf( + "name" to "github", + "kind" to "connector", + "action" to "authorize", + "state" to "pending", + ), + ), + ) + // โ”€โ”€ Slash command tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index f64050d3..3b0c064b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -53,11 +53,13 @@ class SlashCommandDispatchRpcTest { private lateinit var app: Application private lateinit var fakeRepo: FakeChatPersistenceRepository private var reqCount = 0 + private var sessionCreateRequestId: String? = null @Before fun setUp() { Dispatchers.setMain(testDispatcher) reqCount = 0 + sessionCreateRequestId = null mockkStatic(android.util.Log::class) every { android.util.Log.d(any(), any()) } returns 0 @@ -114,6 +116,9 @@ class SlashCommandDispatchRpcTest { every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ val id = "req-id-$reqCount" + if (arg(0) == WsMethods.SESSION_CREATE) { + sessionCreateRequestId = id + } arg<((String) -> Unit)?>(2)?.invoke(id) id } @@ -152,8 +157,8 @@ class SlashCommandDispatchRpcTest { mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) advanceUntilIdle() - // req-id-3 = session.create (after loadSessions + fetchCommandCatalog) - mockEventsFlow.emit(WsEvent.RpcResult("req-id-3", mapOf("session_id" to "session-xyz"))) + val createRequestId = requireNotNull(sessionCreateRequestId) { "session.create was not sent" } + mockEventsFlow.emit(WsEvent.RpcResult(createRequestId, mapOf("session_id" to "session-xyz"))) advanceUntilIdle() return Pair(vm, "session-xyz") } From 62b865b7d13fd95135cc215cef33705024690467 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:44:03 +0300 Subject: [PATCH 10/18] revert: remove duplicate cron run history (#1231) --- .../data/model/CronRunHistory.kt | 37 ---- .../data/remote/HermesApiService.kt | 7 - .../hermescontrol/ui/cron/CronJobsScreen.kt | 159 ------------------ .../ui/cron/CronJobsViewModel.kt | 114 +------------ app/src/main/res/values/strings.xml | 12 -- .../data/remote/HermesApiServiceTest.kt | 102 ----------- .../remote/ProfileScopeInterceptorTest.kt | 14 -- .../ui/cron/CronJobsViewModelTest.kt | 74 -------- 8 files changed, 2 insertions(+), 517 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt deleted file mode 100644 index 50ad8521..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.m57.hermescontrol.data.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CronRunHistoryResponse( - val runs: List = emptyList(), - val limit: Int = 20, -) - -/** - * One Hermes Agent cron execution. - * - * The backend exposes cron runs using the same enriched row shape as - * `/api/sessions`, then annotates each row with its owning profile and - * active/archive state. Only [id] is required; older backend rows can omit any - * of the enrichment fields without making the whole history unusable. - */ -@Serializable -data class CronRun( - val id: String, - val source: String? = null, - val model: String? = null, - val title: String? = null, - val started_at: Double? = null, - val ended_at: Double? = null, - val last_active: Double? = null, - val is_active: Boolean = false, - val archived: Boolean = false, - val message_count: Int? = null, - val tool_call_count: Int? = null, - val input_tokens: Int? = null, - val output_tokens: Int? = null, - val preview: String? = null, - val parent_session_id: String? = null, - val profile: String? = null, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 38d95160..7b0a393b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -21,7 +21,6 @@ import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse @@ -360,12 +359,6 @@ interface HermesApiService : KanbanApiService { @GET("api/cron/jobs") suspend fun getCronJobs(): Response> - @GET("api/cron/jobs/{id}/runs") - suspend fun getCronJobRuns( - @Path("id") id: String, - @Query("limit") limit: Int = 20, - ): Response - @POST("api/cron/jobs/{id}/pause") suspend fun pauseCronJob( @Path("id") id: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index b006d543..ddbc7235 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -19,7 +18,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,7 +32,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -52,7 +49,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -63,7 +59,6 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -80,10 +75,6 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.util.CronExpressionFormatter -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable fun CronJobsScreen( @@ -233,15 +224,6 @@ fun CronJobsScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - IconButton( - onClick = { viewModel.openRunHistory(job) }, - ) { - Icon( - imageVector = Icons.Filled.History, - contentDescription = stringResource(R.string.cron_action_history), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } IconButton( onClick = { viewModel.openEditJobDialog(job.id) }, ) { @@ -359,14 +341,6 @@ fun CronJobsScreen( ) } - if (state.runHistoryState.isOpen) { - CronRunHistoryDialog( - state = state.runHistoryState, - onRetry = viewModel::retryRunHistory, - onDismiss = viewModel::closeRunHistory, - ) - } - // โ”€โ”€ Delete Confirm Dialog โ”€โ”€ state.deleteTarget?.let { job -> AlertDialog( @@ -387,139 +361,6 @@ fun CronJobsScreen( } } -@Composable -private fun CronRunHistoryDialog( - state: CronRunHistoryState, - onRetry: () -> Unit, - onDismiss: () -> Unit, -) { - val spacing = LocalSpacing.current - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.cron_history_title, state.jobName), - style = MaterialTheme.typography.titleLarge, - ) - }, - text = { - when { - state.isLoading -> { - LoadingState( - modifier = Modifier.height(240.dp), - subtitle = stringResource(R.string.cron_history_loading), - ) - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage, - onRetry = onRetry, - modifier = Modifier.height(240.dp), - ) - } - - state.runs.isEmpty() -> { - EmptyState( - icon = Icons.Filled.History, - title = stringResource(R.string.cron_history_empty_title), - subtitle = stringResource(R.string.cron_history_empty_desc), - modifier = Modifier.height(240.dp), - ) - } - - else -> { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 420.dp), - verticalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - items(state.runs, key = { it.id }) { run -> - CronRunRow(run) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cron_history_close)) - } - }, - ) -} - -@Composable -private fun CronRunRow(run: CronRun) { - val spacing = LocalSpacing.current - val (statusText, statusType) = - when { - run.is_active -> stringResource(R.string.cron_history_status_active) to StatusBadgeType.SUCCESS - run.archived -> stringResource(R.string.cron_history_status_archived) to StatusBadgeType.NEUTRAL - run.ended_at != null -> stringResource(R.string.cron_history_status_completed) to StatusBadgeType.INFO - else -> stringResource(R.string.cron_history_status_inactive) to StatusBadgeType.NEUTRAL - } - val startedAt = formatCronRunTime(run.started_at) - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = startedAt ?: run.id, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - StatusBadge(text = statusText, status = statusType) - } - if (!run.profile.isNullOrBlank()) { - Text( - text = stringResource(R.string.cron_history_profile, run.profile), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (run.message_count != null) { - Text( - text = stringResource(R.string.cron_history_messages, run.message_count), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (!run.preview.isNullOrBlank()) { - Text( - text = run.preview, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -private fun formatCronRunTime(epochSeconds: Double?): String? { - if (epochSeconds == null) return null - return runCatching { - Instant - .ofEpochMilli((epochSeconds * 1000).toLong()) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) - }.getOrNull() -} - @Composable fun CronJobEditorDialog( state: CronJobEditorState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index b7b8e9c9..27821eac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.remote.ApiClient -import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ChangeEvents import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange +import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -32,8 +32,6 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -private const val CRON_RUN_HISTORY_LIMIT = 20 - data class CronJobsUiState( val isLoading: Boolean = false, val jobs: List = emptyList(), @@ -43,16 +41,6 @@ data class CronJobsUiState( val deleteTarget: CronJob? = null, // Editor state val editorState: CronJobEditorState = CronJobEditorState(), - val runHistoryState: CronRunHistoryState = CronRunHistoryState(), -) - -data class CronRunHistoryState( - val isOpen: Boolean = false, - val jobId: String? = null, - val jobName: String = "", - val isLoading: Boolean = false, - val runs: List = emptyList(), - val errorMessage: String? = null, ) data class CronJobEditorState( @@ -112,13 +100,10 @@ class CronJobsViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null - private var runHistoryJob: Job? = null internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val jobsCache = SwrCache>() fun clearScopeOwnedState() { - runHistoryJob?.cancel() - runHistoryJob = null _uiState.update { it.copy( isLoading = false, @@ -126,7 +111,6 @@ class CronJobsViewModel : errorMessage = null, deleteTarget = null, editorState = CronJobEditorState(), - runHistoryState = CronRunHistoryState(), ) } } @@ -171,100 +155,6 @@ class CronJobsViewModel : ) } - fun openRunHistory(job: CronJob) { - runHistoryJob?.cancel() - _uiState.update { - it.copy( - runHistoryState = - CronRunHistoryState( - isOpen = true, - jobId = job.id, - jobName = job.name, - isLoading = true, - ), - ) - } - loadRunHistory(job.id) - } - - fun retryRunHistory() { - val jobId = _uiState.value.runHistoryState.jobId ?: return - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = true, - errorMessage = null, - ), - ) - } - loadRunHistory(jobId) - } - - fun closeRunHistory() { - runHistoryJob?.cancel() - runHistoryJob = null - _uiState.update { it.copy(runHistoryState = CronRunHistoryState()) } - } - - private fun loadRunHistory(jobId: String) { - val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() - runHistoryJob = - viewModelScope.launch { - val result = - withContext(ioDispatcher) { - safeApiCall { - ApiClient.hermesApi.getCronJobRuns( - id = jobId, - limit = CRON_RUN_HISTORY_LIMIT, - ) - } - } - val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() - val history = _uiState.value.runHistoryState - if (!history.isOpen || - history.jobId != jobId || - (requestScope != null && currentScope != requestScope) - ) { - return@launch - } - - when (result) { - is NetworkResult.Success -> { - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = false, - runs = result.data.runs, - errorMessage = null, - ), - ) - } - } - - is NetworkResult.Failure -> { - val message = - if (result.error is NetworkError.Http && result.error.code == 404) { - "Run history is unavailable on this Hermes Agent version." - } else { - "Failed to load run history: ${result.error.message}" - } - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = false, - runs = emptyList(), - errorMessage = message, - ), - ) - } - } - } - } - } - fun pauseCronJob(id: String) { val originalJobs = _uiState.value.jobs val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f8e0e58..206f0415 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1161,7 +1161,6 @@ Pause Resume Run - Run history Edit Add cron job Edit Cron Job @@ -1202,17 +1201,6 @@ You have unsaved changes. Discard them? Delete job? Delete \"%1$s\"? This can\'t be undone. - %1$s runs - Loading run history - No runs yet - Recent executions for this job will appear here. - Active - Archived - Completed - Inactive - Profile: %1$s - Messages: %1$d - Close No achievements yet diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt index c8b16601..a037dd9b 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt @@ -99,108 +99,6 @@ class HermesApiServiceTest { assertEquals("GET", recordedRequest.method) } - @Test - fun testGetCronJobRuns_requestsBoundedHistoryAndParsesResponse() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody( - """ - { - "runs": [ - { - "id": "cron_job_1_1700000000", - "source": "cron", - "model": "gpt", - "title": "Backup", - "started_at": 1700000000.5, - "ended_at": 1700000020.0, - "last_active": 1700000020.0, - "is_active": false, - "archived": true, - "message_count": 2, - "tool_call_count": 1, - "input_tokens": 123, - "output_tokens": 45, - "preview": "done", - "profile": "worker" - } - ], - "limit": 20 - } - """.trimIndent(), - ), - ) - - val response = apiService.getCronJobRuns("job_1", limit = 20) - - assertTrue(response.isSuccessful) - val run = response.body()?.runs?.single() - assertNotNull(run) - assertEquals("cron_job_1_1700000000", run?.id) - assertEquals("worker", run?.profile) - assertEquals(true, run?.archived) - assertEquals(2, run?.message_count) - assertEquals("done", run?.preview) - - val recordedRequest = mockWebServer.takeRequest() - assertEquals("/api/cron/jobs/job_1/runs?limit=20", recordedRequest.path) - assertEquals("GET", recordedRequest.method) - } - - @Test - fun testGetCronJobRuns_emptyResponse() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[],"limit":20}"""), - ) - - val response = apiService.getCronJobRuns("job_1") - - assertTrue(response.isSuccessful) - assertTrue(response.body()?.runs?.isEmpty() == true) - } - - @Test - fun testGetCronJobRuns_missingOptionalFieldsUsesDefaults() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[{"id":"cron_job_1_1"}],"limit":20}"""), - ) - - val run = - apiService - .getCronJobRuns("job_1") - .body() - ?.runs - ?.single() - - assertNotNull(run) - assertNull(run?.started_at) - assertEquals(false, run?.is_active) - assertEquals(false, run?.archived) - } - - @Test - fun testGetCronJobRuns_missingRequiredIdThrows() { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[{"started_at":1.0}],"limit":20}"""), - ) - - assertThrows(kotlinx.serialization.SerializationException::class.java) { - runTest { - apiService.getCronJobRuns("job_1") - } - } - } - @Test fun testPauseCronJob_sendsPostRequest() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index 829ddcbc..97a5a730 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -125,20 +125,6 @@ class ProfileScopeInterceptorTest { } } - @Test - fun cronRunHistory_preservesLimitAndFollowsActiveProfileAcrossSwitches() { - val client = clientFor("alpha") - - val alphaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") - assertEquals("alpha", alphaUrl.queryParameter("profile")) - assertEquals("20", alphaUrl.queryParameter("limit")) - - AuthManager.setActiveProfileId("beta") - val betaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") - assertEquals("beta", betaUrl.queryParameter("profile")) - assertEquals("20", betaUrl.queryParameter("limit")) - } - @Test fun skillsEndpoint_isScoped() { val client = clientFor("alpha") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index be16d085..fd1cc2c4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -6,8 +6,6 @@ import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJobFireError -import com.m57.hermescontrol.data.model.CronRun -import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -21,7 +19,6 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -128,77 +125,6 @@ class CronJobsViewModelTest { assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions) } - @Test - fun `openRunHistory loads bounded recent runs`() { - coEvery { mockApi.getCronJobRuns("j1", 20) } returns - Response.success( - CronRunHistoryResponse( - runs = - listOf( - CronRun( - id = "cron_j1_1", - started_at = 1_700_000_000.0, - is_active = true, - profile = "default", - ), - ), - limit = 20, - ), - ) - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - settle() - - val history = vm.uiState.value.runHistoryState - assertTrue(history.isOpen) - assertFalse(history.isLoading) - assertEquals("j1", history.jobId) - assertEquals("Morning", history.jobName) - assertEquals(listOf("cron_j1_1"), history.runs.map { it.id }) - coVerify(exactly = 1) { mockApi.getCronJobRuns("j1", 20) } - } - - @Test - fun `openRunHistory explains unsupported backend`() { - coEvery { mockApi.getCronJobRuns("j1", 20) } returns - Response.error(404, "{}".toResponseBody()) - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - settle() - - val history = vm.uiState.value.runHistoryState - assertFalse(history.isLoading) - assertTrue(history.runs.isEmpty()) - assertTrue(history.errorMessage.orEmpty().contains("unavailable")) - } - - @Test - fun `scope reset cancels in flight run history and clears rows`() { - val response = CompletableDeferred>() - coEvery { mockApi.getCronJobRuns("j1", 20) } coAnswers { response.await() } - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - testDispatcher.scheduler.runCurrent() - vm.clearScopeOwnedState() - response.complete( - Response.success( - CronRunHistoryResponse( - runs = listOf(CronRun(id = "cron_j1_stale", profile = "old-profile")), - ), - ), - ) - settle() - - val history = vm.uiState.value.runHistoryState - assertFalse(history.isOpen) - assertFalse(history.isLoading) - assertTrue(history.runs.isEmpty()) - assertNull(history.jobId) - } - @Test fun `selectBlueprint seeds slot defaults`() { val vm = createViewModel() From e539d650cf35d88d4da694bf9afa0a2986292975 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:26:45 +0300 Subject: [PATCH 11/18] fix(#1215): show omitted process output count (#1232) --- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 7 +++++ .../ui/chat/tool/ToolRenderer.kt | 2 ++ .../hermescontrol/ui/chat/tool/ToolView.kt | 2 ++ .../ui/chat/tool/ToolViewBuilder.kt | 1 + .../ui/chat/tool/render/TaskRenderers.kt | 12 ++++++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ui/chat/tool/ToolViewBuilderTest.kt | 29 +++++++++++++++++++ 10 files changed, 57 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index ce0f49b6..5ed735bd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -495,6 +495,13 @@ private fun ExpandedToolContent( } } + view.outputCut?.let { omitted -> + Text( + text = stringResource(R.string.chat_tool_output_omitted, omitted), + style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning), + ) + } + // โ”€โ”€ Duration footer โ”€โ”€ view.durationLabel?.let { Text( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt index 09a23609..3216b66f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -41,6 +41,8 @@ internal interface ToolRenderer { */ internal data class ToolViewExtras( val detailLabel: String? = null, + /** Process output characters omitted by the backend. */ + val outputCut: Long? = null, val stdout: String? = null, val stderr: String? = null, val exitCode: Int? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index 112d6039..f8c1394d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -35,6 +35,8 @@ data class ToolView( val detail: String = "", /** Optional section label above [detail] (e.g. "Search results"). */ val detailLabel: String? = null, + /** Process output characters omitted by the backend. */ + val outputCut: Long? = null, /** Compact count, e.g. "3 results". */ val countLabel: String? = null, /** Human duration, e.g. "1.2s". */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index d4b436fe..a95d672b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -106,6 +106,7 @@ object ToolViewBuilder { subtitle = subtitle, detail = clampedDetail, detailLabel = extras.detailLabel, + outputCut = extras.outputCut, countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" }, durationLabel = ToolJson.durationLabel(call.result), stdout = clampedStdout, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt index 0d137c92..7beb9887 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt @@ -3,9 +3,12 @@ package com.m57.hermescontrol.ui.chat.tool.render import com.m57.hermescontrol.ui.chat.tool.ToolCall import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull /** `cronjob`: job listings with schedules, or the mutated job's key fields. */ internal object CronjobRenderer : ToolRenderer { @@ -214,6 +217,15 @@ internal object SessionSearchRenderer : ToolRenderer { /** `process`: background session listings and lifecycle acks. */ internal object ProcessRenderer : ToolRenderer { + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + if (status == ToolViewStatus.RUNNING) return ToolViewExtras.NONE + val count = (call.result?.get("output_cut") as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + return ToolViewExtras(outputCut = count?.takeIf { it > 0 }) + } + override fun subtitle(call: ToolCall): String { val action = ToolJson.firstString(call.args, listOf("action")) val procId = ToolJson.firstString(call.args, listOf("session_id")) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 69e49508..2051265b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -165,6 +165,7 @@ ุงูƒุชุจ ุฑุณุงู„ุฉ\u2026 ุฎุทุฃ ููŠ ุงู„ุชู†ููŠุฐ: %1$s ุฑู…ุฒ ุงู„ุฎุฑูˆุฌ: %1$d +ุชู… ุญุฐู %1$d ุญุฑู ู…ู† ู…ุฎุฑุฌุงุช ุงู„ุนู…ู„ูŠุฉ ุฃุฏุงุฉ ุนุฑุถ ู…ู„ู JSON ุงู„ุฃูˆู„ูŠ ุนุฑุถ ุงู„ู…ุฎุฑุฌุงุช ุงู„ู…ุนุงู„ุฌุฉ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index adbf0164..cff47d0a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -162,6 +162,7 @@ ๋ฉ”์‹œ์ง€ ์ž…๋ ฅโ€ฆ ์‹คํ–‰ ์˜ค๋ฅ˜: %1$s ์ข…๋ฃŒ ์ฝ”๋“œ: %1$d + ํ”„๋กœ์„ธ์Šค ์ถœ๋ ฅ์—์„œ %1$d์ž ์ƒ๋žต๋จ ๋„๊ตฌ Raw JSON ๋ณด๊ธฐ ํŒŒ์‹ฑ๋œ ๊ฒฐ๊ณผ ๋ณด๊ธฐ diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index dc9370f7..c4dcb11c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -165,6 +165,7 @@ ่พ“ๅ…ฅๆถˆๆฏโ€ฆ ๆ‰ง่กŒ้”™่ฏฏ๏ผš%1$s ้€€ๅ‡บ็ ๏ผš%1$d + ่ฟ›็จ‹่พ“ๅ‡บ็œ็•ฅไบ† %1$d ไธชๅญ—็ฌฆ ๅทฅๅ…ท ๆ˜พ็คบๅŽŸๅง‹ JSON ๆ˜พ็คบ่งฃๆž่พ“ๅ‡บ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 206f0415..9b8a1b77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,7 @@ Type a message\u2026 Execution Error: %1$s Exit Code: %1$d + %1$d characters omitted from process output Tool Show Raw JSON Show Parsed Output diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index 425b3464..b6ecac48 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -308,6 +308,35 @@ class ToolViewBuilderTest { assertEquals("poll: proc-1", view.subtitle) assertTrue(view.detail.contains("Status: running")) assertTrue(view.detail.contains("listening on port 8080")) + assertEquals(null, view.outputCut) + } + + @Test + fun `process output reports backend omitted character count for each action`() { + listOf("poll", "wait", "log", "kill").forEach { action -> + val outputKey = if (action == "poll") "output_preview" else "output" + val view = + build( + "process_manage", + """{"action":"$action","session_id":"proc-1"}""", + """{"status":"exited","$outputKey":"partial output","output_cut":42}""", + ) + + assertTrue(view.detail.contains("partial output")) + assertEquals(42L, view.outputCut) + } + } + + @Test + fun `process output without positive numeric omission has no notice`() { + listOf("{}", """{"output_cut":0}""", """{"output_cut":"unknown"}""").forEach { metadata -> + val fields = metadata.removePrefix("{").removeSuffix("}") + val result = """{"output":"complete"${if (fields.isNotEmpty()) ",$fields" else ""}}""" + val view = build("process", """{"action":"wait"}""", result) + + assertEquals("complete", view.detail) + assertEquals(null, view.outputCut) + } } @Test From ee634e2abb0ca2bd9fa06728cc89df9054451d40 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:33:17 +0300 Subject: [PATCH 12/18] fix(#1214): render generated images inline (#1233) --- .../ui/chat/tool/ToolViewBuilder.kt | 2 +- .../chat/tool/ToolViewCharacterizationTest.kt | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index a95d672b..b70a26ff 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -154,7 +154,7 @@ object ToolViewBuilder { ): String? { val candidate = ToolJson - .firstString(result, listOf("image_url", "url", "path", "image_path")) + .firstString(result, listOf("image_url", "image", "url", "path", "image_path")) .ifEmpty { ToolJson.firstString(args, listOf("image_url", "url", "path")) } if (candidate.isEmpty()) { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index aeb33d05..8e05020d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -592,8 +592,27 @@ class ToolViewCharacterizationTest { assertEquals("https://img.example/cat.png (image)", view.subtitle) assertEquals("๐Ÿ–ผ๏ธ a cat\n\n๐Ÿ”— https://img.example/cat.png", view.detail) - // Pinned: imageUrlFor does not read the "image" key, so no thumbnail. - assertNull(view.imageUrl) + assertEquals("https://img.example/cat.png", view.imageUrl) + } + + @Test + fun `image_generate data image reaches inline image view`() { + val view = build("image_generate", """{"prompt":"a cat"}""", """{"image":"data:image/png;base64,AAA"}""") + + assertEquals("data:image/png;base64,AAA", view.imageUrl) + } + + @Test + fun `image_generate http image reaches inline image view`() { + val view = build("image_generate", "{}", """{"image":"http://img.example/cat.webp"}""") + + assertEquals("http://img.example/cat.webp", view.imageUrl) + } + + @Test + fun `image_generate local path and non-image url do not reach inline image view`() { + assertNull(build("image_generate", "{}", """{"image":"/tmp/cat.png"}""").imageUrl) + assertNull(build("image_generate", "{}", """{"image":"https://img.example/page"}""").imageUrl) } @Test From 721edef618cc7b9aba57673e9655882e4e4bb445 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:50:18 +0300 Subject: [PATCH 13/18] feat(#1222): add background connection toggle to chat menu (#1235) --- .../ChatBackgroundConnectionMenuItemTest.kt | 47 +++++++++++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 32 ++++++++++++- .../ui/settings/SettingsViewModel.kt | 6 +++ .../ui/settings/SettingsViewModelTest.kt | 28 +++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt new file mode 100644 index 00000000..7d0dffb4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatBackgroundConnectionMenuItemTest.kt @@ -0,0 +1,47 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChatBackgroundConnectionMenuItemTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun menuReflectsStateAndTogglesInBothDirections() { + var enabled by mutableStateOf(false) + compose.setContent { + ChatBackgroundConnectionMenuItem( + checked = enabled, + onToggle = { enabled = it }, + ) + } + + compose.onNodeWithTag("chat_menu_keep_connected").assertIsDisplayed() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.onNodeWithTag("chat_menu_keep_connected").performClick() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOn() + compose.runOnIdle { assertEquals(true, enabled) } + + // The next opening reads the externally changed value rather than a stale menu snapshot. + compose.runOnIdle { enabled = false } + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.runOnIdle { enabled = true } + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).performClick() + compose.onNodeWithTag("chat_menu_keep_connected_checkbox", useUnmergedTree = true).assertIsOff() + compose.runOnIdle { assertEquals(false, enabled) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ed645214..65e30861 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -117,6 +118,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog +import com.m57.hermescontrol.ui.settings.SettingsViewModel import com.m57.hermescontrol.util.ConnectorUrlValidator import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -160,6 +162,8 @@ fun ChatScreen( val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle() val connectionOperationState by viewModel.connectionOperationState.collectAsStateWithLifecycle() + val settingsViewModel: SettingsViewModel = viewModel() + val settingsState by settingsViewModel.uiState.collectAsStateWithLifecycle() // Snapshot-backed search state โ€” read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState @@ -539,7 +543,10 @@ fun ChatScreen( var showSessionMenu by remember { mutableStateOf(false) } Box { IconButton( - onClick = { showSessionMenu = true }, + onClick = { + settingsViewModel.refreshKeepConnectedInBackground() + showSessionMenu = true + }, modifier = Modifier.testTag("chat_session_menu_button"), ) { Icon( @@ -609,6 +616,10 @@ fun ChatScreen( }, modifier = Modifier.testTag("chat_menu_session_integrations"), ) + ChatBackgroundConnectionMenuItem( + checked = settingsState.keepConnectedInBackground, + onToggle = settingsViewModel::onKeepConnectedInBackgroundChange, + ) } } }, @@ -1029,3 +1040,22 @@ fun ChatScreen( ) } } + +@Composable +internal fun ChatBackgroundConnectionMenuItem( + checked: Boolean, + onToggle: (Boolean) -> Unit, +) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_keep_connected_in_background_title)) }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = onToggle, + modifier = Modifier.testTag("chat_menu_keep_connected_checkbox"), + ) + }, + onClick = { onToggle(!checked) }, + modifier = Modifier.testTag("chat_menu_keep_connected"), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 624efb1d..c1e38f2f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -376,6 +376,12 @@ class SettingsViewModel( AuthManager.setKeepConnectedInBackground(enabled) } + /** Reconcile the chat shortcut with changes made through Settings or another screen. */ + fun refreshKeepConnectedInBackground() { + val enabled = AuthManager.isKeepConnectedInBackground() + _uiState.update { it.copy(keepConnectedInBackground = enabled) } + } + /** Clear all auth credentials โ€” logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index ce4b388e..9df61ed2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -383,6 +383,34 @@ class SettingsViewModelTest { verify { AuthManager.setKeepConnectedInBackground(false) } } + @Test + fun testKeepConnectedInBackground_refreshesBothDirectionsFromPersistedSetting() = + runTest { + var persisted = false + every { AuthManager.isKeepConnectedInBackground() } answers { persisted } + every { AuthManager.setKeepConnectedInBackground(any()) } answers { + persisted = firstArg() + } + val viewModel = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + // An edit from Settings becomes visible when chat opens its menu. + persisted = true + viewModel.refreshKeepConnectedInBackground() + assertEquals(true, viewModel.uiState.value.keepConnectedInBackground) + + // Both menu directions use the existing Settings action and persistence path. + viewModel.onKeepConnectedInBackgroundChange(false) + assertEquals(false, persisted) + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + viewModel.onKeepConnectedInBackgroundChange(true) + assertEquals(true, persisted) + + persisted = false + viewModel.refreshKeepConnectedInBackground() + assertEquals(false, viewModel.uiState.value.keepConnectedInBackground) + } + @Test fun testSave_persistsKeepConnectedInBackground() = runTest { From 33d87a11ec108af4a9a5f69c8dc0b18d9d1b42a5 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:25:21 +0300 Subject: [PATCH 14/18] fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236) --- .../ui/plugins/PluginCardTest.kt | 59 ++++++ .../hermescontrol/data/model/PluginInfo.kt | 3 + .../data/remote/HermesApiService.kt | 5 - .../data/ws/PluginRemovalRepository.kt | 31 +++ .../m57/hermescontrol/data/ws/WsMethods.kt | 4 + .../hermescontrol/ui/plugins/PluginsScreen.kt | 112 ++++++----- .../ui/plugins/PluginsViewModel.kt | 124 +++++++++--- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../data/ws/PluginRemovalRepositoryTest.kt | 48 +++++ .../ui/plugins/PluginsViewModelTest.kt | 179 ++++++++++++++++++ 13 files changed, 475 insertions(+), 94 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt new file mode 100644 index 00000000..f9028ba7 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.ui.plugins + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PluginCardTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun inactiveUserPluginOffersEnableAndUninstallInsteadOfInstall() { + val viewModel = PluginsViewModel() + composeRule.setContent { + HermesControlTheme { + PluginCard( + plugin = PluginInfo(name = "demo", source = "user", runtimeStatus = "inactive", canRemove = true), + state = PluginsUiState(), + viewModel = viewModel, + onClick = {}, + ) + } + } + + composeRule.onNodeWithText("Install", useUnmergedTree = true).assertDoesNotExist() + composeRule.onNodeWithText("Enable", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertExists() + } + + @Test + fun bundledPluginNeverShowsUninstall() { + val viewModel = PluginsViewModel() + composeRule.setContent { + HermesControlTheme { + PluginCard( + plugin = + PluginInfo( + name = "builtin", + source = "bundled", + runtimeStatus = "inactive", + canRemove = true, + ), + state = PluginsUiState(), + viewModel = viewModel, + onClick = {}, + ) + } + } + + composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 5ba18ac5..1e00957e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -24,6 +24,9 @@ data class PluginInfo( get() = runtimeStatus.equals("enabled", ignoreCase = true) || runtimeStatus.equals("disabled", ignoreCase = true) + + val removable: Boolean + get() = canRemove && (source == "user" || source == "git") } @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 7b0a393b..bbaf3811 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -665,11 +665,6 @@ interface HermesApiService : KanbanApiService { @Body body: AgentPluginInstallBody, ): Response - @DELETE("api/dashboard/agent-plugins/{name}") - suspend fun uninstallPlugin( - @Path("name", encoded = true) name: String, - ): Response - @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name", encoded = true) name: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt new file mode 100644 index 00000000..92766afe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt @@ -0,0 +1,31 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement + +@Serializable +data class PluginRemovalResult( + val ok: Boolean, + val name: String? = null, + val error: String? = null, +) + +/** Removes a user-installed plugin through the gateway's canonical plugin manager. */ +class PluginRemovalRepository( + private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + }, +) { + suspend fun remove(name: String): PluginRemovalResult { + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) + return Json.decodeFromJsonElement(result.toJsonElement()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 93e3c448..f4e79a7f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -110,6 +110,9 @@ object WsMethods { /** Initiate or restart authorization flow for one or more connectors. */ const val CONNECTORS_CONNECT = "connectors.connect" + // โ”€โ”€ Plugins โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const val PLUGINS_MANAGE = "plugins.manage" + // โ”€โ”€ Billing / subscription (issue #628) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` @@ -174,6 +177,7 @@ object WsMethods { CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped "verification.status", // line 282 PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped + PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc "pet.info", // line 1253 "pet.info.meta", // line 1279 "pet.cells", // line 1302 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9d2bca7b..5cbd407a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -486,7 +486,7 @@ private fun InstallSection( } @Composable -private fun PluginCard( +internal fun PluginCard( plugin: PluginInfo, state: PluginsUiState, viewModel: PluginsViewModel, @@ -542,12 +542,7 @@ private fun PluginCard( // Status badges Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val statusColor = - when { - plugin.enabled -> statusColors.success - plugin.installed -> statusColors.warning - else -> statusColors.error - } + val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning Text( text = plugin.runtimeStatus ?: "inactive", style = MaterialTheme.typography.labelSmall, @@ -591,64 +586,63 @@ private fun PluginCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - if (plugin.installed) { - // Visibility toggle (only for plugins with dashboard manifest) - if (plugin.hasDashboardManifest) { - IconButton( - onClick = { viewModel.togglePluginVisibility(plugin) }, - enabled = !busy, - modifier = Modifier.size(32.dp), - ) { - Icon( - if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = - if (plugin.userHidden) { - stringResource(R.string.plugins_show_in_sidebar) - } else { - stringResource(R.string.plugins_hide_from_sidebar) - }, - modifier = Modifier.size(18.dp), - ) - } + // Visibility toggle (only for active plugins with dashboard manifests) + if (plugin.installed && plugin.hasDashboardManifest) { + IconButton( + onClick = { viewModel.togglePluginVisibility(plugin) }, + enabled = !busy, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + if (plugin.userHidden) { + stringResource(R.string.plugins_show_in_sidebar) + } else { + stringResource(R.string.plugins_hide_from_sidebar) + }, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + // Existing update affordance for active Git installs. + if (plugin.installed && plugin.canUpdateGit) { + OutlinedButton( + onClick = { viewModel.updatePlugin(plugin.name) }, + enabled = !busy, + ) { + Text(stringResource(R.string.plugins_action_update)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + // Backend-provided removability, independent of runtime enabled state. + if (plugin.removable) { + OutlinedButton( + onClick = { viewModel.requestRemovePlugin(plugin.name) }, + enabled = !busy, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.plugins_action_uninstall)) } - - // Update button - if (plugin.canUpdateGit) { - OutlinedButton( - onClick = { viewModel.updatePlugin(plugin.name) }, - enabled = !busy, - ) { - Text(stringResource(R.string.plugins_action_update)) - } - Spacer(modifier = Modifier.width(8.dp)) - } - - // Remove button with confirmation - if (plugin.canRemove) { - OutlinedButton( - onClick = { viewModel.requestRemovePlugin(plugin.name) }, - enabled = !busy, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Icon( - Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.plugins_action_uninstall)) - } - } - } else { + } + if (!plugin.installed) { Button( onClick = { viewModel.activatePlugin(plugin) }, enabled = !busy, ) { - Text(stringResource(R.string.plugins_action_install)) + Text(stringResource(R.string.plugins_action_enable)) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index bcbc4e6b..10d50bde 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.PluginRemovalRepository import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -72,15 +74,18 @@ data class PluginsUiState( } } -class PluginsViewModel : - ViewModel(), +class PluginsViewModel( + private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val pluginsCache = SwrCache() + private var scopeGeneration = 0 fun clearScopeOwnedState() { + scopeGeneration++ _uiState.update { it.copy( isLoading = false, @@ -333,26 +338,24 @@ class PluginsViewModel : } fun activatePlugin(plugin: PluginInfo) { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } - loadPlugins(forceRefresh = true) - } - - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") } - } - } - } + if (_uiState.value.rowBusy != null) return + safeLaunchAction( + onStart = { setRowBusy(plugin.name) }, + apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } }, + onSuccess = { + _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } + loadPlugins(forceRefresh = true) + }, + onError = { error -> + _uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") } + }, + onComplete = { clearRowBusy(plugin.name) }, + ) } /** Show confirmation dialog for removing a plugin */ fun requestRemovePlugin(name: String) { + if (_uiState.value.plugins.none { it.name == name && it.removable }) return _uiState.update { it.copy(removeConfirmPlugin = name) } } @@ -363,24 +366,85 @@ class PluginsViewModel : fun confirmRemovePlugin() { val name = _uiState.value.removeConfirmPlugin ?: return + if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) { + cancelRemovePlugin() + return + } _uiState.update { it.copy(removeConfirmPlugin = null) } + val generation = scopeGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + setRowBusy(name) viewModelScope.launch { - setRowBusy(name) - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) } + try { + val result = pluginRemoval.remove(name) + if (generation != scopeGeneration || + (requestScope != null && AuthManager.currentDataScope() != requestScope) + ) { + return@launch } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } - clearRowBusy(name) - loadPlugins(forceRefresh = true) + if (!result.ok) { + _uiState.update { + it.copy( + toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}", + ) + } + return@launch } + val refreshed = safeApiCall { ApiClient.hermesApi.getPlugins() } + if (generation != scopeGeneration || + (requestScope != null && AuthManager.currentDataScope() != requestScope) + ) { + return@launch + } + when (refreshed) { + is NetworkResult.Success -> { + val data = refreshed.data + requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) } + _uiState.update { state -> + state.copy( + plugins = data.plugins, + orphanPlugins = + data.orphanDashboardPlugins.map { orphan -> + PluginInfo( + name = orphan.name ?: "unknown", + description = + orphan.description ?: orphan.label, + ) + }, + memoryProvider = data.providers?.memoryProvider ?: "", + memoryOptions = data.providers?.memoryOptions.orEmpty(), + contextEngine = data.providers?.contextEngine ?: "compressor", + contextOptions = data.providers?.contextOptions.orEmpty(), + toastMessage = + if (data.plugins.none { it.name == name }) { + "Plugin uninstalled successfully" + } else { + "Removal returned success, but $name still appears in the plugin list" + }, + ) + } + } - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } - clearRowBusy(name) + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Removal sent; could not verify plugin list: ${refreshed.error.message}", + ) + } + } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == scopeGeneration) { + _uiState.update { + it.copy( + toastMessage = "Failed to uninstall plugin: ${e.message ?: "Gateway unavailable"}", + ) + } + } + } finally { + if (generation == scopeGeneration) clearRowBusy(name) } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2051265b..5b7a442f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -942,6 +942,7 @@ ู„ุง ูŠูˆุฌุฏ ูˆุตู ู…ู‚ุฏู…. ุชุญุฏูŠุซ ุฅู„ุบุงุก ุงู„ุชุซุจูŠุช +ุชูุนูŠู„ ุชุซุจูŠุช ุฅุฒุงู„ุฉ ุชุซุจูŠุช ุฅุถุงูุฉ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cff47d0a..20b1d7fc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -794,6 +794,7 @@ ์„ค๋ช…์ด ์—†์Šต๋‹ˆ๋‹ค. ์—…๋ฐ์ดํŠธ ์ œ๊ฑฐ + ํ™œ์„ฑํ™” ์„ค์น˜ ์ œ๊ฑฐ ํ”Œ๋Ÿฌ๊ทธ์ธ ์„ค์น˜ diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c4dcb11c..f72c2a80 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -909,6 +909,7 @@ ๆ— ๆ่ฟฐใ€‚ ๆ›ดๆ–ฐ ๅธ่ฝฝ + ๅฏ็”จ ๅฎ‰่ฃ… ็งป้™ค ๅฎ‰่ฃ…ๆ’ไปถ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b8a1b77..c3dffc60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1021,6 +1021,7 @@ No description provided. Update Uninstall + Enable Install Remove Install Plugin diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt new file mode 100644 index 00000000..81a6d21f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt @@ -0,0 +1,48 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginRemovalRepositoryTest { + @Test + fun `remove sends scoped gateway action and decodes success`() = + runTest { + var method = "" + var params: Map = emptyMap() + val repository = + PluginRemovalRepository { requestedMethod, requestedParams -> + method = requestedMethod + params = requestedParams + Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") + } + + val result = repository.remove("demo") + + assertEquals(WsMethods.PLUGINS_MANAGE, method) + assertTrue(WsMethods.PLUGINS_MANAGE in WsMethods.PROFILE_SCOPED_METHODS) + assertEquals(mapOf("action" to "remove", "name" to "demo"), params) + assertTrue(result.ok) + assertEquals("demo", result.name) + } + + @Test + fun `refusal is a typed failure and missing ok cannot masquerade as success`() = + runTest { + val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } + assertFalse(refused.remove("builtin").ok) + assertEquals("bundled plugin", refused.remove("builtin").error) + + val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } + try { + malformed.remove("demo") + throw AssertionError("Missing ok must fail decoding") + } catch (_: SerializationException) { + // A partial response is not removal confirmation. + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt index 010da388..7c8c759f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -8,12 +8,15 @@ import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.PluginRemovalRepository +import com.m57.hermescontrol.data.ws.PluginRemovalResult import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -34,6 +37,8 @@ import retrofit2.Response class PluginsViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService + private val removablePlugin = + PluginInfo(name = "snyk", runtimeStatus = "inactive", canRemove = true, source = "user") private val sampleCatalogEntry = PluginCatalogEntry( @@ -191,4 +196,178 @@ class PluginsViewModelTest { ?.contains("Failed to install") == true, ) } + + @Test + fun `inactive hub plugin enables without invoking install`() { + coEvery { mockApi.enablePlugin("snyk") } returns Response.success(Unit) + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin.copy(runtimeStatus = "enabled")))) + val viewModel = PluginsViewModel() + + viewModel.activatePlugin(removablePlugin) + assertEquals("snyk", viewModel.uiState.value.rowBusy) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .single() + .enabled, + ) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 1) { mockApi.enablePlugin("snyk") } + coVerify(exactly = 0) { mockApi.installPlugin(any()) } + } + + @Test + fun `inactive plugin can be removed after confirmation and verified refresh`() { + coEvery { mockApi.getPlugins() } returnsMany + listOf( + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))), + Response.success(PluginsHubResponse(plugins = emptyList())), + ) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + assertEquals("snyk", viewModel.uiState.value.removeConfirmPlugin) + viewModel.confirmRemovePlugin() + assertEquals("snyk", viewModel.uiState.value.rowBusy) + assertEquals(1, viewModel.uiState.value.plugins.size) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .isEmpty(), + ) + assertEquals("Plugin uninstalled successfully", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 1) { removal.remove("snyk") } + coVerify(exactly = 2) { mockApi.getPlugins() } + } + + @Test + fun `built in plugin cannot enter removal flow even if asked directly`() { + coEvery { mockApi.getPlugins() } returns + Response.success( + PluginsHubResponse( + plugins = listOf(PluginInfo(name = "builtin", source = "bundled", canRemove = true)), + ), + ) + val removal = mockk() + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("builtin") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.removeConfirmPlugin) + coVerify(exactly = 0) { removal.remove(any()) } + } + + @Test + fun `gateway refusal and disconnection leave plugin in list with reason`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = false, error = "not a user install") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("not a user install"), + ) + + coEvery { removal.remove("snyk") } throws IllegalStateException("socket disconnected") + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("socket disconnected"), + ) + coVerify(exactly = 1) { mockApi.getPlugins() } + } + + @Test + fun `success reply without verified removal retains row and reports mismatch`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("still appears"), + ) + } + + @Test + fun `failed verification preserves row without success toast`() { + coEvery { mockApi.getPlugins() } returnsMany + listOf( + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))), + Response.error(503, "offline".toResponseBody()), + ) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("could not verify"), + ) + } + + @Test + fun `scope switch discards late remove reply`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val reply = CompletableDeferred() + val removal = mockk() + coEvery { removal.remove("snyk") } coAnswers { reply.await() } + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.runCurrent() + viewModel.clearScopeOwnedState() + reply.complete(PluginRemovalResult(ok = true, name = "snyk")) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .isEmpty(), + ) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.getPlugins() } + } } From 1270a08810cb3a9f69750fda5f3cce1859a07899 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:25:37 +0300 Subject: [PATCH 15/18] feat(#1220): complete Kanban management operations parity (#1234) --- .../ui/kanban/KanbanScreenTest.kt | 5 + .../data/model/KanbanActivityModels.kt | 74 +++++ .../data/model/KanbanTaskModels.kt | 32 ++ .../data/remote/KanbanApiService.kt | 64 ++++ .../data/repository/KanbanRepository.kt | 115 ++++++- .../hermescontrol/ui/kanban/KanbanScreen.kt | 79 ++++- .../ui/kanban/KanbanTaskScreen.kt | 311 ++++++++++++++++-- .../ui/kanban/KanbanTaskViewModel.kt | 272 +++++++++++++-- .../ui/kanban/KanbanTransitionRefusal.kt | 30 ++ .../ui/kanban/KanbanViewModel.kt | 92 +++++- .../ui/kanban/components/KanbanFilterSheet.kt | 43 +++ app/src/main/res/values-ar/strings.xml | 27 ++ app/src/main/res/values-ko/strings.xml | 27 ++ app/src/main/res/values-zh/strings.xml | 27 ++ app/src/main/res/values/strings.xml | 27 ++ .../m57/hermescontrol/E2eIntegrationTest.kt | 11 + .../data/model/KanbanSerializationTest.kt | 53 +++ .../data/remote/KanbanApiServiceTest.kt | 154 +++++++++ .../data/repository/KanbanRepositoryTest.kt | 28 ++ .../ui/kanban/KanbanMutationTest.kt | 10 + .../ui/kanban/KanbanTaskViewModelTest.kt | 174 +++++++++- .../ui/kanban/KanbanTransitionRefusalTest.kt | 34 ++ .../ui/kanban/KanbanViewModelTest.kt | 139 ++++++++ 23 files changed, 1777 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt index 52c65fde..4de2b210 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt @@ -34,12 +34,17 @@ class KanbanScreenTest { KanbanFilterSheet( assignees = emptyList(), tenants = emptyList(), + workflowTemplateIds = emptyList(), + currentStepKeys = emptyList(), selectedAssignee = null, selectedTenant = null, + selectedWorkflowTemplateId = null, + selectedCurrentStepKey = null, includeArchived = false, groupRunning = false, onSelectAssignee = {}, onSelectTenant = {}, + onSelectWorkflowFilters = { _, _ -> }, onToggleIncludeArchived = { toggled = it }, onToggleGroupRunning = {}, onClearFilters = {}, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt index 0b6a8974..e27170ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt @@ -22,8 +22,14 @@ data class KanbanDetailEvent( @Serializable data class KanbanRun( val id: Long, + val taskId: String? = null, val profile: String? = null, + val stepKey: String? = null, val status: String, + val claimLock: String? = null, + val claimExpires: Long? = null, + val maxRuntimeSeconds: Int? = null, + val lastHeartbeatAt: Long? = null, val outcome: String? = null, val summary: String? = null, val error: String? = null, @@ -59,3 +65,71 @@ data class KanbanAttachment( data class AttachmentUploadResponse( val attachment: KanbanAttachment? = null, ) + +@Serializable +data class AttachmentListResponse( + val attachments: List = emptyList(), +) + +@Serializable +data class DeleteAttachmentResponse( + val ok: Boolean, + val id: Long, +) + +@Serializable +data class KanbanRunResponse( + val run: KanbanRun, +) + +@Serializable +data class KanbanRunInspection( + val runId: Long, + val alive: Boolean, + val pid: Int? = null, + val reason: String? = null, + val cpuPercent: Double? = null, + val memoryRssBytes: Long? = null, + val memoryVmsBytes: Long? = null, + val numThreads: Int? = null, + val numFds: Int? = null, + val status: String? = null, + val createTime: Double? = null, + val cmdline: List? = null, + val error: String? = null, +) + +@Serializable +data class TerminateRunBody( + val reason: String? = null, +) + +@Serializable +data class TerminateRunResponse( + val ok: Boolean, + val runId: Long, + val taskId: String, +) + +@Serializable +data class KanbanActiveWorker( + val runId: Long, + val taskId: String, + val taskTitle: String, + val taskStatus: String, + val taskAssignee: String? = null, + val profile: String? = null, + val workerPid: Int, + val startedAt: Long? = null, + val claimLock: String? = null, + val claimExpires: Long? = null, + val lastHeartbeatAt: Long? = null, + val maxRuntimeSeconds: Int? = null, +) + +@Serializable +data class ActiveWorkersResponse( + val workers: List = emptyList(), + val count: Int = workers.size, + val checkedAt: Long, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt index 6ec97abb..17fd8c8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -48,6 +48,18 @@ data class TaskLinks( val children: List = emptyList(), ) +@Serializable +data class TaskLinkBody( + val parentId: String, + val childId: String, +) + +@Serializable +data class TaskLinkResponse( + val ok: Boolean, + val gated: Boolean = false, +) + @Serializable data class ChildTaskResult( val id: String, @@ -75,6 +87,9 @@ data class KanbanTask( val startedAt: Long? = null, val workerPid: Int? = null, val lastHeartbeatAt: Long? = null, + val currentRunId: Long? = null, + val workflowTemplateId: String? = null, + val currentStepKey: String? = null, ) { val description: String? get() = body val assignedTo: String? get() = assignee @@ -112,6 +127,10 @@ data class KanbanTaskFull( val startedAt: Long? = null, val workerPid: Int? = null, val lastHeartbeatAt: Long? = null, + val currentRunId: Long? = null, + val workflowTemplateId: String? = null, + val currentStepKey: String? = null, + val maxRuntimeSeconds: Int? = null, val result: String? = null, val createdBy: String? = null, val modelOverride: String? = null, @@ -245,6 +264,19 @@ data class ReclaimTaskResponse( val taskId: String, ) +@Serializable +data class SpecifyTaskBody( + val author: String? = null, +) + +@Serializable +data class SpecifyTaskResponse( + val ok: Boolean, + val taskId: String, + val reason: String? = null, + val newTitle: String? = null, +) + @Serializable data class TaskEstimate( val ok: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt index bd4a7648..ceaa6935 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.remote +import com.m57.hermescontrol.data.model.ActiveWorkersResponse +import com.m57.hermescontrol.data.model.AttachmentListResponse import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.BoardExportResult @@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateBoardResponse import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.ExportBoardBody @@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate @@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskResponse import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.SpecifyTaskResponse import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunBody +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog @@ -57,6 +68,8 @@ interface KanbanApiService { @Query("board") board: String? = null, @Query("include_archived") includeArchived: Boolean = false, @Query("tenant") tenant: String? = null, + @Query("workflow_template_id") workflowTemplateId: String? = null, + @Query("current_step_key") currentStepKey: String? = null, ): Response @POST("api/plugins/kanban/boards") @@ -96,6 +109,8 @@ interface KanbanApiService { suspend fun getTask( @Path("id") taskId: String, @Query("board") board: String? = null, + @Query("run_state_type") runStateType: String? = null, + @Query("run_state_name") runStateName: String? = null, ): Response @POST("api/plugins/kanban/tasks") @@ -170,6 +185,12 @@ interface KanbanApiService { @Part file: MultipartBody.Part, ): Response + @GET("api/plugins/kanban/tasks/{id}/attachments") + suspend fun listAttachments( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + @Streaming @GET("api/plugins/kanban/attachments/{id}") suspend fun downloadAttachment( @@ -177,6 +198,49 @@ interface KanbanApiService { @Query("board") board: String? = null, ): Response + @DELETE("api/plugins/kanban/attachments/{id}") + suspend fun deleteAttachment( + @Path("id") attachmentId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/workers/active") + suspend fun getActiveWorkers( + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/runs/{id}") + suspend fun getRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/runs/{id}/inspect") + suspend fun inspectRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/runs/{id}/terminate") + suspend fun terminateRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + @Body body: TerminateRunBody = TerminateRunBody(), + ): Response + + @POST("api/plugins/kanban/tasks/{id}/specify") + suspend fun specifyTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: SpecifyTaskBody = SpecifyTaskBody(), + ): Response + + @POST("api/plugins/kanban/links") + suspend fun createTaskLink( + @Query("board") board: String? = null, + @Body body: TaskLinkBody, + ): Response + @GET("api/plugins/kanban/profiles") suspend fun getKanbanProfiles(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt index 422a7e27..73af1470 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.repository +import com.m57.hermescontrol.data.model.ActiveWorkersResponse +import com.m57.hermescontrol.data.model.AttachmentListResponse import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.BoardExportResult @@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateBoardResponse import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.ExportBoardBody @@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate @@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskResponse import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.SpecifyTaskResponse import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunBody +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog @@ -48,6 +59,8 @@ interface KanbanRepository { board: String, includeArchived: Boolean = false, tenant: String? = null, + workflowTemplateId: String? = null, + currentStepKey: String? = null, ): NetworkResult suspend fun createBoard(body: CreateBoardBody): NetworkResult @@ -72,6 +85,8 @@ interface KanbanRepository { suspend fun getTask( taskId: String, board: String?, + runStateType: String? = null, + runStateName: String? = null, ): NetworkResult suspend fun createTask( @@ -136,11 +151,49 @@ interface KanbanRepository { file: MultipartBody.Part, ): NetworkResult + suspend fun listAttachments( + taskId: String, + board: String, + ): NetworkResult + + suspend fun deleteAttachment( + attachmentId: Long, + board: String, + ): NetworkResult + suspend fun downloadAttachment( attachmentId: Long, board: String?, ): NetworkResult + suspend fun getActiveWorkers(board: String): NetworkResult + + suspend fun getRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun inspectRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun terminateRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun specifyTask( + taskId: String, + board: String, + ): NetworkResult + + suspend fun createTaskLink( + board: String, + parentId: String, + childId: String, + ): NetworkResult + suspend fun getProfiles(): NetworkResult suspend fun updateProfileDescription( @@ -173,9 +226,11 @@ class KanbanRepositoryImpl( board: String, includeArchived: Boolean, tenant: String?, + workflowTemplateId: String?, + currentStepKey: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) } + safeApiCall { apiProvider().getBoard(board, includeArchived, tenant, workflowTemplateId, currentStepKey) } } override suspend fun createBoard(body: CreateBoardBody): NetworkResult = @@ -215,9 +270,11 @@ class KanbanRepositoryImpl( override suspend fun getTask( taskId: String, board: String?, + runStateType: String?, + runStateName: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } + safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) } } override suspend fun createTask( @@ -364,6 +421,18 @@ class KanbanRepositoryImpl( safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } } + override suspend fun listAttachments( + taskId: String, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } } + + override suspend fun deleteAttachment( + attachmentId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } } + override suspend fun downloadAttachment( attachmentId: Long, board: String?, @@ -372,6 +441,48 @@ class KanbanRepositoryImpl( safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } } + override suspend fun getActiveWorkers(board: String): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } } + + override suspend fun getRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } } + + override suspend fun inspectRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } } + + override suspend fun terminateRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { + val result = safeApiCall { apiProvider().terminateRun(runId, board, TerminateRunBody(reason = "mobile")) } + if (result is NetworkResult.Success && result.data.ok) nudger.scheduleNudge(board) + result + } + + override suspend fun specifyTask( + taskId: String, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) } + } + + override suspend fun createTaskLink( + board: String, + parentId: String, + childId: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) } + } + override suspend fun getProfiles(): NetworkResult = withContext(ioDispatcher) { safeApiCall { apiProvider().getKanbanProfiles() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 68565cc7..80942f25 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -119,10 +120,22 @@ fun KanbanScreen( var selectedAssignee by remember { mutableStateOf(null) } var selectedTenant by remember { mutableStateOf(null) } var showFilterSheet by remember { mutableStateOf(false) } + var showWorkersDialog by remember { mutableStateOf(false) } val filteredTasks = - remember(query, state.tasks, selectedAssignee, selectedTenant) { - state.tasks.filter { task -> + remember( + query, + state.tasks, + selectedAssignee, + selectedTenant, + state.selectedWorkflowTemplateId, + state.selectedCurrentStepKey, + ) { + filterKanbanTasks( + state.tasks, + state.selectedWorkflowTemplateId, + state.selectedCurrentStepKey, + ).filter { task -> val matchesQuery = query.isBlank() || task.title.contains(query, ignoreCase = true) || @@ -331,6 +344,14 @@ fun KanbanScreen( }, ) if (state.selectedBoard != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_active_workers)) }, + onClick = { + showBoardMenu = false + showWorkersDialog = true + viewModel.loadActiveWorkers() + }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.kanban_board_settings)) }, onClick = { @@ -917,10 +938,23 @@ fun KanbanScreen( tenants = tenants, selectedAssignee = selectedAssignee, selectedTenant = selectedTenant, + workflowTemplateIds = + state.tasks + .mapNotNull { it.workflowTemplateId } + .distinct() + .sorted(), + currentStepKeys = + state.tasks + .mapNotNull { it.currentStepKey } + .distinct() + .sorted(), + selectedWorkflowTemplateId = state.selectedWorkflowTemplateId, + selectedCurrentStepKey = state.selectedCurrentStepKey, includeArchived = state.includeArchived, groupRunning = state.groupRunning, onSelectAssignee = { selectedAssignee = it }, onSelectTenant = { selectedTenant = it }, + onSelectWorkflowFilters = viewModel::setWorkflowFilters, onToggleIncludeArchived = viewModel::setIncludeArchived, onToggleGroupRunning = viewModel::setGroupRunning, onClearFilters = { @@ -928,6 +962,7 @@ fun KanbanScreen( selectedTenant = null viewModel.setIncludeArchived(false) viewModel.setGroupRunning(false) + viewModel.setWorkflowFilters(null, null) }, onDismiss = { showFilterSheet = false }, ) @@ -1291,6 +1326,46 @@ fun KanbanScreen( ) } + if (showWorkersDialog) { + AlertDialog( + onDismissRequest = { showWorkersDialog = false }, + title = { Text(stringResource(R.string.kanban_active_workers)) }, + text = { + if (state.isLoadingWorkers) { + CircularProgressIndicator() + } else if (state.activeWorkers.isEmpty()) { + Text(stringResource(R.string.kanban_no_active_workers)) + } else { + LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) { + items(state.activeWorkers, key = { it.runId }) { worker -> + Text( + "${worker.taskTitle} ยท ${worker.profile ?: worker.taskAssignee.orEmpty()}", + modifier = + Modifier + .fillMaxWidth() + .clickable { + val board = state.selectedBoard ?: return@clickable + showWorkersDialog = false + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = worker.taskId, + ), + ) + }.padding(vertical = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showWorkersDialog = false }) { + Text(stringResource(R.string.action_close)) + } + }, + ) + } + if (showDeleteBoardDialog && state.selectedBoard != null) { AlertDialog( onDismissRequest = { showDeleteBoardDialog = false }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 98404bea..51d9ce8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.RestartAlt @@ -62,9 +63,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -75,12 +78,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.KanbanTaskDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanAttachment import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.TaskLinks +import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.common.ErrorState @@ -89,6 +94,13 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor +import com.m57.hermescontrol.util.StreamingUriRequestBody +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -110,6 +122,7 @@ fun KanbanTaskScreen( stringResource(R.string.kanban_tab_files), ) var showDeleteDialog by remember { mutableStateOf(false) } + var runToTerminate by remember { mutableStateOf(null) } LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -208,6 +221,10 @@ fun KanbanTaskScreen( onReclaim = { viewModel.reclaim(boardSlug, taskId) }, + isSpecifying = state.isSpecifying, + isLinking = state.isLinking, + onSpecify = { viewModel.specifyTask(boardSlug, taskId) }, + onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) }, ) } @@ -229,6 +246,10 @@ fun KanbanTaskScreen( TaskRunsTab( runs = detail.runs, workerLog = state.workerLog, + runStateType = state.runStateType, + runStateName = state.runStateName, + onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) }, + onInspect = { viewModel.inspectRun(boardSlug, taskId, it) }, ) } @@ -242,9 +263,10 @@ fun KanbanTaskScreen( TaskFilesTab( attachments = detail.attachments ?: emptyList(), isUploading = state.isUploadingAttachment, - onUpload = { filename, mimeType, bytes -> - viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes) - }, + isDeleting = state.isDeletingAttachment, + board = boardSlug, + taskId = taskId, + viewModel = viewModel, ) } } @@ -276,6 +298,68 @@ fun KanbanTaskScreen( }, ) } + + val selectedRun = state.selectedRun + if (selectedRun != null) { + AlertDialog( + onDismissRequest = viewModel::clearRunInspection, + title = { Text(stringResource(R.string.kanban_run_details)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status) + selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) } + selectedRun.stepKey?.let { MetaRow("Step", it) } + selectedRun.workerPid?.let { MetaRow("PID", it.toString()) } + selectedRun.summary?.let { Text(it) } + selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + state.runInspection?.let { inspection -> + Text( + if (inspection.alive) { + inspection.status ?: selectedRun.status + } else { + inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable) + }, + ) + inspection.cpuPercent?.let { MetaRow("CPU", "$it%") } + inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") } + inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + } + }, + confirmButton = { + if (selectedRun.endedAt == null && selectedRun.status == "running" && + state.detail?.task?.status == "running" + ) { + TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) { + Text(stringResource(R.string.kanban_terminate_run)) + } + } + }, + dismissButton = { + TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } + + if (runToTerminate != null) { + AlertDialog( + onDismissRequest = { runToTerminate = null }, + title = { Text(stringResource(R.string.kanban_terminate_run)) }, + text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) }, + confirmButton = { + Button( + onClick = { + runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) } + runToTerminate = null + }, + enabled = !state.isTerminatingRun, + ) { Text(stringResource(R.string.kanban_terminate_run)) } + }, + dismissButton = { + TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } } @Composable @@ -293,11 +377,16 @@ private fun TaskOverviewTab( onNavigateToTask: (String) -> Unit, onSaveDescription: (String) -> Unit, onReclaim: () -> Unit, + isSpecifying: Boolean, + isLinking: Boolean, + onSpecify: () -> Unit, + onAddParent: (String) -> Unit, modifier: Modifier = Modifier, ) { var isEditingDescription by remember { mutableStateOf(false) } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } var assigneeMenuExpanded by remember { mutableStateOf(false) } + var parentDraft by remember { mutableStateOf("") } val statusColors = LocalHermesStatusColors.current val currentModelOverride = @@ -430,6 +519,8 @@ private fun TaskOverviewTab( task.tenant?.let { MetaRow(label = "Tenant", value = it) } task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") } task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } + task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) } + task.currentStepKey?.let { MetaRow(label = "Step", value = it) } Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() @@ -446,7 +537,7 @@ private fun TaskOverviewTab( } // Dependencies Section - if (links.parents.isNotEmpty() || links.children.isNotEmpty()) { + run { Card( shape = RoundedCornerShape(8.dp), modifier = @@ -461,6 +552,22 @@ private fun TaskOverviewTab( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = parentDraft, + onValueChange = { parentDraft = it }, + label = { Text(stringResource(R.string.kanban_parent_id)) }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + TextButton( + onClick = { + onAddParent(parentDraft) + parentDraft = "" + }, + enabled = parentDraft.isNotBlank() && !isLinking, + ) { Text(stringResource(R.string.kanban_add_parent)) } + } if (links.parents.isNotEmpty()) { Spacer(modifier = Modifier.height(6.dp)) Text( @@ -506,6 +613,13 @@ private fun TaskOverviewTab( } // Effort Estimation Section + if (task.status == "triage") { + OutlinedButton( + onClick = onSpecify, + enabled = !isSpecifying, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { Text(stringResource(R.string.kanban_specify_task)) } + } Card( shape = RoundedCornerShape(8.dp), modifier = @@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String = private fun TaskRunsTab( runs: List, workerLog: com.m57.hermescontrol.data.model.WorkerLog?, + runStateType: String?, + runStateName: String?, + onFilter: (String?, String?) -> Unit, + onInspect: (Long) -> Unit, modifier: Modifier = Modifier, ) { + var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") } + var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") } + var filterMenuExpanded by remember { mutableStateOf(false) } LazyColumn( modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -790,6 +911,34 @@ private fun TaskRunsTab( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + TextButton(onClick = { filterMenuExpanded = true }) { + Text(if (filterType == "status") "Status" else "Outcome") + } + DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { + listOf("status", "outcome").forEach { type -> + DropdownMenuItem(text = { Text(type) }, onClick = { + filterType = type + filterMenuExpanded = false + }) + } + } + } + OutlinedTextField( + value = filterName, + onValueChange = { filterName = it }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.kanban_run_state_filter)) }, + singleLine = true, + ) + TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) { + Text(stringResource(R.string.kanban_filter)) + } + } + if (runStateName != null) { + TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) } + } } if (runs.isEmpty()) { @@ -858,6 +1007,9 @@ private fun TaskRunsTab( style = MaterialTheme.typography.bodySmall, ) } + OutlinedButton(onClick = { onInspect(run.id) }) { + Text(stringResource(R.string.kanban_inspect_run)) + } } } } @@ -1058,31 +1210,107 @@ private fun MetaRow( private fun TaskFilesTab( attachments: List, isUploading: Boolean, - onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit, + isDeleting: Boolean, + board: String, + taskId: String, + viewModel: KanbanTaskViewModel, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - if (uri != null) { - var filename = "attachment" - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1 && cursor.moveToFirst()) { - filename = cursor.getString(nameIndex) ?: "attachment" + val configuration = LocalConfiguration.current + val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources } + val scope = rememberCoroutineScope() + var attachmentToDelete by remember { mutableStateOf(null) } + var attachmentToDownload by remember { mutableStateOf(null) } + val downloadLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + val selected = attachmentToDownload + attachmentToDownload = null + if (uri != null && selected != null) { + scope.launch { + when (val result = viewModel.downloadAttachment(board, selected.id)) { + is NetworkResult.Success -> { + try { + withContext(Dispatchers.IO) { + result.data.use { body -> + context.contentResolver.openOutputStream(uri)?.use { output -> + body.byteStream().use { input -> input.copyTo(output) } + } ?: error("Could not open the selected destination") + } + } + viewModel.showToast(resources.getString(R.string.kanban_attachment_saved)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + viewModel.showToast( + resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"), + ) + } + } + + is NetworkResult.Failure -> { + viewModel.showToast(result.error.message) + } } } - val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - val bytes = - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: byteArrayOf() - if (bytes.isNotEmpty()) { - onUpload(filename, mimeType, bytes) - } } } + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri != null) { + scope.launch { + try { + val part = + withContext(Dispatchers.IO) { + var filename = "attachment" + var contentLength = -1L + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { + filename = c.getString(it) ?: filename + } + c + .getColumnIndex( + OpenableColumns.SIZE, + ).takeIf { it >= 0 && !c.isNull(it) } + ?.let { + contentLength = c.getLong(it) + } + } + } + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + val body = + StreamingUriRequestBody( + context.contentResolver, + uri, + mime.toMediaTypeOrNull(), + contentLength, + ) + MultipartBody.Part.createFormData("file", filename, body) + } + viewModel.uploadAttachment(board, taskId, part) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + viewModel.showToast( + resources.getString( + R.string.kanban_attachment_error, + e.message ?: "Unknown error", + ), + ) + } + } + } + } + + LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) } Column(modifier = modifier.fillMaxSize().padding(16.dp)) { Row( @@ -1096,7 +1324,7 @@ private fun TaskFilesTab( fontWeight = FontWeight.Bold, ) Button( - onClick = { launcher.launch("*/*") }, + onClick = { launcher.launch(arrayOf("*/*")) }, enabled = !isUploading, ) { if (isUploading) { @@ -1136,22 +1364,55 @@ private fun TaskFilesTab( Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = att.filename.ifBlank { "File #${att.id}" }, + att.filename.ifBlank { + "File #${att.id}" + }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) att.size?.let { Text( - text = "${it / 1024} KB", + "${it / 1024} KB", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + IconButton(onClick = { + attachmentToDownload = att + downloadLauncher.launch(att.filename) + }) { + Icon( + Icons.Filled.Download, + contentDescription = stringResource(R.string.kanban_download_attachment), + ) + } + IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.kanban_delete_attachment), + ) + } } } } } } } + attachmentToDelete?.let { attachment -> + AlertDialog( + onDismissRequest = { attachmentToDelete = null }, + title = { Text(stringResource(R.string.kanban_delete_attachment)) }, + text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) }, + confirmButton = { + TextButton(onClick = { + attachmentToDelete = null + viewModel.deleteAttachment(board, taskId, attachment.id) + }) { Text(stringResource(R.string.kanban_delete_attachment)) } + }, + dismissButton = { + TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt index b9b6249e..03ce28cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanRun +import com.m57.hermescontrol.data.model.KanbanRunInspection import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody data class KanbanTaskUiState( val isLoading: Boolean = false, @@ -47,6 +48,15 @@ data class KanbanTaskUiState( val isReassigning: Boolean = false, val isUpdatingModel: Boolean = false, val isUploadingAttachment: Boolean = false, + val isDeletingAttachment: Boolean = false, + val isSpecifying: Boolean = false, + val isLinking: Boolean = false, + val isInspectingRun: Boolean = false, + val isTerminatingRun: Boolean = false, + val selectedRun: KanbanRun? = null, + val runInspection: KanbanRunInspection? = null, + val runStateType: String? = null, + val runStateName: String? = null, ) class KanbanTaskViewModel( @@ -61,6 +71,7 @@ class KanbanTaskViewModel( private var eventsClient: KanbanEventsClient? = null private var eventsBoard: String? = null private var activeTaskId: String? = null + private var activeBoard: String? = null private var currentLoadGen: Int = 0 private var reloadJob: Job? = null private var logPollJob: Job? = null @@ -74,7 +85,18 @@ class KanbanTaskViewModel( reloadJob?.cancel() stopLogPolling() detailPollJob?.cancel() + if (activeBoard != board || activeTaskId != taskId) { + _uiState.update { + it.copy( + runStateType = null, + runStateName = null, + selectedRun = null, + runInspection = null, + ) + } + } activeTaskId = taskId + activeBoard = board _uiState.update { it.copy( isLoading = true, @@ -90,7 +112,15 @@ class KanbanTaskViewModel( startDetailPolling(board, taskId) viewModelScope.launch { - when (val result = repository.getTask(taskId = taskId, board = board)) { + when ( + val result = + repository.getTask( + taskId, + board, + _uiState.value.runStateType, + _uiState.value.runStateName, + ) + ) { is NetworkResult.Success -> { if (gen != currentLoadGen) return@launch val taskData = result.data @@ -254,11 +284,13 @@ class KanbanTaskViewModel( taskId: String, ) { val gen = currentLoadGen - val result = repository.getTask(taskId = taskId, board = board) - if (gen != currentLoadGen || taskId != activeTaskId) return + val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName) + if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return if (result is NetworkResult.Success) { val taskData = result.data - _uiState.update { it.copy(detail = taskData) } + _uiState.update { state -> + state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments)) + } if (taskData.task.status.equals("running", ignoreCase = true)) { if (logPollJob == null) startLogPolling(board, taskId) } else { @@ -521,18 +553,6 @@ class KanbanTaskViewModel( } } - fun uploadAttachment( - board: String, - taskId: String, - filename: String, - mimeType: String, - bytes: ByteArray, - ) { - val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData("file", filename, requestBody) - uploadAttachment(board, taskId, part) - } - fun uploadAttachment( board: String, taskId: String, @@ -548,7 +568,10 @@ class KanbanTaskViewModel( toastMessage = "Attachment uploaded", ) } - loadTask(board, taskId) + if (activeTaskId == taskId && activeBoard == board) { + loadTaskSilently(board, taskId) + refreshAttachments(board, taskId) + } } is NetworkResult.Failure -> { @@ -563,6 +586,217 @@ class KanbanTaskViewModel( } } + fun refreshAttachments( + board: String, + taskId: String, + ) { + viewModelScope.launch { + when (val result = repository.listAttachments(taskId, board)) { + is NetworkResult.Success -> { + _uiState.update { state -> + if (activeTaskId != taskId || activeBoard != board) { + state + } else { + state.copy(detail = state.detail?.copy(attachments = result.data.attachments)) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = result.error.message) } + } + } + } + } + + fun deleteAttachment( + board: String, + taskId: String, + attachmentId: Long, + ) { + if (_uiState.value.isDeletingAttachment) return + _uiState.update { it.copy(isDeletingAttachment = true) } + viewModelScope.launch { + when (val result = repository.deleteAttachment(attachmentId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isDeletingAttachment = false, + toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted", + ) + } + if (result.data.ok) refreshAttachments(board, taskId) + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isDeletingAttachment = false, toastMessage = result.error.message) + } + } + } + } + } + + suspend fun downloadAttachment( + board: String, + attachmentId: Long, + ): NetworkResult = repository.downloadAttachment(attachmentId, board) + + fun showToast(message: String) { + _uiState.update { it.copy(toastMessage = message) } + } + + fun inspectRun( + board: String, + taskId: String, + runId: Long, + ) { + if (_uiState.value.isInspectingRun) return + _uiState.update { it.copy(isInspectingRun = true, selectedRun = null, runInspection = null) } + viewModelScope.launch { + val run = repository.getRun(runId, board) + val inspection = if (run is NetworkResult.Success) repository.inspectRun(runId, board) else null + if (activeTaskId != null && (activeTaskId != taskId || activeBoard != board)) return@launch + _uiState.update { + it.copy( + isInspectingRun = false, + selectedRun = (run as? NetworkResult.Success)?.data?.run, + runInspection = (inspection as? NetworkResult.Success)?.data, + toastMessage = + (run as? NetworkResult.Failure)?.error?.message + ?: (inspection as? NetworkResult.Failure)?.error?.message, + ) + } + } + } + + fun clearRunInspection() { + _uiState.update { it.copy(selectedRun = null, runInspection = null) } + } + + fun terminateRun( + board: String, + taskId: String, + runId: Long, + ) { + if (_uiState.value.isTerminatingRun) return + _uiState.update { it.copy(isTerminatingRun = true) } + viewModelScope.launch { + when (val result = repository.terminateRun(runId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isTerminatingRun = false, + toastMessage = if (result.data.ok) "Run terminated" else "Run was not terminated", + ) + } + if (result.data.ok && result.data.taskId == taskId && + activeTaskId == taskId && activeBoard == board + ) { + val latestRun = repository.getRun(runId, board) + _uiState.update { it.copy(selectedRun = (latestRun as? NetworkResult.Success)?.data?.run) } + loadTaskSilently(board, taskId) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isTerminatingRun = false, toastMessage = result.error.message) + } + } + } + } + } + + fun specifyTask( + board: String, + taskId: String, + ) { + if (_uiState.value.isSpecifying) return + _uiState.update { it.copy(isSpecifying = true) } + viewModelScope.launch { + when (val result = repository.specifyTask(taskId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isSpecifying = false, + toastMessage = + if (result.data.ok) { + "Task specified" + } else { + result.data.reason + ?: "Specification failed" + }, + ) + } + if (result.data.ok && activeTaskId == taskId && + activeBoard == board + ) { + loadTaskSilently(board, taskId) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isSpecifying = false, toastMessage = result.error.message) + } + } + } + } + } + + fun createParentLink( + board: String, + childId: String, + parentId: String, + ) { + if (parentId.isBlank() || _uiState.value.isLinking) return + _uiState.update { it.copy(isLinking = true) } + viewModelScope.launch { + when (val result = repository.createTaskLink(board, parentId.trim(), childId)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLinking = false, + toastMessage = + when { + !result.data.ok -> "Parent link was not created" + result.data.gated -> "Linked; task gated by parent" + else -> "Parent linked" + }, + ) + } + if (result.data.ok) { + if (activeTaskId == childId && activeBoard == board) { + loadTaskSilently(board, childId) + } else if (activeTaskId == null) { + loadTask(board, childId) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isLinking = false, toastMessage = result.error.message) + } + } + } + } + } + + fun setRunFilter( + board: String, + taskId: String, + type: String?, + name: String?, + ) { + if ((type == null) != (name == null) || type !in setOf(null, "status", "outcome")) return + _uiState.update { it.copy(runStateType = type, runStateName = name) } + if (activeBoard == board && activeTaskId == taskId) { + viewModelScope.launch { loadTaskSilently(board, taskId) } + } + } + public override fun onCleared() { stopLogPolling() detailPollJob?.cancel() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt new file mode 100644 index 00000000..f0a02dff --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.ui.kanban + +/** The dashboard currently sends dependency refusals as a 409 string detail, not JSON fields. */ +data class KanbanTransitionRefusal( + val blockingParentIds: List, + val blockingParentStatuses: List, +) + +fun kanbanTransitionRefusal(message: String): KanbanTransitionRefusal? { + val (parentList, pattern) = + when { + message.contains("blocked by parent(s) not done") -> { + message.substringAfter("blocked by parent(s) not done") to Regex("\\(([^(),\\s]+), status=([^()]*)\\)") + } + + message.contains("unsatisfied parent dependencies:") -> { + message.substringAfter("unsatisfied parent dependencies:").substringBefore(';') to + Regex("([^,;()\\s]+) \\(([^()]*)\\)") + } + + else -> { + return null + } + } + val blockers = + pattern.findAll(parentList).map { match -> match.groupValues[1] to match.groupValues[2] }.toList() + return blockers.takeIf { it.isNotEmpty() }?.let { + KanbanTransitionRefusal(it.map { pair -> pair.first }, it.map { pair -> pair.second }) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 874f7ee4..54e3faec 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.ExportBoardBody import com.m57.hermescontrol.data.model.ImportBoardBody +import com.m57.hermescontrol.data.model.KanbanActiveWorker import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanColumn @@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.repository.KanbanRepository @@ -64,8 +66,22 @@ data class KanbanUiState( val columnCollapseOverrides: Map = emptyMap(), val errorMessage: String? = null, val toastMessage: String? = null, + val selectedWorkflowTemplateId: String? = null, + val selectedCurrentStepKey: String? = null, + val activeWorkers: List = emptyList(), + val isLoadingWorkers: Boolean = false, ) +fun filterKanbanTasks( + tasks: List, + workflowTemplateId: String?, + currentStepKey: String?, +): List = + tasks.filter { task -> + (workflowTemplateId == null || task.workflowTemplateId.equals(workflowTemplateId, ignoreCase = true)) && + (currentStepKey == null || task.currentStepKey.equals(currentStepKey, ignoreCase = true)) + } + /** * Task actions mirroring the desktop kanban's transition-gated buttons. * Only moves the backend PATCH route accepts are offered: never `running` @@ -250,7 +266,15 @@ class KanbanViewModel( preferences.setSelectedBoard(endpoint, board.id) val gen = ++currentLoadGen - _uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) } + _uiState.update { + it.copy( + selectedBoard = board, + isLoading = true, + errorMessage = null, + activeWorkers = emptyList(), + isLoadingWorkers = false, + ) + } viewModelScope.launch { loadBoardIntoState(board, gen) connectEvents(board) @@ -272,6 +296,54 @@ class KanbanViewModel( _uiState.update { it.copy(groupRunning = group) } } + fun setWorkflowFilters( + workflowTemplateId: String?, + currentStepKey: String?, + ) { + _uiState.update { + it.copy( + selectedWorkflowTemplateId = workflowTemplateId, + selectedCurrentStepKey = currentStepKey, + ) + } + _uiState.value.selectedBoard?.let { board -> + val gen = ++currentLoadGen + viewModelScope.launch { loadBoardIntoState(board, gen) } + } + } + + fun loadActiveWorkers() { + val board = _uiState.value.selectedBoard ?: return + _uiState.update { it.copy(isLoadingWorkers = true) } + viewModelScope.launch { + when (val result = repository.getActiveWorkers(board.id)) { + is NetworkResult.Success -> { + _uiState.update { + if (it.selectedBoard?.id == board.id) { + it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false) + } else { + it + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + if (it.selectedBoard?.id == board.id) { + it.copy( + isLoadingWorkers = false, + activeWorkers = emptyList(), + toastMessage = "Failed to load active workers: ${result.error.message}", + ) + } else { + it + } + } + } + } + } + } + fun toggleColumnCollapse( columnName: String, boardHasWork: Boolean, @@ -860,10 +932,22 @@ class KanbanViewModel( operatingTaskIds = state.operatingTaskIds - task.id, ) } + reloadBoardSilently() } is NetworkResult.Failure -> { - revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") + val refusal = + (result.error as? NetworkError.Http) + ?.takeIf { it.code == 409 } + ?.let { kanbanTransitionRefusal(it.message) } + val message = + if (refusal != null) { + "Move blocked by parents ${refusal.blockingParentIds.joinToString()} " + + "(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}" + } else { + "Move failed: ${result.error.message}" + } + revertTaskMove(task.id, originalStatus, message) } } } @@ -945,6 +1029,8 @@ class KanbanViewModel( repository.getBoard( board = board.id, includeArchived = _uiState.value.includeArchived, + workflowTemplateId = _uiState.value.selectedWorkflowTemplateId, + currentStepKey = _uiState.value.selectedCurrentStepKey, ) if (gen != currentLoadGen) return@launch if (result is NetworkResult.Success) { @@ -1010,6 +1096,8 @@ class KanbanViewModel( repository.getBoard( board = board.id, includeArchived = _uiState.value.includeArchived, + workflowTemplateId = _uiState.value.selectedWorkflowTemplateId, + currentStepKey = _uiState.value.selectedCurrentStepKey, ) applyBoardResult(board, result, gen) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt index bf9a9cd9..b8986d8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt @@ -34,10 +34,15 @@ fun KanbanFilterSheet( tenants: List, selectedAssignee: String?, selectedTenant: String?, + workflowTemplateIds: List, + currentStepKeys: List, + selectedWorkflowTemplateId: String?, + selectedCurrentStepKey: String?, includeArchived: Boolean, groupRunning: Boolean, onSelectAssignee: (String?) -> Unit, onSelectTenant: (String?) -> Unit, + onSelectWorkflowFilters: (String?, String?) -> Unit, onToggleIncludeArchived: (Boolean) -> Unit, onToggleGroupRunning: (Boolean) -> Unit, onClearFilters: () -> Unit, @@ -105,6 +110,29 @@ fun KanbanFilterSheet( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + "Workflow template", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) { + onSelectWorkflowFilters(null, selectedCurrentStepKey) + } + workflowTemplateIds.forEach { value -> + FilterChoiceRow(value, selectedWorkflowTemplateId == value) { + onSelectWorkflowFilters(value, selectedCurrentStepKey) + } + } + Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + FilterChoiceRow("All steps", selectedCurrentStepKey == null) { + onSelectWorkflowFilters(selectedWorkflowTemplateId, null) + } + currentStepKeys.forEach { value -> + FilterChoiceRow(value, selectedCurrentStepKey == value) { + onSelectWorkflowFilters(selectedWorkflowTemplateId, value) + } + } + // Assignee Filter Text( text = stringResource(R.string.kanban_assignee), @@ -192,3 +220,18 @@ fun KanbanFilterSheet( } } } + +@Composable +private fun FilterChoiceRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5b7a442f..1f8b9619 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1495,6 +1495,33 @@ ุฅุบู„ุงู‚ ุชูƒุงู…ู„ุงุช ุงู„ุฌู„ุณุฉ + + ุชุญุฏูŠุฏ ุงู„ู…ู‡ู…ุฉ + ุฌุงุฑู ุงู„ุชุญุฏูŠุฏโ€ฆ + ุชู… ุชุญุฏูŠุฏ ุงู„ู…ู‡ู…ุฉ ุจู†ุฌุงุญ + ุชุนุฐุฑ ุชุญุฏูŠุฏ ุงู„ู…ู‡ู…ุฉ: %1$s + ุฅุถุงูุฉ ู…ู‡ู…ุฉ ุฃุตู„ + ู…ุนุฑู‘ู ุงู„ู…ู‡ู…ุฉ ุงู„ุฃุตู„ + ู„ุง ูŠู…ูƒู† ุฅู†ุดุงุก ุงู„ุฑุงุจุท ู‚ุจู„ ุชุญุฏูŠุฏ ุงู„ู…ู‡ู…ุฉ + ุชู… ุฅู†ุดุงุก ุฑุงุจุท ุงู„ู…ู‡ู…ุฉ + ุชูุงุตูŠู„ ุงู„ุชุดุบูŠู„ + ูุญุต ุงู„ุชุดุบูŠู„ + ุฅู†ู‡ุงุก ุงู„ุชุดุบูŠู„ + ู‡ู„ ุชุฑูŠุฏ ุฅู†ู‡ุงุก ู‡ุฐุง ุงู„ุชุดุบูŠู„ุŸ + ุชู… ุฅู†ู‡ุงุก ุงู„ุชุดุบูŠู„ + ุงู„ุชุดุบูŠู„ ุบูŠุฑ ู†ุดุท + ูุญุต ุงู„ุชุดุบูŠู„ ุบูŠุฑ ู…ุชุงุญ + ุญุงู„ุฉ ุงู„ุชุดุบูŠู„ + ูƒู„ ุนู…ู„ูŠุงุช ุงู„ุชุดุบูŠู„ + ุญุฐู ุงู„ู…ุฑูู‚ + ู‡ู„ ุชุฑูŠุฏ ุญุฐู ู‡ุฐุง ุงู„ู…ุฑูู‚ุŸ + ุชู†ุฒูŠู„ ุงู„ู…ุฑูู‚ + ุชู… ุญูุธ ุงู„ู…ุฑูู‚ + ุชู… ุญุฐู ุงู„ู…ุฑูู‚ + ุฎุทุฃ ููŠ ุงู„ู…ุฑูู‚: %1$s + ุงู„ุนุงู…ู„ูˆู† ุงู„ู†ุดุทูˆู† + ู„ุง ูŠูˆุฌุฏ ุนุงู…ู„ูˆู† ู†ุดุทูˆู† + ุชุดุบูŠู„ ุฅูŠู‚ุงู ู…ุคู‚ุช diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 20b1d7fc..1d7a72c3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1212,4 +1212,31 @@ %1$s์€(๋Š”) ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค ์„ค์ • ์‘๋‹ต์„ ๋ณด๋‚ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”. Hermes๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ค‘โ€ฆ + + + ์ž‘์—… ์ง€์ • + ์ง€์ • ์ค‘โ€ฆ + ์ž‘์—…์ด ์ง€์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์ž‘์—… ์ง€์ • ์‹คํŒจ: %1$s + ์ƒ์œ„ ์ž‘์—… ์ถ”๊ฐ€ + ์ƒ์œ„ ์ž‘์—… ID + ์ž‘์—…์„ ์ง€์ •ํ•˜๊ธฐ ์ „์—๋Š” ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค + ์ž‘์—… ๋งํฌ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์‹คํ–‰ ์ƒ์„ธ ์ •๋ณด + ์‹คํ–‰ ๊ฒ€์‚ฌ + ์‹คํ–‰ ์ข…๋ฃŒ + ์ด ์‹คํ–‰์„ ์ข…๋ฃŒํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? + ์‹คํ–‰์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์‹คํ–‰์ด ๋น„ํ™œ์„ฑ ์ƒํƒœ์ž…๋‹ˆ๋‹ค + ์‹คํ–‰ ๊ฒ€์‚ฌ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค + ์‹คํ–‰ ์ƒํƒœ + ๋ชจ๋“  ์‹คํ–‰ + ์ฒจ๋ถ€ ํŒŒ์ผ ์‚ญ์ œ + ์ด ์ฒจ๋ถ€ ํŒŒ์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? + ์ฒจ๋ถ€ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + ์ฒจ๋ถ€ ํŒŒ์ผ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์ฒจ๋ถ€ ํŒŒ์ผ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค + ์ฒจ๋ถ€ ํŒŒ์ผ ์˜ค๋ฅ˜: %1$s + ํ™œ์„ฑ ์ž‘์—…์ž + ํ™œ์„ฑ ์ž‘์—…์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f72c2a80..01a08977 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1469,6 +1469,33 @@ ๆ’ญๆ”พ่ง†้ข‘ ๆญฃๅœจๅ‡†ๅค‡ไผš่ฏโ€ฆ ไผš่ฏๆœชๅฐฑ็ปช๏ผŒ่ฏท้‡่ฏ•ไปฅ็ปง็ปญ + + + ๆŒ‡ๅฎšไปปๅŠก + ๆญฃๅœจๆŒ‡ๅฎšโ€ฆ + ไปปๅŠกๆŒ‡ๅฎšๆˆๅŠŸ + ๆŒ‡ๅฎšไปปๅŠกๅคฑ่ดฅ๏ผš%1$s + ๆทปๅŠ ็ˆถไปปๅŠก + ็ˆถไปปๅŠก ID + ๆŒ‡ๅฎšไปปๅŠกๅ‰ๆ— ๆณ•ๅˆ›ๅปบ้“พๆŽฅ + ไปปๅŠก้“พๆŽฅๅทฒๅˆ›ๅปบ + ่ฟ่กŒ่ฏฆๆƒ… + ๆฃ€ๆŸฅ่ฟ่กŒ + ็ปˆๆญข่ฟ่กŒ + ็กฎๅฎš่ฆ็ปˆๆญขๆญค่ฟ่กŒๅ—๏ผŸ + ่ฟ่กŒๅทฒ็ปˆๆญข + ่ฟ่กŒๆœชๅค„ไบŽๆดปๅŠจ็Šถๆ€ + ๆ— ๆณ•ๆฃ€ๆŸฅ่ฟ่กŒ + ่ฟ่กŒ็Šถๆ€ + ๆ‰€ๆœ‰่ฟ่กŒ + ๅˆ ้™ค้™„ไปถ + ็กฎๅฎš่ฆๅˆ ้™คๆญค้™„ไปถๅ—๏ผŸ + ไธ‹่ฝฝ้™„ไปถ + ้™„ไปถๅทฒไฟๅญ˜ + ้™„ไปถๅทฒๅˆ ้™ค + ้™„ไปถ้”™่ฏฏ๏ผš%1$s + ๆดป่ทƒๅทฅไฝœ่ฟ›็จ‹ + ๆฒกๆœ‰ๆดป่ทƒๅทฅไฝœ่ฟ›็จ‹ ่ฎพ็ฝฎ%1$s ็ฌฌ%1$d้กน๏ผŒๅ…ฑ%2$d้กน ๅทฒๆŽˆๆƒ๏ผŒไฝ†ๅทฅๅ…ทไธๅฏ็”จ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3dffc60..1074ec6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1710,4 +1710,31 @@ %1$s is required Couldnโ€™t send the setup response. Try again. Waiting for Hermesโ€ฆ + + + Specify task + Specifyingโ€ฆ + Task specified successfully + Failed to specify task: %1$s + Add parent + Parent task ID + Linking is unavailable until the task is specified + Task link created + Run details + Inspect run + Terminate run + Terminate this run? + Run terminated + Run is inactive + Run inspection is unavailable + Run state + All runs + Delete attachment + Delete this attachment? + Download attachment + Attachment saved + Attachment deleted + Attachment error: %1$s + Active workers + No active workers diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 961ae46f..d575c7c0 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -1256,6 +1256,17 @@ class E2eIntegrationTest { com.m57.hermescontrol.data.model .UpdateTaskResponse(task = task.copy(status = "ready")), ) + coEvery { mockApiService.getBoard("board-1", any(), any()) } returns + Response.success( + KanbanBoardResponse( + listOf( + KanbanColumn("todo", emptyList()), + KanbanColumn("ready", listOf(task.copy(status = "ready"))), + ), + null, + null, + ), + ) viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY) advanceUntilIdle() assertEquals( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt index 876b69de..a63a4a32 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt @@ -314,4 +314,57 @@ class KanbanSerializationTest { assertTrue(unassignJson.contains("\"assignee\":\"\"")) assertTrue(unassignJson.contains("\"reclaim_first\":true")) } + + @Test + fun testRunAndWorkerWireShapesRetainScopeAndOptionalInspectionState() { + val run = + json + .decodeFromString( + """{"run":{"id":7,"task_id":"t_1","profile":"builder","step_key":"implement", "status":"running","worker_pid":42,"started_at":100,"max_runtime_seconds":3600,"last_heartbeat_at":110}}""", + ).run + assertEquals("t_1", run.taskId) + assertEquals("implement", run.stepKey) + assertEquals(3600, run.maxRuntimeSeconds) + assertNull(run.endedAt) + + val workers = + json.decodeFromString( + """{"workers":[{"run_id":7,"task_id":"t_1","task_title":"Build", "task_status":"running","worker_pid":42}],"count":1,"checked_at":123}""", + ) + assertEquals(7L, workers.workers.single().runId) + assertEquals(123L, workers.checkedAt) + + val unavailable = + json.decodeFromString( + """{"run_id":7,"alive":false,"reason":"psutil not available"}""", + ) + assertFalse(unavailable.alive) + assertEquals("psutil not available", unavailable.reason) + assertNull(unavailable.cpuPercent) + } + + @Test + fun testBoardTaskWorkflowFieldsAndNullableAttachments() { + val task = + json.decodeFromString( + """{"id":"t_1","title":"Build","status":"running","workflow_template_id":"release","current_step_key":"verify","current_run_id":7}""", + ) + assertEquals("release", task.workflowTemplateId) + assertEquals("verify", task.currentStepKey) + assertEquals(7L, task.currentRunId) + + val detail = + json.decodeFromString( + """{"task":{"id":"t_1","title":"Build","status":"running"},"attachments":null,"runs":[]}""", + ) + assertNull(detail.attachments) + assertTrue(detail.runs.isEmpty()) + } + + @Test + fun testTaskLinkPayloadUsesBackendSnakeCase() { + val wire = json.encodeToString(TaskLinkBody(parentId = "t_parent", childId = "t_child")) + assertTrue(wire.contains("\"parent_id\":\"t_parent\"")) + assertTrue(wire.contains("\"child_id\":\"t_child\"")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt index 22f62c43..09be1912 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt @@ -7,6 +7,9 @@ import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate import com.m57.hermescontrol.data.model.ReassignTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TerminateRunBody import com.m57.hermescontrol.data.model.UpdateTaskBody import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaType @@ -308,4 +311,155 @@ class KanbanApiServiceTest { assertEquals("POST", nudgeReq.method) assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path) } + + @Test + fun testBoardAndTaskFiltersAreExplicitlyScoped() = + runBlocking { + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "columns": [] }""")) + + api.getBoard( + board = "dev", + includeArchived = false, + tenant = null, + workflowTemplateId = "release", + currentStepKey = "verify", + ) + + val boardRequest = mockServer.takeRequest() + assertEquals("GET", boardRequest.method) + assertTrue(boardRequest.path?.contains("board=dev") == true) + assertTrue(boardRequest.path?.contains("workflow_template_id=release") == true) + assertTrue(boardRequest.path?.contains("current_step_key=verify") == true) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "task": { "id": "t_1", "title": "Task", "status": "done" } }"""), + ) + + api.getTask( + taskId = "t_1", + board = "dev", + runStateType = "outcome", + runStateName = "completed", + ) + + val taskRequest = mockServer.takeRequest() + assertEquals("GET", taskRequest.method) + assertTrue(taskRequest.path?.contains("board=dev") == true) + assertTrue(taskRequest.path?.contains("run_state_type=outcome") == true) + assertTrue(taskRequest.path?.contains("run_state_name=completed") == true) + } + + @Test + fun testAttachmentListAndDeleteAreBoardScoped() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "attachments": [{ "id": 9, "filename": "proof.txt" }] }"""), + ) + + val listed = api.listAttachments(taskId = "t_1", board = "dev") + assertEquals( + "proof.txt", + listed + .body() + ?.attachments + ?.single() + ?.filename, + ) + assertEquals("/api/plugins/kanban/tasks/t_1/attachments?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "id": 9 }""")) + val deleted = api.deleteAttachment(attachmentId = 9L, board = "dev") + assertTrue(deleted.body()?.ok == true) + val request = mockServer.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/api/plugins/kanban/attachments/9?board=dev", request.path) + } + + @Test + fun testWorkerRunAndSpecificationOperationsAreBoardScoped() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{ "workers": [{ "run_id": 7, "task_id": "t_1", "task_title": "Task", "task_status": "running", "worker_pid": 42 }], "count": 1, "checked_at": 123 }""", + ), + ) + val workers = api.getActiveWorkers(board = "dev") + assertEquals( + 7L, + workers + .body() + ?.workers + ?.single() + ?.runId, + ) + assertEquals("/api/plugins/kanban/workers/active?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "run": { "id": 7, "task_id": "t_1", "status": "running", "started_at": 100 } }"""), + ) + val run = api.getRun(runId = 7L, board = "dev") + assertEquals("t_1", run.body()?.run?.taskId) + assertEquals("/api/plugins/kanban/runs/7?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "run_id": 7, "alive": true, "pid": 42, "cpu_percent": 1.5 }"""), + ) + val inspection = api.inspectRun(runId = 7L, board = "dev") + assertTrue(inspection.body()?.alive == true) + assertEquals("/api/plugins/kanban/runs/7/inspect?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "run_id": 7, "task_id": "t_1" }"""), + ) + val terminated = + api.terminateRun( + runId = 7L, + board = "dev", + body = TerminateRunBody(reason = "Operator requested stop"), + ) + assertTrue(terminated.body()?.ok == true) + val terminateRequest = mockServer.takeRequest() + assertEquals("POST", terminateRequest.method) + assertEquals("/api/plugins/kanban/runs/7/terminate?board=dev", terminateRequest.path) + assertTrue(terminateRequest.body.readUtf8().contains("Operator requested stop")) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "task_id": "t_1", "new_title": "Specified task" }"""), + ) + val specified = api.specifyTask("t_1", "dev", SpecifyTaskBody(author = "mobile")) + assertEquals("Specified task", specified.body()?.newTitle) + assertEquals("/api/plugins/kanban/tasks/t_1/specify?board=dev", mockServer.takeRequest().path) + } + + @Test + fun testTaskLinkConsumesGatingResult() = + runBlocking { + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "gated": true }""")) + + val response = + api.createTaskLink( + board = "dev", + body = TaskLinkBody(parentId = "parent", childId = "child"), + ) + + assertTrue(response.body()?.ok == true) + assertTrue(response.body()?.gated == true) + val request = mockServer.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/plugins/kanban/links?board=dev", request.path) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt index 393fffc2..7d571931 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt @@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.SpecifyTaskResponse +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.remote.KanbanApiService @@ -107,4 +111,28 @@ class KanbanRepositoryTest { val result = repository.getBoard("missing") assertTrue(result is NetworkResult.Failure) } + + @Test + fun testNewTaskOperationsCarryBoardAndTypedResults() = + runTest(testDispatcher) { + coEvery { mockApi.deleteAttachment(9L, "ops") } returns + Response.success(DeleteAttachmentResponse(ok = true, id = 9L)) + coEvery { mockApi.terminateRun(7L, "ops", any()) } returns + Response.success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1")) + coEvery { mockApi.specifyTask("t_1", "ops", any()) } returns + Response.success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured")) + coEvery { mockApi.createTaskLink("ops", any()) } returns + Response.success(TaskLinkResponse(ok = true, gated = true)) + + assertTrue((repository.deleteAttachment(9L, "ops") as NetworkResult.Success).data.ok) + assertEquals("t_1", (repository.terminateRun(7L, "ops") as NetworkResult.Success).data.taskId) + assertEquals("Not configured", (repository.specifyTask("t_1", "ops") as NetworkResult.Success).data.reason) + assertTrue((repository.createTaskLink("ops", "parent", "t_1") as NetworkResult.Success).data.gated) + coVerify { mockApi.deleteAttachment(9L, "ops") } + coVerify { mockApi.terminateRun(7L, "ops", any()) } + coVerify { mockApi.specifyTask("t_1", "ops", any()) } + coVerify { + mockApi.createTaskLink("ops", match { it.parentId == "parent" && it.childId == "t_1" }) + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 89aad88e..de218f32 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -161,6 +161,16 @@ class KanbanMutationTest { // Complete network call deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready")))) + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn("todo", emptyList()), + KanbanColumn("ready", listOf(task.copy(status = "ready"))), + ), + ), + ) testDispatcher.scheduler.advanceUntilIdle() assertFalse( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index e56d010e..acf9f4f8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -1,11 +1,21 @@ package com.m57.hermescontrol.ui.kanban +import com.m57.hermescontrol.data.model.AttachmentListResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse +import com.m57.hermescontrol.data.model.KanbanAttachment +import com.m57.hermescontrol.data.model.KanbanRun +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.ReassignTaskResponse import com.m57.hermescontrol.data.model.ReclaimTaskResponse +import com.m57.hermescontrol.data.model.SpecifyTaskResponse +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.ws.KanbanEventsClient @@ -19,6 +29,8 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -197,7 +209,11 @@ class KanbanTaskViewModelTest { ) val vm = createViewModel() - vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3)) + vm.uploadAttachment( + "dev", + "t_1", + MultipartBody.Part.createFormData("file", "test.png", byteArrayOf(1, 2, 3).toRequestBody()), + ) testDispatcher.scheduler.advanceUntilIdle() coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) } @@ -321,4 +337,160 @@ class KanbanTaskViewModelTest { coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") } vm.onCleared() } + + @Test + fun testTerminateRunRefreshesRunAndExactTask() = + runTest(testDispatcher) { + val running = KanbanTaskFull(id = "t_1", title = "Task", status = "running") + val ready = running.copy(status = "ready") + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returnsMany + listOf( + NetworkResult.Success(KanbanTaskDetailResponse(task = running)), + NetworkResult.Success(KanbanTaskDetailResponse(task = ready)), + ) + coEvery { mockRepository.terminateRun(7L, "dev") } returns + NetworkResult.Success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1")) + coEvery { mockRepository.getRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "reclaimed"))) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.terminateRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals( + "ready", + vm.uiState.value.detail + ?.task + ?.status, + ) + assertEquals( + "reclaimed", + vm.uiState.value.selectedRun + ?.status, + ) + coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev", any(), any()) } + coVerify { mockRepository.getRun(7L, "dev") } + vm.onCleared() + } + + @Test + fun testRunInspectionUnavailableIsRepresentedHonestly() = + runTest(testDispatcher) { + coEvery { mockRepository.getRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "running"))) + coEvery { mockRepository.inspectRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunInspection(runId = 7L, alive = false, reason = "psutil not available")) + + val vm = createViewModel() + vm.inspectRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals( + "psutil not available", + vm.uiState.value.runInspection + ?.reason, + ) + assertEquals( + 7L, + vm.uiState.value.selectedRun + ?.id, + ) + } + + @Test + fun testSpecifyFailureUsesTypedReasonWithoutRefreshingTask() = + runTest(testDispatcher) { + coEvery { mockRepository.specifyTask("t_1", "dev") } returns + NetworkResult.Success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured")) + + val vm = createViewModel() + vm.specifyTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.toastMessage + ?.contains("Not configured") == true, + ) + coVerify(exactly = 0) { mockRepository.getTask("t_1", "dev", any(), any()) } + } + + @Test + fun testLinkGatingOutcomeIsVisibleAndReloadsTask() = + runTest(testDispatcher) { + coEvery { mockRepository.createTaskLink("dev", "parent", "t_1") } returns + NetworkResult.Success(TaskLinkResponse(ok = true, gated = true)) + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns + NetworkResult.Success( + KanbanTaskDetailResponse(task = KanbanTaskFull(id = "t_1", title = "Child", status = "todo")), + ) + + val vm = createViewModel() + vm.createParentLink("dev", "t_1", "parent") + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.toastMessage + ?.contains("gated") == true, + ) + coVerify { mockRepository.getTask("t_1", "dev", any(), any()) } + } + + @Test + fun testDeleteAttachmentReloadsExactTaskAttachmentList() = + runTest(testDispatcher) { + val task = KanbanTaskFull(id = "t_1", title = "Task", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns + NetworkResult.Success( + KanbanTaskDetailResponse(task = task, attachments = listOf(KanbanAttachment(9L, filename = "old"))), + ) + coEvery { mockRepository.deleteAttachment(9L, "dev") } returns + NetworkResult.Success(DeleteAttachmentResponse(ok = true, id = 9L)) + coEvery { mockRepository.listAttachments("t_1", "dev") } returns + NetworkResult.Success(AttachmentListResponse(emptyList())) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.deleteAttachment("dev", "t_1", 9L) + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.detail + ?.attachments + ?.isEmpty() == true, + ) + coVerify { mockRepository.listAttachments("t_1", "dev") } + } + + @Test + fun testRunFilterPassesPairedBackendParameters() = + runTest(testDispatcher) { + val detail = KanbanTaskDetailResponse(task = KanbanTaskFull("t_1", "Task", status = "done")) + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns NetworkResult.Success(detail) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.setRunFilter("dev", "t_1", "status", "reclaimed") + testDispatcher.scheduler.runCurrent() + + coVerify { mockRepository.getTask("t_1", "dev", "status", "reclaimed") } + assertEquals("reclaimed", vm.uiState.value.runStateName) + } + + @Test + fun testTerminateRefusalPreservesTaskAndReportsReason() = + runTest(testDispatcher) { + coEvery { mockRepository.terminateRun(7L, "dev") } returns + NetworkResult.Failure(NetworkError.Http(409, "run 7 already ended")) + val vm = createViewModel() + vm.terminateRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals("run 7 already ended", vm.uiState.value.toastMessage) + assertTrue(!vm.uiState.value.isTerminatingRun) + coVerify(exactly = 0) { mockRepository.getRun(7L, "dev") } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt new file mode 100644 index 00000000..32606df8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.kanban + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class KanbanTransitionRefusalTest { + @Test + fun `ready refusal names blocking parent ids and statuses`() { + val refusal = + kanbanTransitionRefusal( + "Cannot move to 'ready': blocked by parent(s) not done โ€” 'Build' (t_a, status=ready), " + + "'Review' (t_b, status=review)", + ) + assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds) + assertEquals(listOf("ready", "review"), refusal?.blockingParentStatuses) + } + + @Test + fun `done refusal identifies unsatisfied parent relationship`() { + val refusal = + kanbanTransitionRefusal( + "cannot move t_c to 'done': unsatisfied parent dependencies: t_a (ready), t_b (running); " + + "complete the parents first (done or archived)", + ) + assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds) + assertEquals(listOf("ready", "running"), refusal?.blockingParentStatuses) + } + + @Test + fun `generic conflict is not falsely classified as dependency refusal`() { + assertNull(kanbanTransitionRefusal("status transition to 'done' not valid from current state")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index e378863e..c6b00bec 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -223,6 +223,19 @@ class KanbanViewModelTest { val vm = createViewModel() vm.loadBoards() settle() + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn(name = "todo", tasks = emptyList()), + KanbanColumn( + name = "ready", + tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "ready")), + ), + ), + ), + ) coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse()) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) settle() @@ -255,6 +268,132 @@ class KanbanViewModelTest { ) } + @Test + fun `moveTask dependency conflict surfaces blocking parent ids`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.updateTask(any(), any(), any()) } returns + Response.error( + 409, + """{"detail":"Cannot move to 'ready': blocked by parent(s) not done โ€” 'Build' (t_parent, status=running)"}""" + .toResponseBody(), + ) + vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) + settle() + assertEquals( + "todo", + vm.uiState.value.tasks + .single() + .status, + ) + assertTrue( + vm.uiState.value.toastMessage + .orEmpty() + .contains("t_parent"), + ) + } + + @Test + fun `workflow filters retain only matching tasks`() { + val tasks = + listOf( + KanbanTask("a", "A", status = "todo", workflowTemplateId = "wf", currentStepKey = "build"), + KanbanTask("b", "B", status = "todo", workflowTemplateId = "other", currentStepKey = "test"), + ) + assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", "build").map { it.id }) + assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", null).map { it.id }) + } + + @Test + fun `active workers are loaded for selected board`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.getActiveWorkers("work") } returns + Response.success( + com.m57.hermescontrol.data.model.ActiveWorkersResponse( + workers = + listOf( + com.m57.hermescontrol.data.model.KanbanActiveWorker( + runId = 1, + taskId = "t1", + taskTitle = "Task 1", + taskStatus = "running", + workerPid = 42, + ), + ), + checkedAt = 1, + ), + ) + vm.loadActiveWorkers() + settle() + assertEquals(1, vm.uiState.value.activeWorkers.size) + assertEquals( + "t1", + vm.uiState.value.activeWorkers + .single() + .taskId, + ) + vm.selectBoard(KanbanBoard(id = "ops", name = "Ops")) + settle() + assertTrue( + vm.uiState.value.activeWorkers + .isEmpty(), + ) + } + + @Test + fun `successful move refreshes board to reconcile done ordering`() { + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn( + name = "todo", + tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo")), + ), + KanbanColumn( + name = "done", + tasks = listOf(KanbanTask(id = "older", title = "Older", status = "done")), + ), + ), + ), + ) + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn(name = "todo", tasks = emptyList()), + KanbanColumn( + name = "done", + tasks = + listOf( + KanbanTask(id = "t1", title = "Task 1", status = "done"), + KanbanTask(id = "older", title = "Older", status = "done"), + ), + ), + ), + ), + ) + coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse()) + vm.moveTask(KanbanTask("t1", "Task 1", status = "todo"), KanbanTaskAction.COMPLETE, "done") + settle() + coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) } + assertEquals( + listOf("t1", "older"), + vm.uiState.value.columns + .single { it.name == "done" } + .tasks + .map { it.id }, + ) + } + @Test fun `moveTask complete sends the completion summary`() { val vm = createViewModel() From 0eb8d9a8c1da5a0e0d5da61b25c29657a55862ca Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:58:21 +0300 Subject: [PATCH 16/18] fix: show tool discovery details in chat bubbles (#1237) --- .../ui/chat/ToolDiscoveryBubbleTest.kt | 57 +++++++++++ .../ui/chat/tool/ToolRendererRegistry.kt | 2 + .../ui/chat/tool/render/SkillRenderers.kt | 96 ++++++++++++++++--- .../chat/tool/ToolViewCharacterizationTest.kt | 35 +++++++ 4 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt new file mode 100644 index 00000000..77b638ff --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ToolDiscoveryBubbleTest.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ToolDiscoveryBubbleTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun tappingSearchBubbleShowsMatchedToolDetails() { + showBubble( + "tool_search", + """{"name":"tool_search","args":{"queries":["process output"]},"result":{"results":[{"query":"process output","matches":["process_manage"]}],"tools":{"process_manage":{"description":"Poll a background process","source_name":"terminal","required":["action"]}}}}""", + ) + + composeRule.onNodeWithText("process output (1 match)", substring = true).performClick() + composeRule.onNodeWithText("Poll a background process", substring = true).assertExists() + } + + @Test + fun tappingDescribeBubbleShowsSchema() { + showBubble( + "tool_describe", + """{"name":"tool_describe","args":{"names":["process_manage"]},"result":{"tools":{"process_manage":{"description":"Poll a background process","parameters":{"type":"object","properties":{"action":{"type":"string","description":"Action to take"}},"required":["action"]}}}}}""", + ) + + composeRule.onNodeWithText("process_manage (1 tool)", substring = true).performClick() + composeRule.onNodeWithText("Action to take", substring = true).assertExists() + } + + private fun showBubble( + name: String, + content: String, + ) { + composeRule.setContent { + HermesControlTheme { + ToolBubble( + ChatMessage( + role = MessageRole.TOOL, + content = content, + toolName = name, + toolStatus = ToolStatus.COMPLETED, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index 3df58405..1d06f7f1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -28,6 +28,7 @@ import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer @@ -91,6 +92,7 @@ internal object ToolRendererRegistry { "skill_view" to SkillViewRenderer, "skill_manage" to SkillManageRenderer, "tool_search" to ToolSearchRenderer, + "tool_describe" to ToolDescribeRenderer, // Media "image_generate" to ImageGenerateRenderer, "vision_analyze" to VisionAnalyzeRenderer, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt index 0937962c..758945f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt @@ -118,21 +118,91 @@ internal object SkillManageRenderer : ToolRenderer { /** `tool_search`: on-demand tool discovery matches. */ internal object ToolSearchRenderer : ToolRenderer { override fun subtitle(call: ToolCall): String { - val query = ToolJson.firstString(call.args, listOf("query")) - val matches = (call.result?.get("matches") as? JsonArray)?.size + val query = + ToolJson + .firstString(call.args, listOf("query")) + .ifEmpty { (call.args?.get("queries") as? JsonArray)?.joinToString(", ") { text(it) } ?: "" } + val matches = + (call.result?.get("results") as? JsonArray)?.sumOf { row -> + (ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray)?.size ?: 0 + } ?: (call.result?.get("matches") as? JsonArray)?.size - return if (matches != null) "$query ($matches matches)" else query + return if (matches != null) "$query ($matches ${if (matches == 1) "match" else "matches"})" else query + } + + override fun detail(call: ToolCall): String { + val tools = call.result?.get("tools") as? JsonObject + val matches = + (call.result?.get("results") as? JsonArray) + ?.flatMap { row -> (ToolJson.parseMaybeObject(row)?.get("matches") as? JsonArray).orEmpty() } + ?: (call.result?.get("matches") as? JsonArray).orEmpty() + return matches + .mapNotNull { match -> + val item = ToolJson.parseMaybeObject(match) + val name = item?.let { ToolJson.firstString(it, listOf("name")) } ?: text(match) + if (name.isEmpty()) return@mapNotNull null + val metadata = tools?.get(name) as? JsonObject + val desc = + ToolJson + .firstString(metadata, listOf("description")) + .ifEmpty { ToolJson.firstString(item, listOf("description")) } + val source = ToolJson.firstString(metadata, listOf("source_name")) + val required = (metadata?.get("required") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty() + buildString { + append("๐Ÿ”ง $name") + if (source.isNotEmpty()) append(" ยท $source") + if (desc.isNotEmpty()) append("\n $desc") + if (required.isNotEmpty()) append("\n Required: $required") + } + }.distinct() + .joinToString("\n\n") + .ifEmpty { "No matching tools" } + } +} + +/** `tool_describe`: readable schema summaries instead of a collapsed JSON object. */ +internal object ToolDescribeRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val names = (call.args?.get("names") as? JsonArray)?.joinToString(", ") { text(it) }.orEmpty() + val count = (call.result?.get("tools") as? JsonObject)?.size + return if (count != null) "$names ($count ${if (count == 1) "tool" else "tools"})" else names } override fun detail(call: ToolCall): String = - (call.result?.get("matches") as? JsonArray) - ?.mapNotNull { el -> - val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null - val name = ToolJson.firstString(m, listOf("name")) - val desc = ToolJson.firstString(m, listOf("description")) - val lines = mutableListOf("๐Ÿ”ง $name") - if (desc.isNotEmpty()) lines += " $desc" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "No matching tools" + (call.result?.get("tools") as? JsonObject) + ?.entries + ?.joinToString("\n\n") { (name, value) -> + val tool = ToolJson.parseMaybeObject(value) + val desc = ToolJson.firstString(tool, listOf("description")) + val schema = tool?.get("parameters") as? JsonObject + val properties = schema?.get("properties") as? JsonObject + val required = (schema?.get("required") as? JsonArray)?.map(::text).orEmpty().toSet() + buildString { + append("๐Ÿ”ง $name") + if (desc.isNotEmpty()) append("\n $desc") + if (!properties.isNullOrEmpty()) { + append("\n Parameters: ") + append( + properties.entries.joinToString("; ") { (paramName, value) -> + val param = ToolJson.parseMaybeObject(value) + val type = ToolJson.firstString(param, listOf("type")) + val help = ToolJson.firstString(param, listOf("description")) + val default = param?.get("default") + buildString { + append(paramName) + if (type.isNotEmpty()) append(" ($type)") + if (paramName in required) append(" *") + if (help.isNotEmpty()) append(" โ€” $help") + if (default != null) append(" [default: ${text(default)}]") + } + }, + ) + } else if (required.isNotEmpty()) { + append("\n Required: ${required.joinToString(", ")}") + } + } + }?.ifEmpty { "No tool descriptions returned" } ?: "No tool descriptions returned" } + +private fun text(value: kotlinx.serialization.json.JsonElement): String = + (value as? JsonPrimitive)?.content ?: value.toString() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index 8e05020d..12e04846 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -758,6 +758,41 @@ class ToolViewCharacterizationTest { assertEquals("๐Ÿ”ง read_file\n Reads\n\n๐Ÿ”ง write_file", view.detail) } + @Test + fun `tool_search renders queries and named tool metadata from discovery response`() { + val view = + build( + "tool_search", + """{"queries":["read background process output poll"]}""", + """{"queries":["read background process output poll"],"total_available":119,"results":[{"query":"read background process output poll","matches":["process_manage","close_terminal"]}],"tools":{"process_manage":{"source":"plugin","source_name":"terminal","description":"Poll or wait on a process","required":["action"]},"close_terminal":{"source":"plugin","source_name":"desktop_ui","description":"Hide a terminal tab","required":["process_id"]}}}""", + ) + + assertEquals("read background process output poll (2 matches)", view.subtitle) + assertEquals( + "๐Ÿ”ง process_manage ยท terminal\n Poll or wait on a process\n Required: action\n\n" + + "๐Ÿ”ง close_terminal ยท desktop_ui\n Hide a terminal tab\n Required: process_id", + view.detail, + ) + } + + @Test + fun `tool_describe renders tool parameters and required fields`() { + val view = + build( + "tool_describe", + """{"names":["mcp__agentmail__list_inboxes"]}""", + """{"tools":{"mcp__agentmail__list_inboxes":{"description":"List email inboxes, paginated.","parameters":{"type":"object","properties":{"limit":{"type":"number","description":"Max number of items to return","default":10},"pageToken":{"type":"string","description":"Page token for pagination"}},"required":[]}}}}""", + ) + + assertEquals("mcp__agentmail__list_inboxes (1 tool)", view.subtitle) + assertEquals( + "๐Ÿ”ง mcp__agentmail__list_inboxes\n List email inboxes, paginated.\n" + + " Parameters: limit (number) โ€” Max number of items to return [default: 10]; " + + "pageToken (string) โ€” Page token for pagination", + view.detail, + ) + } + @Test fun `read_terminal shows line window and terminal block`() { val view = From 4a732870d7bd7ee0389f081c84ece51894963ddd Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:00:55 +0300 Subject: [PATCH 17/18] feat(chat): show read and write file contents in code viewer card (#1238) --- .../ui/chat/FileToolBubbleTest.kt | 95 +++++ .../ui/chat/components/FileViewCardTest.kt | 78 ++++ .../m57/hermescontrol/ui/chat/ToolBubble.kt | 137 +++---- .../ui/chat/components/CodeTerminalCard.kt | 193 ++++++++++ .../ui/chat/components/DiffViewCard.kt | 352 +++++++++--------- .../ui/chat/components/MessageCards.kt | 98 ++--- .../ui/chat/tool/ToolRenderer.kt | 2 + .../hermescontrol/ui/chat/tool/ToolView.kt | 3 + .../ui/chat/tool/ToolViewBuilder.kt | 2 + .../ui/chat/tool/render/FileRenderers.kt | 32 +- .../ui/chat/tool/ToolViewBuilderTest.kt | 35 ++ 11 files changed, 681 insertions(+), 346 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt new file mode 100644 index 00000000..fa5d3736 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt @@ -0,0 +1,95 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class FileToolBubbleTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun tappingReadFileBubbleShowsFileViewCardWithSyntaxViewer() { + val payload = + """ + { + "args": {"path": "/repo/src/Main.kt", "offset": 1, "limit": 2}, + "result": {"content": "1|fun main() {\n2| println(\"hi\")\n3|}"} + } + """.trimIndent() + + showBubble("read_file", payload) + + composeRule.onNodeWithText("Read Main.kt L1-2", substring = true).performClick() + composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("/repo/src/Main.kt", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists() + } + + @Test + fun tappingWriteFileBubbleShowsFileViewCardWhenNoDiff() { + val payload = + """ + { + "args": { + "path": "/repo/src/Config.json", + "content": "{\n \"enabled\": true\n}" + }, + "result": {"verified": true, "success": true} + } + """.trimIndent() + + showBubble("write_file", payload) + + composeRule.onNodeWithText("Config.json", substring = true).performClick() + composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("/repo/src/Config.json", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists() + } + + @Test + fun tappingWriteFileWithDiffShowsDiffViewCard() { + val payload = + """ + { + "args": {"path": "/repo/src/Utils.kt"}, + "result": { + "inline_diff": "--- a/Utils.kt\n+++ b/Utils.kt\n@@ -1,1 +1,1 @@\n-old\n+new" + } + } + """.trimIndent() + + showBubble("write_file", payload) + + composeRule.onNodeWithText("Utils.kt", substring = true).performClick() + composeRule.onNodeWithTag("diff_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("Utils.kt", useUnmergedTree = true).assertExists() + } + + private fun showBubble( + name: String, + content: String, + ) { + composeRule.setContent { + HermesControlTheme { + ToolBubble( + ChatMessage( + role = MessageRole.TOOL, + content = content, + toolName = name, + toolStatus = ToolStatus.COMPLETED, + ), + ) + } + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt new file mode 100644 index 00000000..eed04ae4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class FileViewCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun shortFile_displaysPathLineCountAndFullContentWithoutExpandButton() { + val content = "fun hello() = println(\"world\")\nval x = 42" + var copiedText = "" + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/main/Test.kt", + onCopy = { copiedText = it }, + ) + } + + composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed() + composeTestRule.onNodeWithText("src/main/Test.kt").assertIsDisplayed() + composeTestRule.onNodeWithText("2 lines").assertIsDisplayed() + composeTestRule.onNodeWithText("Show full file (2 lines)").assertDoesNotExist() + + composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed() + } + + @Test + fun longFile_showsExpandButtonAndTogglesFullFile() { + val lines = (1..25).map { "line $it: println($it)" } + val content = lines.joinToString("\n") + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/Big.kt", + ) + } + + composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed() + composeTestRule.onNodeWithText("Show full file (25 lines)").performClick() + composeTestRule.onNodeWithText("Collapse file").assertIsDisplayed() + composeTestRule.onNodeWithText("Collapse file").performClick() + composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed() + } + + @Test + fun copyButton_invokesCallbackWithFullContent() { + val content = "1|fun main() = Unit" + var copied = "" + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/App.kt", + onCopy = { copied = it }, + ) + } + composeTestRule.onNodeWithText("src/App.kt").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Copy file content").performClick() + assertEquals(content, copied) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 5ed735bd..54a8b4d7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -75,7 +75,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import com.m57.hermescontrol.ui.chat.components.FileViewCard import com.m57.hermescontrol.ui.chat.components.highlightSyntax import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolView @@ -423,6 +425,11 @@ private fun ExpandedToolContent( diffText = view.inlineDiff, filePath = view.diffPath, ) + } else if (view.fileContent != null) { + FileViewCard( + content = view.fileContent, + filePath = view.filePath, + ) } // โ”€โ”€ Search hits โ”€โ”€ @@ -478,7 +485,7 @@ private fun ExpandedToolContent( } // โ”€โ”€ Plain detail body โ”€โ”€ - if (view.detail.isNotBlank() && view.inlineDiff == null) { + if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { Text( text = view.detail, modifier = @@ -636,99 +643,49 @@ private fun ToolRawJsonView( } } - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false - } - } - - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("tool_raw_json"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + CodeTerminalCard( + textToCopy = displayText, + modifier = modifier, + testTag = "tool_raw_json", + title = "JSON", + copyContentDescription = stringResource(R.string.content_desc_copy), + headerActions = { + if (isFormatDifferent) { + Text( + text = + if (formatJson) { + stringResource(R.string.chat_tool_compact_json) + } else { + stringResource(R.string.chat_tool_format_json) + }, + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("tool_json_format_toggle") + .clickable(role = Role.Button) { formatJson = !formatJson } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + }, ) { - Column { - Row( + SelectionContainer { + Text( + text = highlighted, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "JSON", - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeTerminalMuted, - ), - ) - Spacer(Modifier.weight(1f)) - if (isFormatDifferent) { - Text( - text = - if (formatJson) { - stringResource(R.string.chat_tool_compact_json) - } else { - stringResource(R.string.chat_tool_format_json) - }, - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("tool_json_format_toggle") - .clickable(role = Role.Button) { formatJson = !formatJson } - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText))) - } - copied = true - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = - if (copied) { - stringResource(R.string.content_desc_copied) - } else { - stringResource(R.string.content_desc_copy) - }, - tint = CodeTerminalMuted, - modifier = Modifier.size(14.dp), - ) - } - } - SelectionContainer { - Text( - text = highlighted, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = CodeTerminalText, - softWrap = true, - ) - } + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = CodeTerminalText, + softWrap = true, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt new file mode 100644 index 00000000..4f84be97 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt @@ -0,0 +1,193 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.CodeTerminalBg +import com.m57.hermescontrol.theme.CodeTerminalBorder +import com.m57.hermescontrol.theme.CodeTerminalMuted +import com.m57.hermescontrol.theme.CodeTerminalText +import kotlinx.coroutines.delay + +/** + * Shared container card for terminal-styled code, diff, and JSON viewers. + * + * Provides the dark terminal surface, consistent header row with leading icon/label, + * optional status badges/actions, animated copy-to-clipboard button, content body slot, + * and optional collapsible footer. + */ +@Composable +fun CodeTerminalCard( + textToCopy: String, + modifier: Modifier = Modifier, + testTag: String? = null, + title: String? = null, + icon: ImageVector? = null, + clipLabel: String? = null, + copyContentDescription: String = stringResource(R.string.content_desc_copy), + onCopy: ((String) -> Unit)? = null, + headerLeading: (@Composable RowScope.() -> Unit)? = null, + headerActions: (@Composable RowScope.() -> Unit)? = null, + totalLines: Int? = null, + collapsibleThreshold: Int = 16, + isExpanded: Boolean = false, + onToggleExpand: (() -> Unit)? = null, + expandLabel: String = "Show full", + collapseLabel: String = "Collapse", + content: @Composable () -> Unit, +) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Surface( + modifier = + modifier + .fillMaxWidth() + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), + shape = RoundedCornerShape(8.dp), + color = CodeTerminalBg, + border = BorderStroke(1.dp, CodeTerminalBorder), + ) { + Column(modifier = Modifier.animateContentSize()) { + // Header bar + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + if (headerLeading != null) { + headerLeading() + } else { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTerminalMuted, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + } + if (!title.isNullOrBlank()) { + Text( + text = title, + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = CodeTerminalText, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(8.dp)) + } + } + + headerActions?.invoke(this) + } + + IconButton( + onClick = { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(clipLabel, textToCopy)) + copied = true + onCopy?.invoke(textToCopy) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = + if (copied) { + stringResource(R.string.content_desc_copied) + } else { + copyContentDescription + }, + tint = CodeTerminalMuted, + modifier = Modifier.size(14.dp), + ) + } + } + + // Body + content() + + // Expand / Collapse footer button + if (totalLines != null && totalLines > collapsibleThreshold && onToggleExpand != null) { + TextButton( + onClick = onToggleExpand, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isExpanded) collapseLabel else expandLabel, + style = MaterialTheme.typography.labelSmall, + color = CodeTerminalMuted, + ) + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = CodeTerminalMuted, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt index af3f116e..105db8ac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt @@ -1,51 +1,33 @@ package com.m57.hermescontrol.ui.chat.components -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.theme.CodeDiffAddBg @@ -54,11 +36,10 @@ import com.m57.hermescontrol.theme.CodeDiffDeleteBg import com.m57.hermescontrol.theme.CodeDiffDeleteText import com.m57.hermescontrol.theme.CodeDiffHunkBg import com.m57.hermescontrol.theme.CodeDiffHunkText -import com.m57.hermescontrol.theme.CodeTerminalBg -import com.m57.hermescontrol.theme.CodeTerminalBorder import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalText -import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext enum class DiffLineType { FILE_HEADER, @@ -169,17 +150,8 @@ fun DiffViewCard( onCopy: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) } var expanded by remember { mutableStateOf(false) } - var copied by remember { mutableStateOf(false) } - - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false - } - } val displayLines = if (expanded || parsed.lines.size <= 16) { @@ -188,166 +160,178 @@ fun DiffViewCard( parsed.lines.take(16) } - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("diff_view_card"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + CodeTerminalCard( + textToCopy = diffText, + modifier = modifier, + testTag = "diff_view_card", + title = parsed.filePath ?: "diff", + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + clipLabel = "diff", + copyContentDescription = "Copy diff", + onCopy = onCopy, + headerActions = { + if (parsed.additionsCount > 0) { + Text( + text = "+${parsed.additionsCount}", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = CodeDiffAddText, + ), + ) + Spacer(Modifier.width(6.dp)) + } + if (parsed.deletionsCount > 0) { + Text( + text = "-${parsed.deletionsCount}", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = CodeDiffDeleteText, + ), + ) + Spacer(Modifier.width(6.dp)) + } + }, + totalLines = parsed.lines.size, + isExpanded = expanded, + onToggleExpand = { expanded = !expanded }, + expandLabel = "Show full diff (${parsed.lines.size} lines)", + collapseLabel = "Collapse diff", ) { - Column(modifier = Modifier.animateContentSize()) { - // Header bar - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, + val verticalScrollModifier = + if (expanded) { + Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState()) + } else { + Modifier + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .then(verticalScrollModifier) + .horizontalScroll(rememberScrollState()) + .width(IntrinsicSize.Max), + ) { + displayLines.forEach { line -> + val (bgColor, textColor) = + when (line.type) { + DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText + DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText + DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText + DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted + DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(bgColor) + .padding(horizontal = 10.dp, vertical = 2.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - tint = CodeTerminalMuted, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(6.dp)) Text( - text = parsed.filePath ?: "diff", + text = line.text, style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Bold, + MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, - color = CodeTerminalText, + fontSize = 11.sp, + color = textColor, ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), ) - Spacer(Modifier.width(8.dp)) - - // Addition / Deletion badges - if (parsed.additionsCount > 0) { - Text( - text = "+${parsed.additionsCount}", - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeDiffAddText, - ), - ) - Spacer(Modifier.width(6.dp)) - } - if (parsed.deletionsCount > 0) { - Text( - text = "-${parsed.deletionsCount}", - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeDiffDeleteText, - ), - ) - Spacer(Modifier.width(6.dp)) - } - } - - IconButton( - onClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - clipboard?.setPrimaryClip(ClipData.newPlainText("diff", diffText)) - copied = true - onCopy(diffText) - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy diff", - tint = CodeTerminalMuted, - modifier = Modifier.size(14.dp), - ) - } - } - - // Diff lines body - val verticalScrollModifier = - if (expanded) { - Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState()) - } else { - Modifier - } - - Column( - modifier = - Modifier - .fillMaxWidth() - .then(verticalScrollModifier) - .horizontalScroll(rememberScrollState()) - .width(IntrinsicSize.Max), - ) { - displayLines.forEach { line -> - val (bgColor, textColor) = - when (line.type) { - DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText - DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText - DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText - DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted - DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText - } - - Box( - modifier = - Modifier - .fillMaxWidth() - .background(bgColor) - .padding(horizontal = 10.dp, vertical = 2.dp), - ) { - Text( - text = line.text, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = textColor, - ), - ) - } - } - } - - // Expand / Collapse footer button for diffs > 16 lines - if (parsed.lines.size > 16) { - TextButton( - onClick = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth(), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = - if (expanded) { - "Collapse diff" - } else { - "Show full diff (${parsed.lines.size} lines)" - }, - style = MaterialTheme.typography.labelSmall, - color = CodeTerminalMuted, - ) - Spacer(Modifier.width(4.dp)) - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - tint = CodeTerminalMuted, - modifier = Modifier.size(16.dp), - ) - } } } } } } + +/** + * Interactive file content view card for read_file & write_file tool outputs. + * Mirrors [DiffViewCard] with syntax highlighting and file path header. + */ +@Composable +fun FileViewCard( + content: String, + filePath: String? = null, + onCopy: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val lines = remember(content) { content.lines() } + val displayText = + remember(content, expanded) { + if (expanded || lines.size <= 16) { + content + } else { + lines.take(16).joinToString("\n") + } + } + val highlighted by produceState( + initialValue = remember(displayText) { AnnotatedString(displayText) }, + key1 = displayText, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(displayText) + } + } + + CodeTerminalCard( + textToCopy = content, + modifier = modifier, + testTag = "file_view_card", + title = filePath?.takeIf { it.isNotBlank() } ?: "file", + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + clipLabel = "file", + copyContentDescription = "Copy file content", + onCopy = onCopy, + headerActions = { + if (lines.isNotEmpty()) { + Text( + text = "${lines.size} lines", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = CodeTerminalMuted, + ), + ) + Spacer(Modifier.width(6.dp)) + } + }, + totalLines = lines.size, + isExpanded = expanded, + onToggleExpand = { expanded = !expanded }, + expandLabel = "Show full file (${lines.size} lines)", + collapseLabel = "Collapse file", + ) { + val verticalScrollModifier = + if (expanded) { + Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState()) + } else { + Modifier + } + + SelectionContainer { + Box( + modifier = + Modifier + .fillMaxWidth() + .then(verticalScrollModifier) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + text = highlighted, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = CodeTerminalText, + ), + softWrap = false, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index a66cfb6f..6bd00aaa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -266,79 +266,35 @@ fun CodeBlockCard( onCopy: (String) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("code_block"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + val highlighted by produceState( + initialValue = remember(code) { AnnotatedString(code) }, + key1 = code, ) { - Column { - // Header row: language badge (left) + copy button (right) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!language.isNullOrBlank()) { - Text( - text = language.uppercase(), - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeTerminalMuted, - ), - ) - } - Spacer(Modifier.weight(1f)) - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - kotlinx.coroutines.delay(2000) - copied = false - } - } - IconButton( - onClick = { - copyToClipboard(context, code) - copied = true - onCopy(code) - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy code", - tint = CodeTerminalMuted, - modifier = Modifier.size(14.dp), - ) - } + value = + withContext(Dispatchers.Default) { + highlightSyntax(code) } - // Code content with syntax highlighting - val highlighted by produceState( - initialValue = remember(code) { AnnotatedString(code) }, - key1 = code, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(code) - } - } - Text( - text = highlighted, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = CodeTerminalText, - softWrap = false, - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } + } + + CodeTerminalCard( + textToCopy = code, + modifier = modifier, + testTag = "code_block", + title = language?.takeIf { it.isNotBlank() }?.uppercase(), + onCopy = onCopy, + copyContentDescription = "Copy code", + ) { + Text( + text = highlighted, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = CodeTerminalText, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt index 3216b66f..272b5b21 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -50,6 +50,8 @@ internal data class ToolViewExtras( val inlineDiff: String? = null, val diffPath: String? = null, val diffStats: DiffStats? = null, + val fileContent: String? = null, + val filePath: String? = null, val searchHits: List? = null, val searchQuery: String? = null, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index f8c1394d..aed5a017 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -54,6 +54,9 @@ data class ToolView( /** File-edit tools: the file the diff applies to. */ val diffPath: String? = null, val diffStats: DiffStats? = null, + /** File contents shown in a syntax-highlighted code card. */ + val fileContent: String? = null, + val filePath: String? = null, /** Image-producing tools: renderable URL (data: or http(s) image). */ val imageUrl: String? = null, /** web_search: parsed result rows. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index b70a26ff..8666af00 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -116,6 +116,8 @@ object ToolViewBuilder { inlineDiff = clampedDiff, diffPath = extras.diffPath, diffStats = extras.diffStats, + fileContent = extras.fileContent?.let { ToolJson.clampForDisplay(it) }, + filePath = extras.filePath, imageUrl = imageUrlFor(call.args, call.result), searchHits = extras.searchHits, searchQuery = extras.searchQuery, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index e053079d..ae8095df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -158,6 +158,21 @@ internal object ReadFileRenderer : ToolRenderer { return ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val content = ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) + return if (content.isEmpty()) { + ToolViewExtras.NONE + } else { + ToolViewExtras( + fileContent = content, + filePath = ToolJson.firstString(call.args, listOf("path", "file", "filepath")), + ) + } + } } /** @@ -196,7 +211,22 @@ internal object FileEditRenderer : ToolRenderer { val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult) if (inlineDiff.isEmpty()) { - return ToolViewExtras.NONE + val content = + if (call.name == "write_file" && status == ToolViewStatus.SUCCESS && call.rawResult != null) { + ToolJson + .firstString(call.args, listOf("content")) + .ifEmpty { ToolJson.firstString(call.result, listOf("content", "text", "body")) } + } else { + "" + } + return if (content.isEmpty()) { + ToolViewExtras.NONE + } else { + ToolViewExtras( + fileContent = content, + filePath = FileEditSupport.editPath(call.args, call.result), + ) + } } return ToolViewExtras( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index b6ecac48..c375b5df 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -81,6 +81,41 @@ class ToolViewBuilderTest { assertEquals("Read a.kt L10-14", view.title) assertEquals("/repo/src/a.kt", view.subtitle) + assertEquals("10|a\n11|b", view.fileContent) + assertEquals("/repo/src/a.kt", view.filePath) + assertEquals("10|a\n11|b", view.detail) + } + + @Test + fun `read_file missing content does not show a code viewer`() { + val view = build("read_file", """{"path":"/repo/missing.kt"}""", """{"error":"not found"}""") + + assertNull(view.fileContent) + } + + @Test + fun `write_file shows written content when no diff is returned`() { + val view = + build( + "write_file", + """{"path":"/repo/new.kt","content":"fun main() = Unit"}""", + """{"success":true,"verified":true}""", + ) + + assertEquals("fun main() = Unit", view.fileContent) + assertEquals("/repo/new.kt", view.filePath) + assertNull(view.inlineDiff) + } + + @Test + fun `write_file diff wins over content and failed writes show no written content`() { + val args = """{"path":"/repo/a.kt","content":"new"}""" + val diff = build("write_file", args, """{"inline_diff":"--- a/a.kt\n+++ b/a.kt\n-old\n+new"}""") + val failed = build("write_file", args, """{"success":false,"error":"denied"}""", isError = true) + + assertNotNull(diff.inlineDiff) + assertNull(diff.fileContent) + assertNull(failed.fileContent) } @Test From 8cd1ec52980ef8165a780f8a4e9beb7d67f6cda8 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:33:54 +0300 Subject: [PATCH 18/18] feat(chat): surface model switch warning messages in chat (#1239) --- .../ui/chat/ChatModelSwitchDelegate.kt | 13 +++ .../ui/chat/ChatModelSwitchDelegateTest.kt | 91 +++++++++++++++++++ .../ui/kanban/KanbanScopeTest.kt | 4 +- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 78b75d7e..092e8a9a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -340,6 +340,19 @@ class ChatModelSwitchDelegate( it.copy(modelSwitchConfirmMessage = confirmMessage) } } + } else { + if (pending != null && pending.sequence == modelSwitchSequence) { + val warning = (map["warning"] as? String)?.trim() + if (!warning.isNullOrBlank()) { + val formattedWarning = + if (warning.startsWith("โš ") || warning.startsWith("โš ๏ธ")) { + warning + } else { + "โš  $warning" + } + addAssistantMessage(formattedWarning) + } + } } } else if (key == "fast") { val rawVal = map["value"] as? String diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index b4d7da88..94370290 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -647,4 +647,95 @@ class ChatModelSwitchDelegateTest { delegate.dismissModelSwitchConfirm() assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) } + + @Test + fun handleConfigSetResult_modelWarningWithoutConfirm_addsAssistantMessage() = + testScope.runTest { + delegate.handleModelSwitch("/model gpt-69-sol --provider openai-codex --session") + advanceUntilIdle() + + val warningText = + "Note: `gpt-69-sol` was not found in the OpenAI Codex model listing. " + + "Similar models: `gpt-5.6-sol`" + delegate.handleConfigSetResult( + id = "req-1", + result = + mapOf( + "key" to "model", + "value" to "gpt-69-sol", + "confirm_required" to false, + "warning" to warningText, + ), + ) + + assertEquals(1, assistantMessages.size) + assertEquals("โš  $warningText", assistantMessages.first()) + } + + @Test + fun handleConfigSetResult_modelWarningWithExistingWarningEmoji_doesNotDuplicateEmoji() = + testScope.runTest { + delegate.handleModelSwitch("/model gpt-69-sol --provider openai-codex --session") + advanceUntilIdle() + + val warningText = "โš  Note: Already prefixed" + delegate.handleConfigSetResult( + id = "req-1", + result = + mapOf( + "key" to "model", + "value" to "gpt-69-sol", + "confirm_required" to false, + "warning" to warningText, + ), + ) + + assertEquals(listOf(warningText), assistantMessages) + } + + @Test + fun handleConfigSetResult_staleSequenceWarning_isIgnored() = + testScope.runTest { + // First switch + delegate.handleModelSwitch("/model gpt-4o --provider openai --session") + advanceUntilIdle() + + // Second switch supersedes first + delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session") + advanceUntilIdle() + + // First switch response arrives with warning + delegate.handleConfigSetResult( + id = "req-1", + result = + mapOf( + "key" to "model", + "value" to "gpt-4o", + "confirm_required" to false, + "warning" to "Stale warning", + ), + ) + + assertTrue(assistantMessages.isEmpty()) + } + + @Test + fun handleConfigSetResult_blankWarning_doesNotAddAssistantMessage() = + testScope.runTest { + delegate.handleModelSwitch("/model gpt-4o --provider openai --session") + advanceUntilIdle() + + delegate.handleConfigSetResult( + id = "req-1", + result = + mapOf( + "key" to "model", + "value" to "gpt-4o", + "confirm_required" to false, + "warning" to " ", + ), + ) + + assertTrue(assistantMessages.isEmpty()) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt index 36e16be9..a9b3d827 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt @@ -38,7 +38,9 @@ class KanbanScopeTest { repository = mockRepository, preferences = preferences, eventsClientProvider = { mockEventsClient }, - ) + ).also { + it.ioDispatcher = testDispatcher + } @Before fun setUp() {