From e4004decb4acd18273a19fca2b2f8d12b829ce8e Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:23:12 +0300 Subject: [PATCH] release: promote dev to main for v1.30.0 (#1271) Signed-off-by: dependabot[bot] Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- app/build.gradle.kts | 12 +- app/debug.keystore | Bin 0 -> 2666 bytes .../10.json | 143 + .../data/local/ChatMessagePagingDeviceTest.kt | 82 +- .../hermescontrol/ui/chat/ChatScreenTest.kt | 36 + .../ui/chat/MarkdownHeadingRenderingTest.kt | 33 + .../ui/chat/MarkdownTableRenderingTest.kt | 35 + .../components/ComposerInteractionTest.kt | 171 +- .../chat/components/ComposerReadinessTest.kt | 8 + .../chat/components/PendingSendPanelTest.kt | 98 + .../ui/chat/components/ReasoningCardTest.kt | 24 + .../ui/chat/components/ReplyErrorCardTest.kt | 139 + .../ui/settings/components/ChatSectionTest.kt | 81 +- .../data/config/ServerStoreState.kt | 2 + .../hermescontrol/data/local/AuthManager.kt | 7 + .../data/local/ChatMessageDao.kt | 3 + .../data/local/ChatMessageEntity.kt | 2 + .../data/local/ChatMessageMapper.kt | 5 + .../data/local/HermesDatabase.kt | 14 +- .../data/model/AudioTranscription.kt | 30 + .../hermescontrol/data/model/BusySendMode.kt | 11 + .../hermescontrol/data/remote/ApiClient.kt | 84 +- .../data/remote/HermesApiService.kt | 12 + .../data/remote/ProfileScopeInterceptor.kt | 7 + .../data/repository/VoiceNoteRepository.kt | 98 + .../hermescontrol/data/ws/HermesWsClient.kt | 14 +- .../data/ws/SubagentRepository.kt | 9 +- .../m57/hermescontrol/data/ws/WsMethods.kt | 1 + .../notification/ChatNotificationService.kt | 69 +- .../ui/chat/BusySendModeLabel.kt | 15 + .../m57/hermescontrol/ui/chat/ChatMessage.kt | 20 + .../ui/chat/ChatPersistenceRepository.kt | 4 + .../ui/chat/ChatQueuePresentation.kt | 17 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 78 +- .../hermescontrol/ui/chat/ChatSendStore.kt | 149 + .../ui/chat/ChatServerMessageMapper.kt | 50 +- .../ui/chat/ChatTranscriptMerge.kt | 112 +- .../ui/chat/ChatVerifierFooter.kt | 80 + .../hermescontrol/ui/chat/ChatViewModel.kt | 1790 +++++++++- .../ui/chat/ChatWsEventReducer.kt | 152 +- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 10 +- .../m57/hermescontrol/ui/chat/ReplyFailure.kt | 59 + .../m57/hermescontrol/ui/chat/ToolBubble.kt | 8 +- .../ui/chat/VoiceNoteRecorder.kt | 120 + .../ui/chat/components/ChatComposer.kt | 128 +- .../ui/chat/components/ChatMediaLaunchers.kt | 135 +- .../ui/chat/components/ComposerToolbar.kt | 278 +- .../ui/chat/components/MessageCards.kt | 53 +- .../ui/chat/components/PendingSendPanel.kt | 103 + .../ui/chat/components/ReplyErrorCard.kt | 122 + .../components/VoiceNoteRecordingPanel.kt | 128 + .../ui/chat/fullbleed/AgentStatusIndicator.kt | 4 +- .../ui/chat/fullbleed/FullBleedChatList.kt | 4 +- .../ui/chat/markdown/MarkdownInlineStyler.kt | 153 +- .../ui/chat/markdown/MarkdownTableView.kt | 48 +- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 9 + .../settings/components/AppearanceSection.kt | 36 + app/src/main/res/values-ar/strings.xml | 24 +- app/src/main/res/values-ko/strings.xml | 24 +- app/src/main/res/values-zh/strings.xml | 120 + app/src/main/res/values/reply_errors.xml | 16 + app/src/main/res/values/strings.xml | 26 + .../data/local/ChatMessageMapperTest.kt | 27 + .../local/HermesDatabaseMigrationsTest.kt | 12 + .../remote/ProfileScopeInterceptorTest.kt | 26 + .../repository/VoiceNoteRepositoryTest.kt | 133 + .../ChatNotificationServiceTest.kt | 59 + .../ui/chat/ChatCachedHistoryTest.kt | 187 ++ .../ui/chat/ChatPagingMergeTest.kt | 101 + .../ui/chat/ChatPersistenceRepositoryTest.kt | 18 + .../ui/chat/ChatQueuePresentationTest.kt | 48 + .../ui/chat/ChatReplyErrorTest.kt | 315 ++ .../ui/chat/ChatSendStoreTest.kt | 207 ++ .../ui/chat/ChatUpdateCommandTest.kt | 53 +- .../ui/chat/ChatVerifierFooterTest.kt | 90 + .../ui/chat/ChatViewModelTest.kt | 2963 ++++++++++++++++- .../ui/chat/FullBleedTurnsTest.kt | 15 + .../ui/chat/MarkdownTextFeatureTest.kt | 58 + .../ui/chat/ReplyFailureDetailsTest.kt | 61 + .../ui/chat/SlashCommandDispatchRpcTest.kt | 45 +- .../ui/chat/fakes/FakeChatMessageDao.kt | 4 + .../ui/settings/SettingsViewModelTest.kt | 14 + gradle/libs.versions.toml | 4 +- 85 files changed, 9284 insertions(+), 465 deletions(-) create mode 100644 app/debug.keystore create mode 100644 app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/VoiceNoteRecorder.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt create mode 100644 app/src/main/res/values/reply_errors.xml create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f1c19e7b..6c8edfc4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -75,7 +75,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: manual - tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.26.2/codeql-bundle-linux64.tar.gz + tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.27.1/codeql-bundle-linux64.tar.gz - name: Build Codebase (Compile Only) run: | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63cd14e3..df81b98c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,6 +36,16 @@ android { } signingConfigs { + getByName("debug") { + // Stable debug keystore committed to the repo so debug APKs keep + // the same signature across machines and CI runs. CI runners use + // an ephemeral default debug keystore otherwise, which makes every + // CI debug APK uninstallable over a previously installed build. + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } create("release") { val isReleaseBuild = gradle.startParameter.taskNames.any { name -> @@ -301,4 +311,4 @@ tasks.named("check") { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..882c29b6e6a4fe6eb3568ace5bffbee6dacd024a GIT binary patch literal 2666 zcma)8X*kr4`kpb1##ly#n3u7X5Po)%Wu%NUA(X9TH)G4Ol?)lO4B5hqtYwLkeJxu? zWf?{XBTEQ*sc8`gW9s-{|8w-i4}Ru3h^Y7XeCgDQt3|Lx-C0dtj*Va5mKX~65Z1I7n9 z0J?vVynqr^*lKo9)qSjQlud${QM?un(t1;gTwL-fE-(h*g&+F=i(Cj82>1hzxE_xK zdqSXKMJVe0YGTiTi7mH1e@NyvDeoO98A?|W1?WfzwbKSmT9!^YSq40=zIk-#&FPnL zQfwWqhu_OD%9bZi9<1kSY?-fjk+)rK=p)KidsCMe@i=x*`sl65DDD3AMuHb<_4ce4 zRj5NhgA&r2Uy-Y2(m>?K+&;JCU>#5*yO#HJgE8Z$Eptq$I@0b{{&wL~?o_IIMFudM zpsr0clbR@o#8BoB1M=2jy z86ibGhrl(Ty=dF`5hs)FIHBCRrvUltv0L#;Ue3S$Vz^(T!Wa9p}{YC3HAfmCxxL;i$1~W02 zOTjI#k#KZ}`t_KaLi=6v`}Y_IDIa&2zx=MmP+_I!rnI|NrCw+4hhH={b?5SViX~%E zXct??=0BOflwG6@UaYfDMOFZ3*Og{Rn+I7E`2h8TD&KzrH#X9MWm3K{p%NBpZlo%}g)hOTK zyZ*brq{Votp86oui}z9&oo;j$jcJNN24wBD9cu_i8g5sNBnfW{k2T~?+00=LTj(|- z`Nn$vf(3PC738F`c)fW^Z8C#WK~;#_tUX*pj%-c~$;7)fa~q9RsNC>*Dqf;uVLSg7 zbxl^>frHLfI>xh|?=xAvRGyKWz!u*%%}>;9F9eA)HOz8*r<(>UWA|l(Ux8OxhX`C{ z?wIn9v;?TuhaR%y)zX_+TjsK3GspN5{j!Q&64vyHP{_KNOD8d+=+S;kHr@o)Y^rV+ zQ{o&VE?kYKU%I9*D?3A|Khmo5puV`Lkct~{!r{f%EfX5x40-s=f*dwH^kYb* z_SfC4M(_rFA%8&0`*Gh=c<9wUE9sv4v)A0`DnKm2%Z{3@)CpSpkh9&+rF zW3ha$chw9y^3mpAz>VaI;vmD>?6Qt<8T{v+4NQD9<;B#VVEM!aIrxP`$D{buPE~02 z+0|{%(}pXepFTMr|5QcX)WH{WWPIlvnl2_KaEM!==I9FrkN$!Fg6r?f8n#3@5e8K( z_T@t+3s`}^Nx;{_xC|ksBB3@_)U_==W8^T&*Q8~#8niIzl!@-VnIM}kxl$$hWVWa` zT0W;&{L*+g?Dl&t4?k*>ebP+IiSmW&EBYfvL>&XJ&lI~A+W_a$5R(U2ZJ9OlK}-i? z9{s1wpz8RywMwKi@}Ym&1GzVy8 zt>tP0*{|fU^Eqc==`Yk@Pqj^6g$iKYq91*lSF#2-mwwq9bJCwOdCzo7ql@rH@^ao@ z(>~8iH>Gt>iZ=iHxv zaOSFwlk;w>T>_MU$0b5o4I?QDas>r|JV5^d1%e0%=mGKsxq(7K-XPzD?iL{Z_xcev zVd!C7g1;w5P3IJ#p``<8XahPr02!+Gdx;xfLWas5P?B6=&;f}4t$_bqaO2`C{F|HF zS#7}v*K1M2vNOv^S^rCLQ(wOwHDt(Jk=6U&{B^d4`wkgWqT#YDq&Y@@*FB<*mz=3G3l!9u+z&6}x+3an#fO?tMQ6gIKo#|KwQhRsuPNkh((5mKc%Zsz+Y`?ec;LfW= zndad>yM5!WL|58SRG1bR5>8#pICT@de! z;QpHE4NaaulOxpdju(P+D{P~LeYt~D>rKxJYP*CUh}lA4C*#MfGdcfkyj(3pzyuZ( zcq{|+UMnH&=7*hPxy$=+&fdYozLt^;mHoBDbt1!U+0xm*dZH5gHm9oGE^&P9{fes3 zq{i^CglMeq@Cuh8=*~=-H2+81bcubrvgjayhpk9l3>W9z8bSTFRq`!JeM9njm(Q@0 z%dwSGYPnNnk~+)J;@55R@JPS&xw)}jnO8!e6aJhR3ULgh6i7^#WUUcrwau@%42>-| zt!tJm*?ZN5+)TO>eePBb!KW-G(6CzalGS2XO_9O_k02wHNSGQ#s{ZafG?L|S^Fd@% z@2VoYo!4*)qS`uCYEF_$d01=-A+O(Cla^G{XdjxBjt=~D8SPfwx2$`vJi@{ogmDgP z>h*1Yv`>h$qg_zSwHan&Vl9XT-GKuh1JEE(h|^*vZIr@&nr$uWU=#)jY1O>LP8^Qd znzMVRdlB#GkaR9%HR-cnH4D8ksh0sbw`HG{HwyDA8drLpu^RJwhC+l08=en26B==P z_1ADX2eO1498zxuT?~5I61X$ yBA@_DB8o&R45%Dj3SiXP& literal 0 HcmV?d00001 diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json new file mode 100644 index 00000000..8c91d72b --- /dev/null +++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/10.json @@ -0,0 +1,143 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "1b554126b4f6033e34af91670253c1b9", + "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, `message_provenance` TEXT NOT NULL DEFAULT 'UNKNOWN', 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" + }, + { + "fieldPath": "messageProvenance", + "columnName": "message_provenance", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNKNOWN'" + } + ], + "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, '1b554126b4f6033e34af91670253c1b9')" + ] + } +} \ 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 index 795859bb..2340f16f 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/local/ChatMessagePagingDeviceTest.kt @@ -41,7 +41,7 @@ class ChatMessagePagingDeviceTest { Room .databaseBuilder(context, HermesDatabase::class.java, databaseName) .setDriver(driver()) - .addMigrations(HermesDatabase.MIGRATION_8_9) + .addMigrations(HermesDatabase.MIGRATION_8_9, HermesDatabase.MIGRATION_9_10) .build() repository = ChatPersistenceRepository(database.chatMessageDao()) } @@ -234,6 +234,29 @@ class ChatMessagePagingDeviceTest { assertPagingIndexUsed() } + @Test + fun migration9to10PreservesAmbiguousUnsentUuidUserAsUnknown() = + runBlocking { + val unsent = + ChatMessageEntity( + id = "pending-before-upgrade", + sessionId = "session", + role = "USER", + content = "may not have reached the server", + timestamp = 1L, + sortOrder = 1L, + ) + createVersion9(listOf(unsent)) + openDatabase() + val restored = database.chatMessageDao().getMessage(unsent.id)!! + assertEquals("UNKNOWN", restored.messageProvenance) + assertEquals( + com.m57.hermescontrol.ui.chat.MessageProvenance.UNKNOWN, + restored.toUiModel().messageProvenance, + ) + assertEquals(unsent.content, restored.content) + } + @Test fun confirmedAliasPreservesLivePayloadAndCanonicalOrderAcrossReopen() = runBlocking { @@ -350,6 +373,63 @@ class ChatMessagePagingDeviceTest { } } + /** Build the exported v9 schema so Room validates the real non-destructive v9 -> v10 path. */ + private fun createVersion9(rows: List) { + database.close() + context.deleteDatabase(databaseName) + val schema = + InstrumentationRegistry + .getInstrumentation() + .context.assets + .open("com.m57.hermescontrol.data.local.HermesDatabase/9.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, rest_id, sort_group, sort_order) 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) + row.restId?.let { insert.bindText(15, it) } ?: insert.bindNull(15) + insert.bindLong(16, row.sortGroup.toLong()) + insert.bindLong(17, row.sortOrder) + insert.step() + insert.reset() + insert.clearBindings() + } + } + connection.execSQL("PRAGMA user_version = 9") + } + } + private fun assertPagingIndexUsed() { database.close() driver().open(context.getDatabasePath(databaseName).absolutePath).use { connection -> 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 2b4b9731..5e5c5b79 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 @@ -4,16 +4,19 @@ import android.content.pm.PackageManager import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.ui.common.ActionProgressController import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -125,4 +128,37 @@ class ChatScreenTest { composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed() composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() } + + @Test + fun chatScreen_primarySendUsesSavedBusyDefault() { + val uiState = + ChatUiState( + connectionStatus = ConnectionStatus.CONNECTED, + isSessionReady = true, + busySendMode = BusySendMode.QUEUE, + ) + 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.connectionOperationState } returns + MutableStateFlow(ConnectionOperationUiState()).asStateFlow() + every { mockViewModel.actionProgress } returns + ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) + every { mockViewModel.sendMessage(any(), any()) } returns true + + composeTestRule.setContent { + ChatScreen( + onOpenDrawer = {}, + sessionId = null, + viewModel = mockViewModel, + ) + } + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default") + composeTestRule.onNodeWithTag("send_button").performClick() + + verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) } + verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) } + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt new file mode 100644 index 00000000..ec36d0dc --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class MarkdownHeadingRenderingTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun headingRendersCodeInsideBold() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = "## **Set `foo` now**", + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed() + composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt new file mode 100644 index 00000000..559a5fc8 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt @@ -0,0 +1,35 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class MarkdownTableRenderingTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun tableHeaderAndCellRenderInlineMarkup() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = "| **Name** | Value |\n| --- | --- |\n| Item | `code` |", + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + composeTestRule.onNodeWithText("Name").assertIsDisplayed() + composeTestRule.onNodeWithText("code").assertIsDisplayed() + composeTestRule.onNodeWithText("**Name**").assertDoesNotExist() + composeTestRule.onNodeWithText("`code`").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 2da52438..63317410 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.getUnclippedBoundsInRoot -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -24,9 +27,12 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.test.espresso.Espresso.pressBack import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.ws.CommandCatalog import org.junit.Assert.assertEquals import org.junit.Rule @@ -42,7 +48,7 @@ import org.junit.runner.RunWith @MediumTest class ComposerInteractionTest { @get:Rule - val composeTestRule = createComposeRule() + val composeTestRule = createAndroidComposeRule() private var micTaps = 0 private var sends = 0 @@ -52,6 +58,158 @@ class ComposerInteractionTest { private var photosTaps = 0 private var fileTaps = 0 + private fun setContextualComposer( + text: String, + isMainTurnBusy: Boolean, + attachments: List = emptyList(), + onSend: () -> Unit = {}, + onBusySend: (BusySendMode) -> Unit = {}, + isConnected: Boolean = true, + pendingReasoningLevel: String? = null, + ) { + composeTestRule.setContent { + ChatInputBar( + inputFieldValue = TextFieldValue(text), + onInputChange = {}, + onSend = onSend, + onBusySend = onBusySend, + onMicTap = {}, + isListening = false, + isAgentTyping = isMainTurnBusy, + isMainTurnBusy = isMainTurnBusy, + isConnected = isConnected, + commandCatalog = CommandCatalog(), + isSessionReady = true, + pendingAttachments = attachments, + pendingReasoningLevel = pendingReasoningLevel, + ) + } + } + + @Test + fun idleDraft_showsRegularSendWithoutBusyActions() { + setContextualComposer(text = "hello", isMainTurnBusy = false) + + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() + } + + @Test + fun busyBlankDraft_hidesBusyActions() { + setContextualComposer(text = "", isMainTurnBusy = true) + + composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() + } + + @Test + fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() { + var regularSends = 0 + val selected = mutableListOf() + setContextualComposer( + text = "change course", + isMainTurnBusy = true, + onSend = { regularSends++ }, + onBusySend = selected::add, + ) + + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick() + composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick() + + composeTestRule.runOnIdle { + assertEquals(1, regularSends) + assertEquals(listOf(BusySendMode.QUEUE), selected) + } + } + + @Test + fun busyAttachmentOnly_showsSendAndQueue() { + val attachment = Attachment("content://document/1", "notes.txt", "text/plain") + setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment)) + + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() + } + + @Test + fun busyDisconnectedDraft_disablesSendAndQueue() { + setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false) + composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled() + composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled() + } + + @Test + fun busyPendingReasoning_disablesSendAndQueue() { + setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high") + composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled() + composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled() + } + + @Test + fun busySlashDraft_usesRegularSendWithoutBusyActions() { + var regularSends = 0 + setContextualComposer( + text = "/stop", + isMainTurnBusy = true, + onSend = { regularSends++ }, + ) + + composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("send_button").performClick() + composeTestRule.runOnIdle { assertEquals(1, regularSends) } + } + + @Test + fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() { + val busy = mutableStateOf(true) + composeTestRule.setContent { + var input by remember { mutableStateOf(TextFieldValue("draft in progress")) } + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = {}, + onBusySend = {}, + onMicTap = {}, + isListening = false, + isAgentTyping = busy.value, + isMainTurnBusy = busy.value, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + ) + } + + composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() + composeTestRule.runOnUiThread { busy.value = false } + composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress") + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + } + + @Test + fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() { + composeTestRule.setContent { + Box(Modifier.width(220.dp)) { + ChatInputBar( + inputFieldValue = TextFieldValue("change course"), + onInputChange = {}, + onSend = {}, + onBusySend = {}, + onMicTap = {}, + isListening = false, + isAgentTyping = true, + isMainTurnBusy = true, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + ) + } + } + + composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + } + /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ private fun setComposer( reasoningLevel: String? = "medium", @@ -246,7 +404,8 @@ class ComposerInteractionTest { "short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth" } check(actionButton.right > toolbarBounds.right - 32.dp) { - "trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}" + "trailing action should stay near the toolbar edge, " + + "right=${actionButton.right} toolbarRight=${toolbarBounds.right}" } } @@ -337,11 +496,11 @@ class ComposerInteractionTest { } @Test - fun attachmentTray_dismissesOnBackPress() { + fun attachmentTray_dismissesOnActivityBack() { setComposer() openAttachmentTray() - pressBack() + composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() } composeTestRule.mainClock.advanceTimeBy(300) composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt index 796c9a5e..fc075cf0 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt @@ -34,6 +34,7 @@ class ComposerReadinessTest { onMicTap = {}, isListening = false, isAgentTyping = true, + canInterrupt = ready.value, isConnected = true, commandCatalog = CommandCatalog(), isSessionReady = ready.value, @@ -42,13 +43,20 @@ class ComposerReadinessTest { compose.onNodeWithTag("chat_input").performTextInput("held draft") compose.onNodeWithTag("chat_input").assertTextEquals("held draft") compose.onNodeWithTag("chat_session_preparing").assertExists() + // Nothing to interrupt while the session prepares: the action button + // must not offer Stop (review, PR #1250). + compose.onNodeWithTag("stop_button").assertDoesNotExist() compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick() compose.runOnIdle { assertEquals(0, sends) } compose.runOnIdle { ready.value = true } compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist() + // Ready + typing = an interruptible generation: Stop replaces the + // action glyph while queue-send stays available in the flat slot. + compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.runOnIdle { assertEquals(1, sends) } compose.runOnIdle { ready.value = false } + compose.onNodeWithTag("stop_button").assertDoesNotExist() compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("chat_input").assertTextEquals("held draft") } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt new file mode 100644 index 00000000..ab69dd29 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanelTest.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode +import com.m57.hermescontrol.ui.chat.PendingSend +import com.m57.hermescontrol.ui.chat.PendingSendState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PendingSendPanelTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun row(state: PendingSendState) = + PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state) + + @Test + fun ordinarySendNeverShowsDeliveryPanel() { + val state = mutableStateOf(PendingSendState.SENDING) + compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) } + compose.onNodeWithText("My message").assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist() + compose.runOnIdle { state.value = PendingSendState.ACCEPTED } + compose.onNodeWithText("My message").assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist() + } + + @Test + fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() { + val state = mutableStateOf(PendingSendState.QUEUED) + compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) } + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist() + compose.runOnIdle { state.value = PendingSendState.SENDING } + compose.onNodeWithText("My message").assertDoesNotExist() + compose.runOnIdle { state.value = PendingSendState.ACCEPTED } + compose.onNodeWithText("My message").assertDoesNotExist() + } + + @Test + fun failedAndUncertainDeliveryRemainRecoverable() { + val state = mutableStateOf(PendingSendState.REJECTED) + var retried: String? = null + compose.setContent { + PendingSendPanel( + listOf(row(state.value)), + mainTurnBusy = false, + onSendNow = { retried = it }, + ) + } + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick() + compose.runOnIdle { + assertEquals("receipt", retried) + state.value = PendingSendState.UNKNOWN + } + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed() + } + + @Test + fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() { + val state = mutableStateOf(PendingSendState.QUEUED) + compose.setContent { + PendingSendPanel( + sends = listOf(row(state.value)), + mainTurnBusy = true, + onSendNow = {}, + ) + } + + listOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.REJECTED, + PendingSendState.UNKNOWN, + ).forEach { pendingState -> + compose.runOnIdle { state.value = pendingState } + compose + .onNodeWithText( + compose.activity.getString(R.string.chat_pending_send_now_warning), + ).assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist() + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt index 494509c1..94882e6e 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt @@ -71,4 +71,28 @@ class ReasoningCardTest { composeTestRule.onNodeWithTag("reasoning_card").performClick() composeTestRule.onNodeWithText("Show full").assertDoesNotExist() } + + @Test + fun completedReasoning_rendersMarkdown() { + renderInLazyColumn("**Important** completed thought") + + composeTestRule.onNodeWithTag("reasoning_card").performClick() + composeTestRule.onNodeWithText("Important completed thought").assertIsDisplayed() + composeTestRule.onNodeWithText("**Important** completed thought").assertDoesNotExist() + } + + @Test + fun streamingReasoning_remainsRawText() { + composeTestRule.setContent { + LazyColumn { + item(key = "reasoning") { + ReasoningCard(reasoningText = "**Thinking**", isStreaming = true) + } + } + } + + composeTestRule.onNodeWithTag("reasoning_card").performClick() + composeTestRule.onNodeWithText("**Thinking**").assertIsDisplayed() + composeTestRule.onNodeWithText("Thinking").assertDoesNotExist() + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt new file mode 100644 index 00000000..dfe453f5 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCardTest.kt @@ -0,0 +1,139 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.chat.ReplyFailure +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReplyErrorCardTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun detailsStartCollapsedAndCopyIsScrubbedWithoutAutomaticSharing() { + var copied = "" + var shared = false + compose.setContent { + HermesControlTheme { + Box(Modifier.width(320.dp)) { + ReplyErrorCard( + failure = ReplyFailure("HTTP 429\nAuthorization: Bearer private-key"), + onDismiss = {}, + onOpenLogs = {}, + onCopy = { copied = it }, + onShare = { shared = true }, + ) + } + } + } + compose.onNodeWithText("HTTP 429", substring = true).assertDoesNotExist() + + compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_details)).performClick() + compose.onNodeWithText("HTTP 429", substring = true).assertIsDisplayed() + compose.onNodeWithText("private-key", substring = true).assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_copy)).performClick() + compose.runOnIdle { + assertEquals("HTTP 429\nAuthorization: [REDACTED]", copied) + assertFalse(shared) + } + } + + @Test + fun sharingRequiresExpandedReviewAndUsesExactSanitizedPayload() { + var shared = "" + var logsOpened = 0 + var dismissed = 0 + compose.setContent { + HermesControlTheme { + ReplyErrorCard( + failure = ReplyFailure("Provider failed\nAuthorization: Bearer private-token"), + onDismiss = { dismissed++ }, + onOpenLogs = { logsOpened++ }, + onCopy = {}, + onShare = { shared = it }, + ) + } + } + compose.runOnIdle { + assertEquals("", shared) + assertEquals(0, logsOpened) + assertEquals(0, dismissed) + } + val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details) + val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed) + val expandedLabel = compose.activity.getString(R.string.chat_reply_failed_details_expanded) + val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share) + + compose + .onNodeWithText(detailsLabel) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel)) + compose.onNodeWithText(shareLabel).assertIsNotEnabled().performClick() + compose.runOnIdle { assertEquals("", shared) } + + compose.onNodeWithText(detailsLabel).performClick() + compose + .onNodeWithText(detailsLabel) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expandedLabel)) + compose.onNodeWithText(shareLabel).assertIsEnabled().performClick() + compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_logs)).performClick() + compose + .onNodeWithContentDescription( + compose.activity.getString(R.string.chat_reply_failed_dismiss), + ).performClick() + compose.runOnIdle { + assertEquals("Provider failed\nAuthorization: [REDACTED]", shared) + assertEquals(1, logsOpened) + assertEquals(1, dismissed) + } + } + + @Test + fun expandedReviewResetsForANewFailure() { + val failure = androidx.compose.runtime.mutableStateOf(ReplyFailure("First failure")) + compose.setContent { + HermesControlTheme { + ReplyErrorCard( + failure = failure.value, + onDismiss = {}, + onOpenLogs = {}, + onCopy = {}, + onShare = {}, + ) + } + } + + val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details) + val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed) + val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share) + compose.onNodeWithText(detailsLabel).performClick() + compose.onNodeWithText(shareLabel).assertIsEnabled() + + compose.runOnIdle { failure.value = ReplyFailure("Second failure") } + + compose + .onNodeWithText(detailsLabel) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel)) + compose.onNodeWithText(shareLabel).assertIsNotEnabled() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt index cd0082c8..06ac9727 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt @@ -1,14 +1,24 @@ package com.m57.hermescontrol.ui.settings.components +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled 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.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -20,6 +30,25 @@ class ChatSectionTest { @get:Rule val composeTestRule = createComposeRule() + @Test + fun busySendDefaultDropdownRoutesSelection() { + var chosen: BusySendMode? = null + composeTestRule.setContent { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + busySendMode = BusySendMode.CORRECT, + onBusySendModeChange = { chosen = it }, + ) + } + composeTestRule.onNodeWithTag("busy_send_default").performClick() + val queueLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_busy_queue) + composeTestRule.onNodeWithText(queueLabel).performClick() + assertEquals(BusySendMode.QUEUE, chosen) + } + @Test fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() { composeTestRule.setContent { @@ -48,31 +77,47 @@ class ChatSectionTest { var assistantChanges = 0 var tpsChanges = 0 composeTestRule.setContent { - ChatSection( - typingEffectEnabled = false, - onTypingEffectEnabledChange = {}, - typingEffectDelayMs = 30, - onTypingEffectDelayMsChange = {}, - messageStatsEnabled = true, - onMessageStatsEnabledChange = { masterChanges++ }, - showUserMessageTokens = true, - onUserMessageTokensChange = { userChanges++ }, - showAssistantMessageTokens = true, - onAssistantMessageTokensChange = { assistantChanges++ }, - showTokensPerSecond = true, - onTokensPerSecondChange = { tpsChanges++ }, - ) + // PR #1254: match the real settings page's scrollable viewport. + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + messageStatsEnabled = true, + onMessageStatsEnabledChange = { masterChanges++ }, + showUserMessageTokens = true, + onUserMessageTokensChange = { userChanges++ }, + showAssistantMessageTokens = true, + onAssistantMessageTokensChange = { assistantChanges++ }, + showTokensPerSecond = true, + onTokensPerSecondChange = { tpsChanges++ }, + ) + } } - composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick() - composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick() - composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick() + composeTestRule + .onNodeWithTag("settings_user_message_tokens") + .performScrollTo() + .assertIsEnabled() + .performClick() + composeTestRule + .onNodeWithTag( + "settings_assistant_message_tokens", + ).performScrollTo() + .assertIsEnabled() + .performClick() + composeTestRule + .onNodeWithTag("settings_tps") + .performScrollTo() + .assertIsEnabled() + .performClick() assertEquals(0, masterChanges) assertEquals(1, userChanges) assertEquals(1, assistantChanges) assertEquals(1, tpsChanges) - composeTestRule.onNodeWithTag("settings_message_stats").performClick() + composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick() assertEquals(1, masterChanges) assertEquals(1, userChanges) assertEquals(1, assistantChanges) diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index f069698d..e81a38cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.config +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -22,6 +23,7 @@ data class ServerStoreState( val pinnedModels: List = emptyList(), val wsAuthParam: String = "token", val typingEffectEnabled: Boolean = true, + val busySendMode: BusySendMode = BusySendMode.CORRECT, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, val messageStatsEnabled: Boolean = false, diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index f8c0c98c..d8d0fc33 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration import com.m57.hermescontrol.data.config.resolvedBaseUrl import com.m57.hermescontrol.data.config.resolvedHost import com.m57.hermescontrol.data.config.resolvedPort +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager @@ -805,6 +806,12 @@ object AuthManager { fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled + fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode + + fun setBusySendMode(mode: BusySendMode) { + serverStore.update { it.copy(busySendMode = mode) } + } + fun setTypingEffectEnabled(enabled: Boolean) { serverStore.update { it.copy(typingEffectEnabled = enabled) } } 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 e4f6cc96..8901202b 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 @@ -86,4 +86,7 @@ interface ChatMessageDao { @Query("DELETE FROM chat_messages WHERE session_id = :sessionId") suspend fun deleteMessagesForSession(sessionId: String) + + @Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL") + suspend fun deleteUnconfirmedMessage(id: 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 0695140d..9b645e7a 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 @@ -54,6 +54,8 @@ data class ChatMessageEntity( val sortGroup: Int = 1, @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Long = 0, + @ColumnInfo(name = "message_provenance", defaultValue = "'UNKNOWN'") + val messageProvenance: String = "UNKNOWN", ) internal fun ChatMessageEntity.isSessionStartMarker(): Boolean = 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 97c39b5b..5be0dd2d 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.local import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageProvenance import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.ToolStatus @@ -40,6 +41,9 @@ fun ChatMessageEntity.toUiModel(): ChatMessage = completionId = completionId, restId = restId, localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 && !isSessionStartMarker() }, + messageProvenance = + MessageProvenance.entries.firstOrNull { it.name == messageProvenance } + ?: MessageProvenance.UNKNOWN, ) fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = @@ -59,4 +63,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = tps = tps, completionId = completionId, restId = restId, + messageProvenance = messageProvenance.name, ) 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 c30d2891..6464a569 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 = 9, + version = 10, exportSchema = true, ) abstract class HermesDatabase : RoomDatabase() { @@ -115,6 +115,17 @@ abstract class HermesDatabase : RoomDatabase() { } } + val MIGRATION_9_10: Migration = + object : Migration(9, 10) { + override suspend fun migrate(connection: SQLiteConnection) { + // v9 cannot prove whether a UUID-only row reached the server. Preserve that + // ambiguity; only new outgoing prompts record LOCAL_PENDING before submit. + connection.execSQL( + "ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'", + ) + } + } + suspend fun get(context: Context): HermesDatabase = withContext(Dispatchers.IO) { instance?.let { return@withContext it } @@ -152,6 +163,7 @@ abstract class HermesDatabase : RoomDatabase() { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ).fallbackToDestructiveMigration(false) .build() .also { instance = it } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt new file mode 100644 index 00000000..a260b2d0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/AudioTranscription.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Request body for `POST /api/audio/transcribe` — the dashboard's server-side + * transcription relay, the same endpoint the desktop client's voice path uses. + * + * The recording rides inline as a base64 `data:` URL (the desktop wire shape). + * [OkHttpProvider.json] carries [kotlinx.serialization.json.JsonNamingStrategy.SnakeCase], + * so [dataUrl]/[mimeType] serialize as `data_url`/`mime_type`. + */ +@Serializable +data class AudioTranscriptionRequest( + val dataUrl: String, + val mimeType: String, +) + +/** + * Response of `POST /api/audio/transcribe`. + * + * [transcript] is empty (not an error) when the server detects no speech in + * the recording. + */ +@Serializable +data class AudioTranscriptionResponse( + val ok: Boolean = false, + val transcript: String? = null, + val provider: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt new file mode 100644 index 00000000..44f6163e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/BusySendMode.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class BusySendMode { + CORRECT, + QUEUE, + GUIDE, + INTERRUPT, +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index e413181b..369d9a43 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -4,9 +4,11 @@ import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.data.local.AuthManager import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit /** * Provides a Retrofit-backed [HermesApiService]. @@ -22,6 +24,9 @@ object ApiClient { @Volatile private var service: HermesApiService? = null + @Volatile + private var okHttpClient: OkHttpClient? = null + /** The current [HermesApiService] instance. Lazily created on first access. */ val hermesApi: HermesApiService get() { @@ -39,6 +44,7 @@ object ApiClient { synchronized(this) { retrofit = null service = null + okHttpClient = null } } @@ -81,6 +87,64 @@ object ApiClient { return tempRetrofit.create(HermesApiService::class.java) } + /** + * Dedicated [HermesApiService] for the blocking voice-note transcription + * POST (`/api/audio/transcribe`). + * + * STT blocks until the profile's provider finishes, so it cannot run on + * the shared 30s client, and a re-submitted POST would repeat expensive + * provider work after the request may already have reached the server + * (the repository also passes `retries = 0`). The returned service reuses + * the shared connection pool and the auth/profile interceptor stack, and + * applies [readTimeoutMs] to reads and writes with transport retries + * disabled (review, PR #1250). + */ + fun transcriptionService(readTimeoutMs: Long): HermesApiService { + val client = + OkHttpProvider + .base + .newBuilder() + .addInterceptor(restAuthInterceptor()) + .addInterceptor(ProfileScopeInterceptor) + .authenticator(TokenRefreshAuthenticator) + .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + .retryOnConnectionFailure(false) + .build() + return Retrofit + .Builder() + .baseUrl(AuthManager.endpointForBuild().baseUrl) + .client(client) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .create(HermesApiService::class.java) + } + + /** + * REST auth stamping shared by every client: token mode sends the bearer + * header; gated (basic-auth cookie) mode leaves the request alone because + * the shared CookieManager jar carries the session cookie and a stray + * Authorization header would 401 (issue #470). + */ + private fun restAuthInterceptor(): Interceptor = + Interceptor { chain -> + val request = chain.request() + if (AuthManager.isGatedMode()) { + return@Interceptor chain.proceed(request) + } + val token = AuthManager.getToken() + if (!token.isNullOrBlank()) { + chain.proceed( + request + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .build(), + ) + } else { + chain.proceed(request) + } + } + // ── Internal ───────────────────────────────────────────────────────── private fun buildService(): HermesApiService = @@ -111,25 +175,7 @@ object ApiClient { // must NOT stamp one here — otherwise every REST tab (skills, cron, // config, ...) fails with "token expired" while the WS chat, which // authenticates via ?ticket=, keeps working. - val authInterceptor = - Interceptor { chain -> - val request = chain.request() - if (AuthManager.isGatedMode()) { - // Cookie in the shared jar is the only valid REST credential. - return@Interceptor chain.proceed(request) - } - val token = AuthManager.getToken() - if (!token.isNullOrBlank()) { - chain.proceed( - request - .newBuilder() - .addHeader("Authorization", "Bearer $token") - .build(), - ) - } else { - chain.proceed(request) - } - } + val authInterceptor = restAuthInterceptor() val okHttp = OkHttpProvider 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 bbaf3811..55e6cddf 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 @@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.AddMcpServerRequest import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AnalyticsResponse +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.BulkDeleteRequest @@ -1030,4 +1032,14 @@ interface HermesApiService : KanbanApiService { suspend fun deleteManagedFile( @Body body: ManagedFileDelete, ): Response + + // ── Audio: server-side transcription ──────────────────────────────── + // The dashboard's voice relay (hermes_cli/web_routers/audio.py) — the + // same endpoint the desktop client's voice notes use. The clip rides + // inline as a base64 data URL; the server resolves STT through the + // active profile's configured provider. + @POST("api/audio/transcribe") + suspend fun transcribeAudio( + @Body body: AudioTranscriptionRequest, + ): Response } 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 497a5a63..3c407a83 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 @@ -26,6 +26,13 @@ object ProfileScopeInterceptor : Interceptor { private val PROFILE_SCOPED_PREFIXES = listOf( "/api/analytics", + // Voice-note transcription resolves STT through the profile's + // configured provider, so the request must carry the active + // profile (the desktop sends this route `...profileScoped()`; + // hermes_cli/web_routers/audio.py is config-scoped). Unscoped, a + // multi-profile host transcribes under the launch profile + // (review, PR #1250). + "/api/audio", "/api/config", "/api/credentials/pool", "/api/cron", diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt new file mode 100644 index 00000000..3dabae8a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/VoiceNoteRepository.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.NetworkError +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import java.io.File +import java.io.IOException +import java.util.Base64 + +/** + * MIME type of the recorder's output: MPEG-4 container with AAC audio — see + * `VoiceNoteRecorder` in the chat UI layer. + */ +const val VOICE_NOTE_MIME_TYPE = "audio/mp4" + +/** + * Transcribes a recorded voice note through the dashboard's server-side + * transcription endpoint (`POST /api/audio/transcribe`). + * + * The wire shape is the desktop client's: base64 `data:` URL plus MIME type. + * The server resolves STT through the active profile's configured provider + * (the request is profile-scoped by ProfileScopeInterceptor, matching the + * desktop's `...profileScoped()` on this route), so the transcript matches + * the rest of Hermes instead of the phone's on-device recognizer. + * + * Two contracts differ from a plain REST call (review, PR #1250): + * - [serviceProvider] resolves the Retrofit service at CALL time, so a + * connection/profile switch (ApiClient.rebuild) applies to the next + * transcription instead of pinning the construction-time service. + * - The STT POST runs on a dedicated long-timeout client with `retries = 0`: + * re-submitting after a timeout or 5xx would repeat expensive provider + * work although the request may already have reached the server. + * + * Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern). + */ +open class VoiceNoteRepository( + private val serviceProvider: (timeoutMs: Long) -> HermesApiService = { timeoutMs -> + ApiClient.transcriptionService(timeoutMs) + }, +) { + open suspend fun transcribe(file: File): NetworkResult { + val encoded = + try { + file.readBytes() + } catch (e: IOException) { + // A local read failure must return a Failure instead of + // escaping as an exception — the caller resets its + // transcription state from this result (review, PR #1250). + return NetworkResult.Failure( + NetworkError.Unknown("Failed to read the voice note: ${e.message}", e), + ) + } + val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded) + val result = + safeApiCall(retries = 0) { + serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio( + AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE), + ) + } + return when (result) { + is NetworkResult.Success -> { + NetworkResult.Success( + result.data + ?.transcript + .orEmpty() + .trim(), + ) + } + + is NetworkResult.Failure -> { + result + } + } + } + + companion object { + /** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */ + const val MIN_REQUEST_TIMEOUT_MS = 180_000L + + /** Cap so a hung transcription cannot pin the call forever — desktop parity. */ + const val MAX_REQUEST_TIMEOUT_MS = 600_000L + + private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L + + /** + * Timeout for the blocking STT endpoint, mirroring the desktop's + * `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char + * keeps short clips at the floor while multi-minute recordings scale + * toward the cap. Integer ceil() keeps the arithmetic exact. + */ + fun transcriptionTimeoutMs(dataUrl: String): Long = + ((dataUrl.length + BASE64_CHARS_PER_TIMEOUT_MS - 1) / BASE64_CHARS_PER_TIMEOUT_MS) + .coerceIn(MIN_REQUEST_TIMEOUT_MS, MAX_REQUEST_TIMEOUT_MS) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index ab3fa379..1098e7be 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -775,6 +775,7 @@ object HermesWsClient { val method: String, val deferred: CompletableDeferred, var timeoutJob: Job? = null, + val suppressErrorEvent: Boolean = false, ) /** Tracks in-flight [request] calls by their JSON-RPC id. */ @@ -794,11 +795,12 @@ object HermesWsClient { method: String, params: Map = emptyMap(), timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, ): CompletableDeferred { val deferred = CompletableDeferred() val id = send(method, params) { reqId -> - pendingCalls[reqId] = PendingCall(method, deferred) + pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent) } deferred.invokeOnCompletion { cause -> if (cause is CancellationException) { @@ -1458,6 +1460,16 @@ object HermesWsClient { disconnectIfIdleInBackground() } } + if (pendingCalls[event.id]?.suppressErrorEvent == true) { + // Opt-in suppression: the caller already handles the + // failure through the CompletableDeferred, so the event + // copy would only surface a duplicate UI banner for + // optional features (e.g. "subagent.list" on gateways + // without the method, issue #1089). Default request() + // behavior still emits the event for shared consumers. + resolvePending(event.id, null, event.error) + return + } removeQueuedMessage(event.id) resolvePending(event.id, null, event.error) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt index 45a9ac37..1cda13ec 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SubagentRepository.kt @@ -37,6 +37,7 @@ object SubagentRepository { .request( WsMethods.SUBAGENT_LIST, mapOf("session_id" to sessionId), + suppressErrorEvent = true, ).await() decode(result) } catch (e: Exception) { @@ -65,7 +66,13 @@ object SubagentRepository { if (sessionId.isNotBlank()) { params["session_id"] = sessionId } - val result = HermesWsClient.request(WsMethods.SUBAGENT_TAIL, params).await() + val result = + HermesWsClient + .request( + WsMethods.SUBAGENT_TAIL, + params, + suppressErrorEvent = true, + ).await() return decode(result) } 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 f4e79a7f..cdbf2e51 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 @@ -11,6 +11,7 @@ object WsMethods { const val SESSION_CREATE = "session.create" const val SESSION_INTERRUPT = "session.interrupt" const val SESSION_REDIRECT = "session.redirect" + const val SESSION_STEER = "session.steer" const val SESSION_DELETE = "session.delete" const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 831aa535..48342326 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.ui.chat.replyFailureFromPayload import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean +internal data class MessageCompleteNotificationPlan( + val text: String, + val sessionId: String?, + val isReplyMessage: Boolean, + val completionId: String?, + val correlationText: String?, + val allowInlineReply: Boolean, +) + +internal fun messageCompleteNotificationPlan( + event: WsEvent.MessageComplete, + targetSessionId: String?, + newMessageText: String, + failureText: String, +): MessageCompleteNotificationPlan { + if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) { + return MessageCompleteNotificationPlan( + text = failureText, + sessionId = targetSessionId, + isReplyMessage = false, + completionId = null, + correlationText = null, + allowInlineReply = false, + ) + } + + return MessageCompleteNotificationPlan( + text = + event.text + .take(100) + .replace("\n", " ") + .ifBlank { newMessageText }, + sessionId = targetSessionId, + isReplyMessage = true, + completionId = event.completionId, + correlationText = event.text, + allowInlineReply = true, + ) +} + /** * Foreground service that keeps the WebSocket connection alive while the app * is backgrounded and posts a notification when a new assistant reply @@ -111,19 +152,22 @@ class ChatNotificationService : Service() { if (!isAppInForeground.get()) { when (event) { is WsEvent.MessageComplete -> { - val preview = - event.text - .take(100) - .replace("\n", " ") - .ifBlank { getString(R.string.notif_new_message) } val targetSessionId = event.storedSessionId ?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId) + val plan = + messageCompleteNotificationPlan( + event = event, + targetSessionId = targetSessionId, + newMessageText = getString(R.string.notif_new_message), + failureText = getString(R.string.chat_reply_failed_title), + ) showReplyNotification( - text = preview, - sessionId = targetSessionId, - isReplyMessage = true, - completionId = event.completionId, + text = plan.text, + sessionId = plan.sessionId, + isReplyMessage = plan.isReplyMessage, + completionId = plan.completionId, + allowInlineReply = plan.allowInlineReply, // The durable REST row for this turn, when the // boundary armed before the prompt was submitted // still lets us name it unambiguously. Null is a @@ -132,7 +176,9 @@ class ChatNotificationService : Service() { // is strictly better than dismissing the wrong // duplicate reply. serverMessageId = - coalesceTurnRow(targetSessionId, event.text), + plan.correlationText?.let { + coalesceTurnRow(plan.sessionId, it) + }, ) // The wait is over — retire the foreground // service. The reply notification above @@ -216,6 +262,7 @@ class ChatNotificationService : Service() { isReplyMessage: Boolean = false, completionId: String? = null, serverMessageId: Int? = null, + allowInlineReply: Boolean = true, ) { val builder = NotificationCompat @@ -262,7 +309,7 @@ class ChatNotificationService : Service() { ) } - if (!sessionId.isNullOrBlank()) { + if (allowInlineReply && !sessionId.isNullOrBlank()) { val replyLabel = getString(R.string.notif_reply_placeholder) val remoteInput = RemoteInput diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt new file mode 100644 index 00000000..99a4d91e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/BusySendModeLabel.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode + +@Composable +fun BusySendMode.label(): String = + when (this) { + BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct) + BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue) + BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide) + BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt) + } 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 f65c6a6d..4a33fb46 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 @@ -35,6 +35,12 @@ data class ToolOutputRiskData( val redacted: Boolean, ) +/** Durable evidence about a locally-created row; UNKNOWN must not be treated as proof of delivery. */ +enum class MessageProvenance { + UNKNOWN, + LOCAL_PENDING, +} + data class ChatMessage( val id: String = UUID.randomUUID().toString(), val role: MessageRole, @@ -91,12 +97,26 @@ data class ChatMessage( val restId: String? = null, /** Cache insertion sequence for unconfirmed local rows; null before first persistence. */ val localOrder: Long? = null, + /** Persisted before prompt submission so process death cannot turn an unsent prompt into old history. */ + val messageProvenance: MessageProvenance = MessageProvenance.UNKNOWN, + /** Read from history, not observed live in this view. Never persisted as delivery state. */ + val isHistoricalCache: Boolean = false, + /** + * Legacy USER restored without a canonical identity or a current send receipt. + * Placement only: keep it outside the live tail without asserting delivery. + * Transient; UNKNOWN provenance and the persisted row remain unchanged. + */ + val isRestoredUnconfirmed: Boolean = false, ) /** 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-") } +/** A persisted RUNNING snapshot is not evidence of current tool activity. */ +internal val ChatMessage.isToolRunning: Boolean + get() = toolStatus == ToolStatus.RUNNING && !isHistoricalCache + /** * 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 da1b4537..89c5e210 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 @@ -84,4 +84,8 @@ open class ChatPersistenceRepository( suspend fun clearMessagesForSession(sessionId: String) { daoProvider().deleteMessagesForSession(sessionId) } + + suspend fun deleteMessage(id: String) { + daoProvider().deleteUnconfirmedMessage(id) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt new file mode 100644 index 00000000..c95b505d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentation.kt @@ -0,0 +1,17 @@ +package com.m57.hermescontrol.ui.chat + +/** Presentation only: retain queued messages internally for persistence and receipt matching. */ +internal fun messagesWithoutUnsentQueue( + messages: List, + pendingSends: List, +): List { + val waitingIds = + pendingSends + .filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED } + .map { it.id } + .toSet() + if (waitingIds.isEmpty()) return messages + return messages.filterNot { + it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null + } +} 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 65e30861..ee9d91da 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 @@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -76,12 +78,15 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.HistoryScreen +import com.m57.hermescontrol.LogsScreen 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.BusySendMode import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient @@ -102,6 +107,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog +import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard import com.m57.hermescontrol.ui.chat.components.SearchBarRow import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet @@ -167,7 +173,11 @@ 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 sourceMessages = timelineState.historyMessages ?: state.messages + val displayedMessages = + remember(sourceMessages, state.pendingSends) { + messagesWithoutUnsentQueue(sourceMessages, state.pendingSends) + } val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -190,6 +200,7 @@ fun ChatScreen( } Lifecycle.Event.ON_RESUME -> { + viewModel.refreshSettings() connectorsViewModel.onResume() val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } @@ -309,7 +320,7 @@ fun ChatScreen( // streaming tail. LaunchedEffect( timelineState.isHistorical, - state.messages, + displayedMessages, streamingState.streamingMessage, streamingState.isThinking, state.subagentIndicators, @@ -323,13 +334,13 @@ fun ChatScreen( scrollController.onTailChanged( tailKey = tailContentKey( - messages = state.messages, + messages = displayedMessages, streamingMessage = streamingState.streamingMessage, isThinking = streamingState.isThinking, subagentIndicators = state.subagentIndicators, clarifyRequest = state.clarifyRequest, ), - messageCount = state.messages.size, + messageCount = displayedMessages.size, ) } LaunchedEffect(timelineState.historyAnchorRowId) { @@ -429,12 +440,16 @@ fun ChatScreen( onAddAttachments = { attachments -> viewModel.addAttachments(attachments) }, + onVoiceNoteRecorded = { file -> + viewModel.sendVoiceNote(file) + }, onShowMessage = { msg -> scrollScope.launch { snackbarHostState.showSnackbar(msg) } }, launchExternalActivity = launchExternalActivity, + isTranscribingVoiceNote = state.isTranscribingVoiceNote, context = context, ) @@ -784,6 +799,39 @@ fun ChatScreen( savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, onImageClick = { viewingImage = it }, + replyErrorContent = + state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> + { + val clipboard = LocalClipboardManager.current + val copiedMessage = stringResource(R.string.chat_reply_failed_copied) + val shareTitle = stringResource(R.string.chat_reply_failed_share) + val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable) + ReplyErrorCard( + failure = failure, + onDismiss = { viewModel.dismissReplyFailure(failure.id) }, + onOpenLogs = { NavigationController.navigateTo(LogsScreen) }, + onCopy = { details -> + clipboard.setText(AnnotatedString(details)) + scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) } + }, + onShare = { details -> + val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details" + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, report) + } + try { + launchExternalActivity { + context.startActivity(Intent.createChooser(intent, shareTitle)) + } + } catch (_: ActivityNotFoundException) { + scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) } + } + }, + ) + } + }, ) // Loading overlay @@ -834,6 +882,12 @@ fun ChatScreen( }, ) + com.m57.hermescontrol.ui.chat.components.PendingSendPanel( + sends = state.pendingSends, + mainTurnBusy = state.isMainTurnBusy, + onSendNow = viewModel::sendQueuedNow, + ) + ChatInputBar( inputFieldValue = inputFieldValue, onInputChange = { inputFieldValue = it }, @@ -844,9 +898,23 @@ fun ChatScreen( scrollController.jumpToBottom(animated = true) } }, + onBusySend = { mode -> + if (viewModel.sendMessage(inputFieldValue.text, mode)) { + inputFieldValue = TextFieldValue("") + scrollController.jumpToBottom(animated = true) + } + }, onMicTap = mediaLaunchers.onMicTap, - isListening = mediaLaunchers.isListening, + onMicHoldStart = mediaLaunchers.onMicHoldStart, + onMicHoldEnd = mediaLaunchers.onMicHoldEnd, + onMicHoldCancel = mediaLaunchers.onMicHoldCancel, + isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, + isRecordingVoice = mediaLaunchers.isRecordingVoice, + voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude, + onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, + isMainTurnBusy = state.isMainTurnBusy, + canInterrupt = state.canInterrupt, isConnected = state.isConnected, isSessionReady = state.isSessionReady && !timelineState.isHistorical, sessionPreparationFailed = state.resumeError != null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt new file mode 100644 index 00000000..a4a9263e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSendStore.kt @@ -0,0 +1,149 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.Context +import android.content.SharedPreferences +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */ +@Serializable +data class PendingSend( + val id: String, + val scope: String, + val sessionId: String, + val text: String, + val attachments: List = emptyList(), + val mode: BusySendMode, + val state: PendingSendState = PendingSendState.QUEUED, + val createdAt: Long = System.currentTimeMillis(), + val attempts: Int = 0, + /** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */ + val requiresAttachmentRecovery: Boolean = false, +) + +@Serializable +enum class PendingSendState { + QUEUED, + PARKED, + SENDING, + ACCEPTED, + UNKNOWN, + REJECTED, +} + +/** + * Reconcile only identities that the transcript merge already mapped to a durable REST row. + * Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and + * consumes duplicate occurrences once; repeating that matching against receipts can acknowledge + * more sends than the history page actually contains. + */ +internal fun pendingSendIdsConfirmedByDurableAliases( + confirmedAliases: List, + pending: List, +): Set { + val durableUserAliasIds = + confirmedAliases + .asSequence() + .filter { it.role == MessageRole.USER && it.canonicalRestId != null } + .map { it.id } + .toSet() + return pending + .asSequence() + .filter { + it.state in + setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.map { it.id } + .filter { it in durableUserAliasIds } + .toSet() +} + +/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */ +class ChatSendStore( + private val prefs: SharedPreferences? = null, +) { + constructor(context: Context) : this( + context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE), + ) + + private val json = Json { ignoreUnknownKeys = true } + private var rows: List = + prefs?.getString("rows", null)?.let { raw -> + runCatching { json.decodeFromString>(raw) }.getOrDefault(emptyList()) + } ?: emptyList() + + init { + // A request may have reached the gateway before process death. Never replay it automatically. + replace( + rows.map { + it.quarantineLegacyAttachments().let { quarantined -> + if (quarantined.state == PendingSendState.SENDING || + quarantined.state == PendingSendState.ACCEPTED + ) { + quarantined.copy(state = PendingSendState.UNKNOWN) + } else { + quarantined + } + } + }, + ) + } + + private fun PendingSend.quarantineLegacyAttachments(): PendingSend = + if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) { + copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true) + } else { + this + } + + @Synchronized + fun all(): List = rows + + @Synchronized + fun put(row: PendingSend) { + replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments()) + } + + @Synchronized + fun remove(id: String) { + replace(rows.filterNot { it.id == id }) + } + + @Synchronized + fun update( + id: String, + transform: (PendingSend) -> PendingSend, + ) { + replace(rows.map { if (it.id == id) transform(it) else it }) + } + + @Synchronized + fun promote(id: String) { + val row = rows.firstOrNull { it.id == id } ?: return + replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id }) + } + + @Synchronized + fun park( + scope: String, + sessionId: String, + ) { + replace( + rows.map { + if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) { + it.copy(state = PendingSendState.PARKED) + } else { + it + } + }, + ) + } + + private fun replace(next: List) { + if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) { + error("Could not save chat send queue") + } + rows = next + } +} 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 59327ce1..79a9972b 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 @@ -49,6 +49,7 @@ internal fun mapServerMessages( } val wsCompletionIdByRestIndex = mutableMapOf() + val verifierContentByRestIndex = 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. @@ -89,6 +90,27 @@ internal fun mapServerMessages( remainingWs.removeAt(wsIdx).completionId?.let { wsCompletionIdByRestIndex[i] = it } } } + // Fallback: match live assistant containing a file-mutation verifier footer against + // its footer-free REST counterpart (issue #1241). + 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.displayContentText ?: m.contentText + if (rawContent.isBlank()) continue + val canonicalContent = + if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim() + val wsIdx = remainingWs.indexOfLast { ChatVerifierFooter.matchesBase(it.content, canonicalContent) } + if (wsIdx >= 0) { + val matchedWs = remainingWs.removeAt(wsIdx) + matchedWs.completionId?.let { wsCompletionIdByRestIndex[i] = it } + // Propagate the richer live footer content to the REST row so canonical mapping keeps it + if (ChatVerifierFooter.split(matchedWs.content) != null) { + verifierContentByRestIndex[i] = matchedWs.content + } + } + } } // Reasoning follows the same reserved identities as completions, never a reusable text lookup. @@ -119,6 +141,19 @@ internal fun mapServerMessages( val match = remaining.indexOfLast { it.content.trim() == content } if (match >= 0) reasoningSources[index] = remaining.removeAt(match) } + for (index in messages.indices.reversed()) { + val row = messages[index] + 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(displayContent).trim() + val match = remaining.indexOfLast { ChatVerifierFooter.matchesBase(it.content, content) } + if (match >= 0) reasoningSources[index] = remaining.removeAt(match) + } } val mapped = mutableListOf() @@ -166,6 +201,8 @@ internal fun mapServerMessages( var finalContent = if (role == MessageRole.USER) { stripGatewayAttachedContext(stripGatewaySteerWrapper(rawContent)) + } else if (role == MessageRole.ASSISTANT && verifierContentByRestIndex.containsKey(index)) { + verifierContentByRestIndex[index] ?: rawContent } else { rawContent } @@ -227,6 +264,17 @@ internal fun mapServerMessages( 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 + val local = toolsById[message.id] + if (local?.isHistoricalCache == true && local.toolStatus == ToolStatus.RUNNING) { + // A canonical tool-result row settles a cached tool.start, not the reverse. + local.copy( + restId = message.canonicalRestId, + content = message.content, + toolStatus = ToolStatus.COMPLETED, + isHistoricalCache = false, + ) + } else { + local?.copy(restId = message.canonicalRestId) ?: message + } } } 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 cede6a9c..474b164b 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 @@ -185,6 +185,14 @@ internal class TranscriptComparison( val tb = trimmed.getOrPut(b.content) { b.content.trim() } if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false if (ta == tb) return true + if (a.role == MessageRole.ASSISTANT && + (a.completionId != null || b.completionId != null) && + a.completionId == b.completionId && + ta.isNotBlank() && tb.isNotBlank() && + ChatVerifierFooter.matchesBase(ta, tb) + ) { + return true + } if (a.role == MessageRole.USER && captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == captions.getOrPut(tb) { stripAttachmentRefLines(tb) } @@ -250,7 +258,17 @@ internal fun matchTranscriptMessages( ) && // Distinct IDs within the same source are separate occurrences, not echoes. ((message.canonicalRestId == null) != (other.canonicalRestId == null)) && - comparison.same(message, other) + ( + comparison.same(message, other) || + ( + allowAssistantContentMatches && + message.role == MessageRole.ASSISTANT && + message.content.isNotBlank() && other.content.isNotBlank() && + message.completionId != null && + message.completionId == other.completionId && + ChatVerifierFooter.matchesBase(message.content, other.content) + ) + ) } if (match != null) { used[match] = true @@ -317,6 +335,7 @@ internal fun dedupeCachedMessages( restId = it.canonicalRestId, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, + isRestoredUnconfirmed = false, ) } ?: message } @@ -341,13 +360,53 @@ internal fun mergeCachedTranscriptPage( 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 + val preservedContent = + if (match.role == MessageRole.ASSISTANT && + ChatVerifierFooter.split(match.content) != null && + ChatVerifierFooter.matchesBase(match.content, message.content) + ) { + match.content + } else if (message.role == MessageRole.ASSISTANT && + ChatVerifierFooter.split(message.content) != null && + ChatVerifierFooter.matchesBase(message.content, match.content) + ) { + message.content + } else { + null + } + val rich = + when { + // A late historical snapshot may contribute safe metadata, but it must + // never replace a canonical server payload/status already on screen. + message.isHistoricalCache && match.canonicalRestId != null -> { + match.copy( + toolName = match.toolName ?: message.toolName, + toolCallId = match.toolCallId.ifBlank { message.toolCallId }, + attachments = match.attachments ?: message.attachments, + finishTimestamp = match.finishTimestamp ?: message.finishTimestamp, + tokenCount = match.tokenCount ?: message.tokenCount, + tps = match.tps ?: message.tps, + ) + } + + match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> { + message + } + + else -> { + match + } + } match.id to rich.copy( id = match.id, - restId = message.canonicalRestId ?: match.canonicalRestId, + content = preservedContent ?: rich.content, + restId = match.canonicalRestId ?: message.canonicalRestId, completionId = match.completionId ?: message.completionId, - displayKind = message.displayKind ?: match.displayKind, + displayKind = match.displayKind ?: message.displayKind, + isRestoredUnconfirmed = + match.isRestoredUnconfirmed && message.isRestoredUnconfirmed && + match.canonicalRestId == null && message.canonicalRestId == null, ) }.toMap() val resolvedOrders = @@ -381,13 +440,24 @@ internal fun mergeTranscriptWithLive( match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, displayKind = message.displayKind ?: match.displayKind, + isHistoricalCache = false, + isRestoredUnconfirmed = false, ) } preserveLiveIds && match != null && !match.id.startsWith("rest-") -> { + val mergedContent = + if (match.role == MessageRole.ASSISTANT && + ChatVerifierFooter.split(match.content) != null && + ChatVerifierFooter.matchesBase(match.content, message.content) + ) { + match.content + } else { + message.content + } match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, - content = message.content, + content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, reasoningText = message.reasoningText.ifBlank { match.reasoningText }, @@ -398,11 +468,28 @@ internal fun mergeTranscriptWithLive( displayKind = message.displayKind ?: match.displayKind, tokenCount = message.tokenCount ?: match.tokenCount, completionId = message.completionId ?: match.completionId, + isHistoricalCache = false, + isRestoredUnconfirmed = false, ) } else -> { - message.copy(completionId = message.completionId ?: match?.completionId) + val mergedContent = + if (message.role == MessageRole.ASSISTANT && + match != null && + ChatVerifierFooter.split(match.content) != null && + ChatVerifierFooter.matchesBase(match.content, message.content) + ) { + match.content + } else { + message.content + } + message.copy( + content = mergedContent, + completionId = message.completionId ?: match?.completionId, + isHistoricalCache = false, + isRestoredUnconfirmed = false, + ) } } } @@ -431,6 +518,8 @@ private fun List.inTranscriptOrder( resolvedOrders: Map, ): List { val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L + // Session-start markers use -1 and must remain before unresolved cached history. + val beforeCanonical = (mapNotNull { it.canonicalOrder }.filter { it >= 0L }.minOrNull() ?: 0L) - 1L var precedingCanonical: Long? = null var hasPendingPredecessor = false var pendingLocalOrder: Long? = null @@ -448,9 +537,18 @@ private fun List.inTranscriptOrder( Long.MAX_VALUE } else { precedingCanonical?.takeIf { it >= 0L } - ?: latestCanonical + ?: if (message.role == MessageRole.USER) { + precedingCanonical ?: latestCanonical + } else { + latestCanonical + } } if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE + } else if (message.isHistoricalCache || message.isRestoredUnconfirmed) { + // Room groups UUID-only rows after all confirmed rows; that predecessor is + // not a chronological anchor. Restored legacy rows stay before the server + // window too, without promoting their uncertain delivery to confirmed history. + localAnchors[message.id] = beforeCanonical } else { hasPendingPredecessor = true pendingLocalOrder = message.localOrder diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt new file mode 100644 index 00000000..3651c7b1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooter.kt @@ -0,0 +1,80 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Utility to recognize and extract File-mutation verifier footers appended to + * assistant responses on live channels. + * + * Pattern emitted by backend `turn_explainers._format_file_mutation_failure_footer`: + * ⚠️ File-mutation verifier: N file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed. + * • `path` — [tool] error_preview + * • … and N more + */ +internal object ChatVerifierFooter { + private const val HEADER_PREFIX = "⚠️ File-mutation verifier:" + private const val HEADER_NO_EMOJI_PREFIX = "File-mutation verifier:" + + data class SplitResult( + val body: String, + val footer: String, + ) + + /** + * Splits [content] into (body, footer) if it cleanly ends with a recognized + * File-mutation verifier block. If not, returns null. + */ + fun split(content: String): SplitResult? { + val trimmed = content.trimEnd() + if (trimmed.isBlank()) return null + + val headerIdx = + trimmed.lastIndexOf(HEADER_PREFIX).let { idx -> + if (idx >= 0) idx else trimmed.lastIndexOf(HEADER_NO_EMOJI_PREFIX) + } + if (headerIdx <= 0) return null + + // Must be preceded by a newline (standalone block) + val charBefore = trimmed[headerIdx - 1] + if (charBefore != '\n') return null + + val headerAndRest = trimmed.substring(headerIdx) + val lines = headerAndRest.lines() + val firstLine = lines.first().trim() + + // Verify first line structure: "⚠️ File-mutation verifier: ... FAILED this turn..." + if (!firstLine.contains("file edit(s) FAILED this turn") && + !firstLine.contains("file(s) were NOT modified this turn") + ) { + return null + } + + // Remaining lines must be bullet items or continuation lines + for (i in 1 until lines.size) { + val line = lines[i] + if (line.isBlank()) continue + val trimmedLine = line.trimStart() + // Bullets typically start with • + if (!trimmedLine.startsWith("•") && !trimmedLine.startsWith("-") && !line.startsWith(" ")) { + return null + } + } + + val body = trimmed.substring(0, headerIdx).trimEnd() + if (body.isBlank()) return null + + val footer = trimmed.substring(headerIdx) + return SplitResult(body = body, footer = footer) + } + + /** + * Returns true if [a] and [b] have the same underlying text once any + * trailing file-mutation verifier footer is removed. + */ + fun matchesBase( + a: String, + b: String, + ): Boolean { + val cleanA = split(a)?.body ?: a.trim() + val cleanB = split(b)?.body ?: b.trim() + return cleanA == cleanB + } +} 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 8c8ad8a5..a46daaab 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 @@ -6,9 +6,11 @@ import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -21,13 +23,16 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.repository.VoiceNoteRepository 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.EventParser import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toAny @@ -64,6 +69,11 @@ 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 const val VOICE_NOTE_OFFLINE_MESSAGE = "Voice note not sent — not connected" +private const val VOICE_NOTE_EMPTY_MESSAGE = "No speech detected in the voice note" +private const val VOICE_NOTE_FAILED_MESSAGE = "Voice note transcription failed" +private const val VOICE_NOTE_UNSENT_MESSAGE = + "Voice note not sent — transcript kept in the input field" private val REASONING_EFFORT_LEVELS = setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") @@ -106,6 +116,10 @@ private data class PreparedAttachment( val encodedFile: File, ) +private class QueuedAttachmentTooLargeException( + val attachment: Attachment, +) : Exception("Attachment too large: ${attachment.name}") + private sealed interface PrepareAttachmentResult { data class Success( val prepared: PreparedAttachment, @@ -123,8 +137,14 @@ data class ChatUiState( val chatTitle: String = "Hermes", val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED, val isAgentTyping: Boolean = false, + val isMainTurnBusy: Boolean = false, + val isSending: Boolean = false, + val busySendMode: BusySendMode = BusySendMode.CORRECT, + val pendingSends: List = emptyList(), val isThinking: Boolean = false, val thinkingText: String = "", + /** True while a recorded voice note uploads for server-side transcription. */ + val isTranscribingVoiceNote: Boolean = false, val isLoading: Boolean = false, val isLoadingOlder: Boolean = false, val hasOlderMessages: Boolean = false, @@ -137,6 +157,10 @@ data class ChatUiState( /** Standalone streaming message — rendered after the main list. */ val streamingMessage: ChatMessage? = null, val errorMessage: String? = null, + /** Persistent until dismissed/new turn; distinct from one-shot RPC/network snackbars. */ + val replyFailure: ReplyFailure? = null, + /** Identity of the single non-durable failed assistant projection for this session lifecycle. */ + val replyFailureProjection: ReplyFailureProjection? = null, // Background job completion toast (issue #527) — non-blocking snackbar val backgroundCompleteMessage: String? = null, // Attachment feedback — surfaced as a non-blocking snackbar (issue #724) @@ -241,6 +265,15 @@ data class ChatUiState( ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED + + /** + * True only while a generation can actually be interrupted: typing can + * start during session preparation, before any runtime session exists, + * and `session.interrupt` has nothing to stop then. The composer derives + * its Stop affordance from this instead of [isAgentTyping] alone + * (review, PR #1250). + */ + val canInterrupt: Boolean get() = isAgentTyping && isSessionReady } data class ChatTimelineState( @@ -399,6 +432,8 @@ class ChatViewModel( 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, + private val sendStore: ChatSendStore = ChatSendStore(application), + private val voiceNoteRepository: VoiceNoteRepository = VoiceNoteRepository(), ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -423,8 +458,53 @@ class ChatViewModel( val connectionCheckpoint: ConnectionResumeCheckpoint? = null, ) + private data class SendOwner( + val scope: String, + val storageSessionId: String, + val agentSessionId: String, + val generation: Long, + ) + + private data class HardInterruptRequest( + val messageId: String, + val mainTurnEpoch: Long, + val owner: SendOwner, + ) + + private data class PendingSendReservation( + val pending: PendingSend, + val userMessage: ChatMessage, + val wasStreaming: Boolean, + val storageSessionId: String, + val agentSessionId: String, + val scope: String, + val generation: Long, + val mainTurnEpoch: Long, + val originalPending: PendingSend? = null, + ) + private val sessionRequestById = ConcurrentHashMap() + private val outgoingRequestById = ConcurrentHashMap() + private val hardInterruptRequestById = ConcurrentHashMap() + private val queuedStagingIds = ConcurrentHashMap.newKeySet() + private val pendingSendReservationLock = Any() + private val pendingSendReservations = mutableListOf() + private var pendingSendReservationJob: Job? = null + private var queueDrainJob: Job? = null + private var mainTurnBusy = false + set(busy) { + field = busy + _uiState.update { state -> + if (state.isMainTurnBusy == busy) state else state.copy(isMainTurnBusy = busy) + } + } + + private var lastMainCompletionAt = 0L + private var lastSubmissionKey: String? = null + private var lastSubmissionAt = 0L private var sessionGeneration = 0L + private var mainTurnEpoch = 0L + private var lastPendingSendCreatedAt = 0L private var resumeRequestSequence = 0L private var activeResumeRequestSequence = 0L @@ -442,6 +522,8 @@ class ChatViewModel( val attachments: List, val wasStreaming: Boolean, val userMessage: ChatMessage, + val createdAt: Long, + val mainTurnEpoch: Long, ) private var pendingInitialPrompt: PendingPrompt? = null @@ -676,6 +758,27 @@ class ChatViewModel( status == ConnectionStatus.NO_NETWORK || status == ConnectionStatus.AUTH_EXPIRED ) { + mainTurnBusy = false + // A gateway acceptance is not a durable REST acknowledgement. Recover even + // receipts whose RPC correlation was already removed, without resending them. + val acceptedIds = + sendStore + .all() + .filter { + it.scope == sendScope() && it.state == PendingSendState.ACCEPTED + }.map { it.id } + (outgoingRequestById.values + hardInterruptRequestById.values.map { it.messageId } + acceptedIds) + .distinct() + .forEach { messageId -> + if (sendStore.all().any { + it.id == messageId && + (it.state == PendingSendState.SENDING || it.state == PendingSendState.ACCEPTED) + } + ) { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } _uiState.update { it.copy(isLoading = false) } subagentsDelegate.closeSubagentTranscript() // The runtime session id is only valid while the socket @@ -691,6 +794,8 @@ class ChatViewModel( activeHydrationRequestSequence = ++hydrationRequestSequence // Preserve metadata so late results and errors are rejected as stale. sessionGeneration++ + queueDrainJob?.cancel() + queueDrainJob = null // #129: invalidate and cancel paged work together, including its busy flags. hydrationJob?.cancel() olderJob?.cancel() @@ -842,10 +947,29 @@ class ChatViewModel( } private fun handleWsEvent(event: WsEvent) { + // Filter explicitly scoped terminal/start events before buffer resets and reduction. + // Legacy unscoped failures are ambiguous across sessions, so fail closed: gateways + // must include session_id for failed replies to be surfaced by this client. + val turnSessionId = + when (event) { + is WsEvent.MessageStart -> event.sessionId + is WsEvent.MessageComplete -> event.sessionId + is WsEvent.MessageDone -> event.sessionId + else -> null + } + if (turnSessionId != null && !isCurrentSession(turnSessionId)) return + if (event is WsEvent.MessageComplete && event.rawPayload?.get("status") == "error" && + event.sessionId == null + ) { + return + } + // RpcError is reduced before ViewModel request handling. Drop stale // session errors here so the shared reducer cannot clear loading or // surface an error for a newly selected session. if (event is WsEvent.RpcError && isStaleSessionRequest(event.id)) { + outgoingRequestById.remove(event.id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(event.id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } forgetRequest(event.id) return } @@ -909,18 +1033,25 @@ class ChatViewModel( } is WsEvent.MessageToken -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleMessageToken(event) } is WsEvent.ThinkingDelta -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleThinkingDelta(event) } is WsEvent.ReasoningDelta -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleReasoningDelta(event) } is WsEvent.MessageStart -> { + if (isCurrentSession(event.sessionId)) { + mainTurnEpoch++ + mainTurnBusy = true + } // The server accepted a prompt for this session — its DB row // now exists (created lazily at prompt.submit), so a reconnect // resume will succeed. @@ -929,15 +1060,26 @@ class ChatViewModel( } is WsEvent.MessageComplete -> { + if (isCurrentSession(event.sessionId)) { + mainTurnBusy = false + lastMainCompletionAt = System.currentTimeMillis() + refreshSendReceipts() + } // Buffers cleared before reduce; ViewModel resets them after streamingController.resetStreaming() } is WsEvent.MessageDone -> { + if (isCurrentSession(event.sessionId)) { + mainTurnBusy = false + lastMainCompletionAt = System.currentTimeMillis() + refreshSendReceipts() + } streamingController.resetStreaming() } is WsEvent.ToolStart -> { + if (isCurrentSession(event.sessionId)) mainTurnBusy = true // Issue #771: the reducer keeps the streaming message (and its // reasoning) alive across the tool call so the finalized answer // retains the thinking card. Only the token buffers are cleared @@ -1238,10 +1380,13 @@ class ChatViewModel( // ── Message streaming ──────────────────────────────────────────────── - /** - * Checks if an incoming WS event belongs to the currently active - * session. Returns true if the event should be processed. - */ + fun dismissReplyFailure(id: String) { + _uiState.update { state -> + if (state.replyFailure?.id == id) state.copy(replyFailure = null) else state + } + } + + /** Checks if an incoming WS event belongs to the currently active session. */ private fun isCurrentSession(eventSessionId: String?): Boolean { // If the event has no session ID, process it (legacy compatibility) if (eventSessionId == null) return true @@ -1303,6 +1448,7 @@ class ChatViewModel( // Session info pushed by backend when config changes // (model switch, reasoning level, etc.) if (info != null) { + (info["running"] as? Boolean)?.let { mainTurnBusy = it } val model = info["model"] as? String val provider = info["provider"] as? String val reasoningEffort = info["reasoning_effort"] as? String @@ -1387,11 +1533,109 @@ class ChatViewModel( runtimeSessionId ?: _uiState.value.currentSessionId, ) } + // Session.info can carry "pending_clarify" the same way — restore + // the clarify bubble so a question asked while detached stays + // answerable after leaving and re-entering the chat. + val pendingClarify = info["pending_clarify"] as? Map<*, *> + if (pendingClarify != null) { + surfacePendingClarify( + pendingClarify, + runtimeSessionId ?: _uiState.value.currentSessionId, + ) + } } } + /** + * Restore a pending clarify from the "pending_clarify" replay payload that + * "session.resume" / "session.info" carry while a clarify blocks the turn + * server-side (gateway _live_session_payload). Without this, leaving and + * re-entering a chat shows the "waiting for input" status with no way to + * answer: the live "clarify.request" event only reached whatever client + * was attached when the agent asked. + * + * The payload has the same shape as a live "clarify.request" event (batch + * "questions" or legacy "question"/"choices", plus the "request_id" the + * answer must reference), so reuse [EventParser] and feed the typed event + * through [handleWsEvent] — the exact path a live question takes. Batch + * replays may also carry locked per-question "answers". + */ + private fun surfacePendingClarify( + payload: Map<*, *>, + sessionId: String?, + ) { + val clarifyId = (payload["clarify_id"] ?: payload["request_id"]) as? String + if (clarifyId != null && _uiState.value.clarifyRequest?.clarifyId == clarifyId) { + // Already on screen — avoid clobbering in-progress answer state. + return + } + val clarifyPayload: Map = payload.entries.associate { (k, v) -> k.toString() to v } + val event = + EventParser.parseParams( + mapOf( + "type" to "clarify.request", + "session_id" to sessionId, + "payload" to clarifyPayload, + ), + ) as? WsEvent.ClarifyRequest ?: return + if (event.questions.isEmpty() && event.text.isNullOrBlank() && event.options.isNullOrEmpty()) { + return + } + // "answers" is replay-only (locked batch answers); mirror it into the + // event so restored questions render their answered state. + val lockedAnswers = + (clarifyPayload["answers"] as? Map<*, *>) + ?.mapNotNull { (qid, answer) -> + val id = qid as? String ?: return@mapNotNull null + val text = answer as? String ?: return@mapNotNull null + id to text + }?.toMap() + ?: emptyMap() + handleWsEvent(event.copy(lockedAnswers = lockedAnswers)) + } + // ── RPC response handling ──────────────────────────────────────────── + private fun settleOutgoingResult( + requestId: String, + method: String, + result: Any?, + ) { + val messageId = outgoingRequestById.remove(requestId) ?: return + val status = rpcResultMap(result)?.get("status") as? String + val accepted = + when (method) { + WsMethods.SESSION_REDIRECT -> status == "redirected" || status == "queued" + WsMethods.SESSION_STEER -> status == "queued" || status == "steered" + else -> status == "streaming" || status == "queued" + } + when { + accepted -> { + markPendingSend(messageId, PendingSendState.ACCEPTED) + if (sendStore.all().any { it.id == messageId && lastMainCompletionAt >= it.createdAt }) { + refreshSendReceipts() + } + } + + status == "rejected" && method != WsMethods.PROMPT_SUBMIT -> { + removeUnconfirmedBubble(messageId) + sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) } + publishPendingSends() + drainPendingQueue() + } + + status == "rejected" -> { + markPendingSend(messageId, PendingSendState.REJECTED) + removeUnconfirmedBubble(messageId) + } + + else -> { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } + } + @Suppress("UNCHECKED_CAST") private fun handleRpcResult( id: String, @@ -1399,7 +1643,16 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) - if (request != null && isStaleSessionRequest(request)) return + if (request != null && isStaleSessionRequest(request)) { + outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } + return + } + if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT || + method == WsMethods.SESSION_STEER + ) { + settleOutgoingResult(id, method, result) + } when (method) { WsMethods.SESSION_CREATE -> { val resultMap = result as? Map @@ -1431,6 +1684,7 @@ class ChatViewModel( sessionUsage = null, ) } + publishPendingSends() // A gone-session recovery just landed — announce it now that // the message list has been reset by the create. if (pendingGoneSessionNotice) { @@ -1450,13 +1704,27 @@ class ChatViewModel( val pending = pendingInitialPrompt pendingInitialPrompt = null if (pending != null) { - dispatchPrompt( - text = pending.text, - attachments = pending.attachments, - wasStreaming = pending.wasStreaming, - storageSessionId = storageId, - agentSessionId = runtimeId, - userMessage = pending.userMessage, + enqueuePendingSendReservation( + PendingSendReservation( + pending = + PendingSend( + id = pending.userMessage.id, + scope = sendScope(), + sessionId = storageId, + text = pending.text, + attachments = pending.attachments, + mode = BusySendMode.CORRECT, + state = PendingSendState.SENDING, + createdAt = pending.createdAt, + ), + userMessage = pending.userMessage, + wasStreaming = pending.wasStreaming, + storageSessionId = storageId, + agentSessionId = runtimeId, + scope = sendScope(), + generation = sessionGeneration, + mainTurnEpoch = pending.mainTurnEpoch, + ), ) } } @@ -1510,20 +1778,24 @@ class ChatViewModel( WsMethods.SESSION_RESUME -> { val resultMap = result as? Map + // A resume response may carry retained terminal state. Correlate the + // exact request and stored session before binding the runtime id or + // projecting any failure; a late response must not touch the new chat. + val resumeRequest = request ?: return + val sessionId = resumeRequest.sessionId ?: return + if (sessionId != _uiState.value.currentSessionId) return + val resumedStoredId = (resultMap?.get("resumed") as? String)?.takeIf { it.isNotBlank() } + if (resumedStoredId != null && resumedStoredId != sessionId) return val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() } if (runtimeId == null) { - val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return - handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response") + handleResumeFailure(sessionId, resumeRequest.generation, "Invalid session resume response") return } runtimeSessionId = runtimeId connectionOperationDelegate.bindSession(runtimeId) + mainTurnBusy = resultMap["running"] as? Boolean ?: false // Resume succeeded — the gateway confirmed the DB row. sessionHasServerPresence = true - val sessionId = - request?.sessionId - ?: (resultMap["resumed"] as? String) - ?: _uiState.value.currentSessionId // Parse session info from backend — model, provider, reasoning_effort val infoMap = resultMap["info"] as? Map @@ -1592,9 +1864,11 @@ class ChatViewModel( ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) addSystemMessage("Session resumed") fetchContextUsage() - val generation = request?.generation ?: sessionGeneration + projectRetainedReplyFailure(resultMap["inflight"] as? Map, runtimeId) + val generation = resumeRequest.generation resumedGeneration = generation finishResumeWhenHydrated(generation) + publishPendingSends() subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId) // Reconnect replay: resume payload can carry `pending_approval` // (server `_session_info_payload`); surface it, then ask for @@ -1606,6 +1880,17 @@ class ChatViewModel( runtimeSessionId ?: sessionId, ) } + // Reconnect replay: resume payload can carry "pending_clarify" + // (server _live_session_payload) — restore the clarify bubble + // so questions asked while the client was detached remain + // answerable after leaving and re-entering the session. + val pendingClarify = resultMap["pending_clarify"] as? Map<*, *> + if (pendingClarify != null) { + surfacePendingClarify( + pendingClarify, + runtimeSessionId ?: sessionId, + ) + } val activeSessionId = runtimeSessionId ?: sessionId // Reconnect replay: the backend-owned connector operation is // authoritative and uses the same full snapshot as the live @@ -1621,20 +1906,70 @@ class ChatViewModel( } WsMethods.SESSION_INTERRUPT -> { - // Issue #842 follow-up: seal whatever the agent streamed so far - // (interim commentary + partial answer) BEFORE clearing the - // streaming state. The old tool.start orphan seal used to leave - // pre-tool text behind on interrupt; with that seal gone, the - // partial would otherwise vanish entirely. - sealStreamingMessageIfAny() - _uiState.update { - it.copy( - isAgentTyping = false, - ) + val hardInterrupt = hardInterruptRequestById.remove(id) + val status = rpcResultMap(result)?.get("status") + val targetsCurrentTurn = hardInterrupt == null || hardInterrupt.mainTurnEpoch == mainTurnEpoch + if (targetsCurrentTurn && status in setOf("interrupted", "not_interrupted")) { + // Never let a late result for an older same-session turn + // erase a newer stream or make its replacement race it. + sealStreamingMessageIfAny() + _uiState.update { it.copy(isAgentTyping = false) } + _streamingState.update { StreamingState() } + streamingController.resetStreaming() + if (status == "interrupted") addSystemMessage("Session interrupted") + mainTurnBusy = false + } + hardInterrupt?.let { tracked -> + val messageId = tracked.messageId + val row = sendStore.all().firstOrNull { it.id == messageId } ?: return@let + when { + !targetsCurrentTurn && status in setOf("interrupted", "not_interrupted") -> { + // The replacement is known unsent, but this successful interrupt + // result belongs to an older turn. Wait behind the newer turn. + sendStore.update(messageId) { + it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE) + } + publishPendingSends() + } + + status == "interrupted" -> { + val message = + _uiState.value.messages.firstOrNull { it.id == row.id } + ?: ChatMessage( + id = row.id, + role = MessageRole.USER, + content = row.text, + attachments = row.attachments, + ) + dispatchPrompt( + text = row.text, + attachments = row.attachments, + wasStreaming = false, + storageSessionId = row.sessionId, + agentSessionId = tracked.owner.agentSessionId, + userMessage = message, + mode = BusySendMode.INTERRUPT, + queued = true, + owner = tracked.owner, + pendingReceipt = row, + outboundAlreadyStarted = true, + ) + } + + status == "not_interrupted" -> { + sendStore.update(messageId) { + it.copy(state = PendingSendState.QUEUED, mode = BusySendMode.QUEUE) + } + publishPendingSends() + drainPendingQueue() + } + + else -> { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } } - _streamingState.update { StreamingState() } - streamingController.resetStreaming() - addSystemMessage("Session interrupted") } WsMethods.COMMANDS_CATALOG -> { @@ -1663,6 +1998,25 @@ class ChatViewModel( } } + /** + * Replays a gateway-retained failed turn through the same reducer path as a + * live terminal `message.complete(status=error)`. The assistant field is + * the partial display projection; user/transcript fields are never exported + * as diagnostics, and reducer effects intentionally do not persist it. + */ + private fun projectRetainedReplyFailure( + inflight: Map?, + runtimeId: String, + ) { + if (inflight?.get("status") != "error") return + val result = + ChatWsEventReducer.reduceRetainedReplyFailure(_uiState.value, inflight, runtimeId) + _uiState.value = result.state + _streamingState.value = result.streamingState + dispatchReducerEffects(result.effects) + streamingController.resetStreaming() + } + @Suppress("UNCHECKED_CAST") private fun handleDispatchResult(result: Any?) { val map = result as? Map<*, *> ?: return @@ -1712,19 +2066,45 @@ class ChatViewModel( ) { val method = idToMethod.remove(id) ?: return val request = sessionRequestById.remove(id) - if (request != null && isStaleSessionRequest(request)) return + if (request != null && isStaleSessionRequest(request)) { + outgoingRequestById.remove(id)?.let { markPendingSend(it, PendingSendState.UNKNOWN) } + hardInterruptRequestById.remove(id)?.let { markPendingSend(it.messageId, PendingSendState.UNKNOWN) } + return + } val errorMsg = when (error) { is Map<*, *> -> error["message"] as? String ?: error.toString() else -> error.toString() } - if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) { + val messageId = outgoingRequestById.remove(id) + if (messageId != null) { + val code = (error as? JsonRpcError)?.code + val correction = method == WsMethods.SESSION_REDIRECT || method == WsMethods.SESSION_STEER + if (correction && code in setOf(4001, 4009, 4010)) { + removeUnconfirmedBubble(messageId) + sendStore.update(messageId) { it.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) } + publishPendingSends() + drainPendingQueue() + } else { + markPendingSend( + messageId, + if (code != null && code in 4000..4999) PendingSendState.REJECTED else PendingSendState.UNKNOWN, + ) + removeUnconfirmedBubble(messageId) + } + } + hardInterruptRequestById.remove(id)?.let { tracked -> + markPendingSend(tracked.messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(tracked.messageId) + } + + if (method == WsMethods.PROMPT_SUBMIT && !mainTurnBusy) { // A prompt rejection can arrive before message.start, leaving the // UI with only the optimistic typing state. Clear the live tail so // a failed generation cannot leave stale dots or reasoning behind. sealStreamingMessageIfAny() - _uiState.update { it.copy(isAgentTyping = false) } + _uiState.update { it.copy(isAgentTyping = mainTurnBusy) } _streamingState.update { StreamingState() } streamingController.resetStreaming() } @@ -1751,9 +2131,13 @@ class ChatViewModel( if (method == WsMethods.SESSION_CREATE) { val pending = pendingInitialPrompt pendingInitialPrompt = null + publishPendingSends() if (pending != null) { _uiState.update { it.copy( + messages = it.messages.filterNot { message -> message.id == pending.userMessage.id }, + pendingAttachments = pending.attachments + it.pendingAttachments, + composerTextToRestore = pending.text, isAgentTyping = false, errorMessage = "Failed to create session: $errorMsg", ) @@ -1775,6 +2159,566 @@ class ChatViewModel( // ── Send message ───────────────────────────────────────────────────── + private fun sendScope(): String = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + + private fun isCurrentSendContext( + scope: String, + storageSessionId: String, + agentSessionId: String, + generation: Long, + ): Boolean = + generation == sessionGeneration && + scope == sendScope() && + storageSessionId == _uiState.value.currentSessionId && + agentSessionId == runtimeSessionId + + private fun isCurrentSendContext(owner: SendOwner): Boolean = + isCurrentSendContext( + owner.scope, + owner.storageSessionId, + owner.agentSessionId, + owner.generation, + ) + + private fun captureSendOwner( + storageSessionId: String, + agentSessionId: String, + scope: String = sendScope(), + generation: Long = sessionGeneration, + ): SendOwner = SendOwner(scope, storageSessionId, agentSessionId, generation) + + /** Context invalidation and synchronous WS enqueue are serialized on Main. */ + private suspend fun withCurrentSendOwner( + owner: SendOwner, + action: () -> T, + ): T? = + withContext(Dispatchers.Main.immediate) { + if (isCurrentSendContext(owner)) action() else null + } + + private fun restoreKnownUnsentReceipt( + attempted: PendingSend, + rollback: PendingSend?, + deleteSnapshot: Boolean, + ) { + if (rollback != null) sendStore.put(rollback) else sendStore.remove(attempted.id) + publishPendingSends() + if (deleteSnapshot && rollback?.attachments.orEmpty().none { it.uri.startsWith("file:", ignoreCase = true) }) { + viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(attempted.id) } + } + } + + /** Commit/claim and enqueue the first RPC without releasing Main ownership. */ + private suspend fun commitReceiptAndEnqueue( + owner: SendOwner, + attempted: PendingSend, + rollback: PendingSend?, + deleteSnapshotOnRollback: Boolean, + enqueue: () -> T, + ): T? = + withContext(Dispatchers.Main.immediate) { + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback) + return@withContext null + } + sendStore.put(attempted) + publishPendingSends() + // Store callbacks can re-enter profile selection. + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt(attempted, rollback, deleteSnapshotOnRollback) + return@withContext null + } + enqueue() + } + + private fun deleteQueuedAttachmentSnapshot(messageId: String) { + File(getApplication().filesDir, "chat-send/$messageId").deleteRecursively() + } + + @Synchronized + private fun nextPendingSendCreatedAt(): Long { + lastPendingSendCreatedAt = maxOf(System.currentTimeMillis(), lastPendingSendCreatedAt + 1L) + return lastPendingSendCreatedAt + } + + private fun hasPendingSendReservation( + scope: String, + sessionId: String?, + ): Boolean = + synchronized(pendingSendReservationLock) { + pendingSendReservations.any { it.scope == scope && it.storageSessionId == sessionId } + } + + private fun enqueuePendingSendReservation(reservation: PendingSendReservation) { + synchronized(pendingSendReservationLock) { pendingSendReservations += reservation } + startPendingSendReservationDrain() + } + + @Synchronized + private fun startPendingSendReservationDrain() { + if (pendingSendReservationJob?.isActive == true) return + pendingSendReservationJob = + viewModelScope.launch(ioDispatcher) { + try { + while (true) { + val reservation = + synchronized(pendingSendReservationLock) { pendingSendReservations.firstOrNull() } + ?: break + try { + processPendingSendReservation(reservation) + } finally { + synchronized(pendingSendReservationLock) { + pendingSendReservations.removeAll { it.pending.id == reservation.pending.id } + } + } + } + } finally { + pendingSendReservationJob = null + if (synchronized(pendingSendReservationLock) { pendingSendReservations.isNotEmpty() }) { + startPendingSendReservationDrain() + } else { + drainPendingQueue() + } + } + } + } + + private suspend fun processPendingSendReservation(reservation: PendingSendReservation) { + val owner = + captureSendOwner( + reservation.storageSessionId, + reservation.agentSessionId, + reservation.scope, + reservation.generation, + ) + try { + var staged = stageQueuedAttachments(reservation.pending).copy(requiresAttachmentRecovery = false) + kotlinx.coroutines.yield() + if (withCurrentSendOwner(owner) { true } != true) { + if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(reservation.pending.id) + return + } + if (reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT && + reservation.mainTurnEpoch != mainTurnEpoch + ) { + staged = staged.copy(mode = BusySendMode.QUEUE, state = PendingSendState.QUEUED) + } + val stagedMessage = + reservation.userMessage.copy( + attachments = staged.attachments.takeIf { it.isNotEmpty() }, + ) + if (withCurrentSendOwner(owner) { + _uiState.update { state -> + val messages = + if (state.messages.any { it.id == stagedMessage.id }) { + state.messages.map { if (it.id == stagedMessage.id) stagedMessage else it } + } else { + state.messages + stagedMessage + } + state.copy(messages = messages, isSending = true) + } + true + } != true + ) { + if (reservation.originalPending == null) deleteQueuedAttachmentSnapshot(staged.id) + return + } + + when { + staged.state == PendingSendState.QUEUED -> { + val committed = + withContext(Dispatchers.Main.immediate) { + if (!isCurrentSendContext(owner)) return@withContext false + sendStore.put(staged) + publishPendingSends() + if (!isCurrentSendContext(owner)) { + restoreKnownUnsentReceipt( + staged, + reservation.originalPending, + deleteSnapshot = reservation.originalPending != null, + ) + false + } else { + true + } + } + if (committed) repo.persistMessage(stagedMessage, owner.storageSessionId) + } + + reservation.wasStreaming && staged.mode == BusySendMode.INTERRUPT -> { + commitReceiptAndEnqueue( + owner, + staged, + reservation.originalPending, + deleteSnapshotOnRollback = true, + ) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { id -> trackHardInterrupt(id, staged.id, owner, reservation.mainTurnEpoch) }, + ) + } + } + + else -> { + dispatchPrompt( + text = staged.text, + attachments = staged.attachments, + wasStreaming = reservation.wasStreaming, + storageSessionId = owner.storageSessionId, + agentSessionId = owner.agentSessionId, + userMessage = stagedMessage, + mode = if (reservation.wasStreaming) staged.mode else BusySendMode.CORRECT, + owner = owner, + pendingReceipt = staged, + rollbackReceipt = reservation.originalPending, + deleteSnapshotOnRollback = true, + ).join() + } + } + } catch (e: Exception) { + if (reservation.originalPending != null) { + sendStore.put(reservation.originalPending) + } else { + sendStore.remove(reservation.pending.id) + deleteQueuedAttachmentSnapshot(reservation.pending.id) + } + publishPendingSends() + if (e is CancellationException) throw e + if (withCurrentSendOwner(owner) { true } == true) { + if (reservation.originalPending == null) { + _uiState.update { state -> + state.copy( + messages = state.messages.filterNot { it.id == reservation.pending.id }, + pendingAttachments = reservation.pending.attachments + state.pendingAttachments, + composerTextToRestore = reservation.pending.text, + errorMessage = + if (e is QueuedAttachmentTooLargeException) { + attachmentTooLargeMessage(e.attachment) + } else { + "Could not retain attachment" + }, + ) + } + } else if (e is QueuedAttachmentTooLargeException) { + _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(e.attachment)) } + } + publishPendingSends() + } + } + } + + private fun publishPendingSends() { + val sessionId = _uiState.value.currentSessionId + val scope = sendScope() + val current = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + _uiState.update { + it.copy( + pendingSends = current, + isSending = + pendingInitialPrompt != null || current.any { row -> row.state == PendingSendState.SENDING }, + ) + } + } + + private fun trackOutgoingRequest( + requestId: String, + messageId: String, + method: String, + generation: Long, + sessionId: String, + ) { + outgoingRequestById[requestId] = messageId + trackSessionRequest(requestId, method, generation, sessionId = sessionId) + if (!isTestEnvironment()) { + viewModelScope.launch { + delay(30_000L) + expireOutgoingRequest(requestId) + } + } + } + + internal fun expireOutgoingRequest(requestId: String) { + val messageId = + outgoingRequestById[requestId] + ?: hardInterruptRequestById[requestId]?.messageId + ?: return + if (sendStore.all().any { it.id == messageId && it.state == PendingSendState.SENDING }) { + markPendingSend(messageId, PendingSendState.UNKNOWN) + removeUnconfirmedBubble(messageId) + } + } + + private fun trackHardInterrupt( + requestId: String, + messageId: String, + owner: SendOwner, + interruptedTurnEpoch: Long, + ) { + hardInterruptRequestById[requestId] = HardInterruptRequest(messageId, interruptedTurnEpoch, owner) + trackSessionRequest( + requestId, + WsMethods.SESSION_INTERRUPT, + owner.generation, + sessionId = owner.storageSessionId, + ) + if (!isTestEnvironment()) { + viewModelScope.launch { + delay(30_000L) + expireOutgoingRequest(requestId) + } + } + } + + private fun markPendingSend( + messageId: String, + state: PendingSendState, + ) { + sendStore.update(messageId) { it.copy(state = state) } + publishPendingSends() + } + + private fun removePendingSend(messageId: String) { + sendStore.remove(messageId) + viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) } + publishPendingSends() + } + + private fun removeUnconfirmedBubble(messageId: String) { + val row = _uiState.value.messages.firstOrNull { it.id == messageId && it.canonicalRestId == null } ?: return + _uiState.update { state -> state.copy(messages = state.messages.filterNot { it.id == row.id }) } + viewModelScope.launch(ioDispatcher) { repo.deleteMessage(row.id) } + } + + private fun failOutgoingBeforeSubmit( + messageId: String, + reason: String, + generation: Long = sessionGeneration, + ) { + markPendingSend(messageId, PendingSendState.REJECTED) + if (generation != sessionGeneration) return + removeUnconfirmedBubble(messageId) + _uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) } + } + + /** Send one locally queued turn only after the previous receipt has reached REST history. */ + @Synchronized + private fun drainPendingQueue() { + val sessionId = _uiState.value.currentSessionId ?: return + val runtimeId = runtimeSessionId ?: return + val scope = sendScope() + if (mainTurnBusy || !_uiState.value.isSessionReady || + wsClient.connectionStatus.value != ConnectionStatus.CONNECTED + ) { + return + } + if (queueDrainJob?.isActive == true) return + if (hasPendingSendReservation(scope, sessionId)) return + val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + if (rows.any { + it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN || + it.state == PendingSendState.ACCEPTED + } + ) { + return + } + val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return + if (queuedStagingIds.contains(next.id)) return + val owner = captureSendOwner(sessionId, runtimeId, scope, sessionGeneration) + queueDrainJob = + viewModelScope.launch(ioDispatcher) { + try { + val staged = stageQueuedAttachments(next) + if (withCurrentSendOwner(owner) { true } != true) return@launch + val message = + _uiState.value.messages.firstOrNull { it.id == next.id } + ?: ChatMessage( + id = next.id, + role = MessageRole.USER, + content = next.text, + attachments = staged.attachments, + ) + dispatchPrompt( + text = next.text, + attachments = staged.attachments, + wasStreaming = false, + storageSessionId = owner.storageSessionId, + agentSessionId = owner.agentSessionId, + userMessage = message, + mode = BusySendMode.QUEUE, + queued = true, + owner = owner, + pendingReceipt = + staged.copy( + state = PendingSendState.SENDING, + attempts = next.attempts + 1, + ), + rollbackReceipt = next, + ).join() + } catch (e: Exception) { + if (e is CancellationException) throw e + failOutgoingBeforeSubmit( + next.id, + if (e is QueuedAttachmentTooLargeException) { + attachmentTooLargeMessage(e.attachment) + } else { + "Could not retain queued attachment" + }, + ) + } + } + } + + private fun stageQueuedAttachments(row: PendingSend): PendingSend { + if (row.attachments.isEmpty()) return row + val context = getApplication() + val dir = File(context.filesDir, "chat-send/${row.id}") + if (!dir.exists() && !dir.mkdirs()) error("Could not create attachment snapshot") + val staged = + row.attachments.mapIndexed { index, attachment -> + val uri = Uri.parse(attachment.uri) + val target = File(dir, index.toString()) + if (target.exists()) { + attachment.copy(uri = Uri.fromFile(target).toString(), size = target.length()) + } else { + val partial = File(dir, "$index.part") + var copied = 0L + try { + val input = context.contentResolver.openInputStream(uri) ?: error("Attachment unavailable") + input.use { source -> + partial.outputStream().use { output -> + val buffer = ByteArray(8192) + while (true) { + val count = source.read(buffer) + if (count < 0) break + copied += count + if (copied > MAX_CHAT_ATTACHMENT_BYTES) { + throw QueuedAttachmentTooLargeException(attachment) + } + output.write(buffer, 0, count) + } + } + } + if (!partial.renameTo(target)) error("Could not save attachment snapshot") + attachment.copy(uri = Uri.fromFile(target).toString(), size = copied) + } catch (e: Exception) { + partial.delete() + throw e + } + } + } + return row.copy(attachments = staged) + } + + private fun refreshSendReceipts() { + val sessionId = _uiState.value.currentSessionId ?: return + if (sendStore.all().any { + it.scope == sendScope() && it.sessionId == sessionId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + ) { + if (sessionHasServerPresence) syncCurrentSession() else loadSessionMessages(sessionId, sessionGeneration) + } else { + drainPendingQueue() + } + } + + /** A manual promotion is the only path that may retry an uncertain send. */ + @Synchronized + fun sendQueuedNow(id: String) { + val row = sendStore.all().firstOrNull { it.id == id } ?: return + if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId || + runtimeSessionId == null + ) { + return + } + if (row.state !in + setOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.REJECTED, + PendingSendState.UNKNOWN, + PendingSendState.ACCEPTED, + ) + ) { + return + } + if (queueDrainJob?.isActive == true || + synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } } + ) { + return + } + val others = + sendStore.all().any { + it.id != id && it.scope == row.scope && it.sessionId == row.sessionId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + if (others) { + _uiState.update { it.copy(errorMessage = "Wait for the current send to settle") } + return + } + if (row.attachments.isNotEmpty()) { + val runtimeId = runtimeSessionId ?: return + val generation = sessionGeneration + val wasStreaming = mainTurnBusy + val message = + _uiState.value.messages.firstOrNull { it.id == row.id } + ?: ChatMessage( + id = row.id, + role = MessageRole.USER, + content = row.text, + attachments = row.attachments, + ) + enqueuePendingSendReservation( + PendingSendReservation( + pending = + row.copy( + mode = if (wasStreaming) BusySendMode.INTERRUPT else BusySendMode.QUEUE, + state = PendingSendState.SENDING, + attempts = row.attempts + 1, + ), + userMessage = message, + wasStreaming = wasStreaming, + storageSessionId = row.sessionId, + agentSessionId = runtimeId, + scope = row.scope, + generation = generation, + mainTurnEpoch = mainTurnEpoch, + originalPending = row, + ), + ) + return + } + sendStore.promote(id) + publishPendingSends() + if (mainTurnBusy) { + val runtimeId = runtimeSessionId ?: return + val owner = captureSendOwner(row.sessionId, runtimeId, row.scope, sessionGeneration) + val interruptedTurnEpoch = mainTurnEpoch + viewModelScope.launch { + commitReceiptAndEnqueue( + owner, + row.copy(state = PendingSendState.SENDING, attempts = row.attempts + 1), + row, + deleteSnapshotOnRollback = false, + ) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { requestId -> trackHardInterrupt(requestId, id, owner, interruptedTurnEpoch) }, + ) + } + } + } else { + drainPendingQueue() + } + } + /** * Send a user prompt, uploading any pending attachments to the backend * first via their dedicated RPC methods. @@ -1790,7 +2734,10 @@ class ChatViewModel( * 6. For each file → await `file.attach` (requires session_id), collect @file: refs * 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend */ - fun sendMessage(text: String): Boolean { + fun sendMessage( + text: String, + modeOverride: BusySendMode? = null, + ): Boolean { if (!canSubmitMessage()) return false if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false @@ -1814,17 +2761,45 @@ class ChatViewModel( _uiState.update { it.copy( errorMessage = attachmentTooLargeMessage(oversizedAttachment), - composerTextToRestore = text, ) } - return true + return false } - // Snapshot + clear attachments so the input bar empties immediately val attachments = _uiState.value.pendingAttachments.toList() - clearAttachments() + val submissionKey = + listOf(_uiState.value.currentSessionId, text, attachments.joinToString { it.uri }).joinToString("\u001f") + val now = System.nanoTime() + if (submissionKey == lastSubmissionKey && now - lastSubmissionAt < 500_000_000L) return false + val wasStreaming = mainTurnBusy + val clickedMainTurnEpoch = mainTurnEpoch + val hasOutstandingDelivery = + sendStore.all().any { + it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId && + it.state in + setOf( + PendingSendState.SENDING, + PendingSendState.ACCEPTED, + PendingSendState.UNKNOWN, + ) + } || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId) + val busy = wasStreaming || hasOutstandingDelivery + val selectedMode = modeOverride ?: _uiState.value.busySendMode + val mode = + when { + selectedMode == BusySendMode.INTERRUPT && !wasStreaming && hasOutstandingDelivery -> { + BusySendMode.QUEUE + } - val wasStreaming = _uiState.value.isAgentTyping + busy && selectedMode != BusySendMode.INTERRUPT && + (attachments.isNotEmpty() || !wasStreaming) -> { + BusySendMode.QUEUE + } + + else -> { + selectedMode + } + } val userMessage = ChatMessage( @@ -1832,23 +2807,135 @@ class ChatViewModel( content = text, attachments = if (attachments.isNotEmpty()) attachments else null, tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, + messageProvenance = MessageProvenance.LOCAL_PENDING, ) - // Update UI immediately - _uiState.update { state -> - state.copy( - messages = state.messages + userMessage, - isAgentTyping = true, - ) - } - val storageSessionId = _uiState.value.currentSessionId val agentSessionId = runtimeSessionId + val scope = sendScope() + val createdAt = nextPendingSendCreatedAt() + if (storageSessionId != null && agentSessionId != null && + (attachments.isNotEmpty() || hasPendingSendReservation(scope, storageSessionId)) + ) { + val generation = sessionGeneration + val pending = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = + if (busy && mode == BusySendMode.QUEUE) { + PendingSendState.QUEUED + } else { + PendingSendState.SENDING + }, + createdAt = createdAt, + ) + lastSubmissionKey = submissionKey + lastSubmissionAt = now + clearAttachments() + enqueuePendingSendReservation( + PendingSendReservation( + pending = pending, + userMessage = userMessage, + wasStreaming = wasStreaming, + storageSessionId = storageSessionId, + agentSessionId = agentSessionId, + scope = scope, + generation = generation, + mainTurnEpoch = clickedMainTurnEpoch, + ), + ) + return true + } + if (storageSessionId != null && agentSessionId != null && busy && mode == BusySendMode.QUEUE) { + try { + sendStore.put( + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + createdAt = createdAt, + state = + if (busy && mode == BusySendMode.QUEUE) { + PendingSendState.QUEUED + } else { + PendingSendState.SENDING + }, + ), + ) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = "Could not save message for delivery") } + return false + } + } + lastSubmissionKey = submissionKey + lastSubmissionAt = now + clearAttachments() + + _uiState.update { state -> + state.copy( + messages = state.messages + userMessage, + isSending = true, + ) + } + publishPendingSends() if (storageSessionId == null || agentSessionId == null) { // Issue #969: Session creation is still in-flight. Hold the prompt // so it is dispatched automatically the moment SESSION_CREATE lands. - pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage) + pendingInitialPrompt = + PendingPrompt(text, attachments, wasStreaming, userMessage, createdAt, clickedMainTurnEpoch) + publishPendingSends() + return true + } + + if (busy && mode == BusySendMode.QUEUE) { + queuedStagingIds.add(userMessage.id) + viewModelScope.launch(ioDispatcher) { + try { + stageQueuedAttachments(sendStore.all().first { it.id == userMessage.id }) + repo.persistMessage(userMessage, storageSessionId) + } catch (e: Exception) { + if (e is CancellationException) throw e + failOutgoingBeforeSubmit(userMessage.id, "Could not retain queued attachment") + } finally { + queuedStagingIds.remove(userMessage.id) + drainPendingQueue() + } + } + return true + } + + if (wasStreaming && mode == BusySendMode.INTERRUPT) { + val owner = captureSendOwner(storageSessionId, agentSessionId, scope, sessionGeneration) + val interruptedTurnEpoch = mainTurnEpoch + val receipt = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = PendingSendState.SENDING, + createdAt = createdAt, + ) + viewModelScope.launch { + commitReceiptAndEnqueue(owner, receipt, null, deleteSnapshotOnRollback = false) { + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to owner.agentSessionId), + onSent = { id -> trackHardInterrupt(id, userMessage.id, owner, interruptedTurnEpoch) }, + ) + } + } return true } @@ -1859,10 +2946,130 @@ class ChatViewModel( storageSessionId = storageSessionId, agentSessionId = agentSessionId, userMessage = userMessage, + mode = if (wasStreaming) mode else BusySendMode.CORRECT, + owner = captureSendOwner(storageSessionId, agentSessionId, scope), + pendingReceipt = + PendingSend( + id = userMessage.id, + scope = scope, + sessionId = storageSessionId, + text = text, + attachments = attachments, + mode = if (wasStreaming) mode else BusySendMode.CORRECT, + state = PendingSendState.SENDING, + createdAt = createdAt, + ), ) return true } + /** True while a voice-note transcription owns the mic-to-send pipeline. */ + private var voiceNoteTranscriptionInFlight = false + + /** + * Transcribe a recorded voice note through the dashboard's server-side + * STT relay (`POST /api/audio/transcribe` — the desktop client's voice + * path) and submit the transcript as the next message. The profile's + * configured STT provider answers, not the phone's on-device recognizer. + * + * The recording belongs to the session that was active when it was taken: + * transcription takes long enough for the socket, the profile, or the user + * to move on, so a transcript that can no longer land in that session or + * connection/profile scope is preserved in the composer instead of being + * dropped. Only one + * transcription runs at a time — [voiceNoteTranscriptionInFlight] keeps + * every branch below single-flight (review, PR #1250). + */ + fun sendVoiceNote(file: File) { + if (voiceNoteTranscriptionInFlight) { + file.delete() + return + } + if (!canSubmitMessage()) { + file.delete() + _uiState.update { it.copy(errorMessage = VOICE_NOTE_OFFLINE_MESSAGE) } + return + } + val recordedSessionId = _uiState.value.currentSessionId + val recordedRuntimeSessionId = runtimeSessionId + // The recording also belongs to the connection/profile scope it was + // taken on: both session IDs can still be null/null across a scope + // switch while a replacement session create is pending, so the data + // scope is the deciding identity for that window (review, PR #1250). + val recordedDataScope = AuthManager.currentDataScope() + voiceNoteTranscriptionInFlight = true + viewModelScope.launch { + _uiState.update { it.copy(isTranscribingVoiceNote = true) } + try { + val result = + withContext(ioDispatcher) { + try { + voiceNoteRepository.transcribe(file) + } finally { + file.delete() + } + } + when (result) { + is NetworkResult.Success -> { + val transcript = result.data.trim() + if (transcript.isEmpty()) { + _uiState.update { it.copy(errorMessage = VOICE_NOTE_EMPTY_MESSAGE) } + } else { + deliverVoiceTranscript( + transcript = transcript, + recordedSessionId = recordedSessionId, + recordedRuntimeSessionId = recordedRuntimeSessionId, + recordedDataScope = recordedDataScope, + ) + } + } + + is NetworkResult.Failure -> { + Log.w(TAG, "Voice note transcription failed: ${result.error.message}") + _uiState.update { + it.copy(errorMessage = "$VOICE_NOTE_FAILED_MESSAGE: ${result.error.message}") + } + } + } + } finally { + // Always release the single-flight flag and the composer's + // transcription state, even if a branch above throws. + voiceNoteTranscriptionInFlight = false + _uiState.update { it.copy(isTranscribingVoiceNote = false) } + } + } + } + + /** + * Submits a transcribed voice note, or keeps the transcript in the input + * field when it can no longer land where it was recorded. The recording + * file is already deleted by now, so a rejected send restores the text + * instead of losing it. + */ + private fun deliverVoiceTranscript( + transcript: String, + recordedSessionId: String?, + recordedRuntimeSessionId: String?, + recordedDataScope: DataScope?, + ) { + val sessionChanged = + _uiState.value.currentSessionId != recordedSessionId || + runtimeSessionId != recordedRuntimeSessionId + // A connection/profile switch can leave the session IDs null on both + // sides of the recording (a replacement session create may still be + // pending), so the data scope is the ownership check that catches + // that move (review, PR #1250). + val scopeChanged = AuthManager.currentDataScope() != recordedDataScope + if (sessionChanged || scopeChanged || !sendMessage(transcript)) { + _uiState.update { + it.copy( + composerTextToRestore = transcript, + errorMessage = VOICE_NOTE_UNSENT_MESSAGE, + ) + } + } + } + private fun canSubmitMessage(): Boolean = _uiState.value.pendingReasoningLevel == null && wsClient.connectionStatus.value == ConnectionStatus.CONNECTED && @@ -1885,8 +3092,15 @@ class ChatViewModel( storageSessionId: String, agentSessionId: String, userMessage: ChatMessage? = null, - ) { - val dispatchGeneration = sessionGeneration + mode: BusySendMode = BusySendMode.CORRECT, + queued: Boolean = false, + owner: SendOwner = captureSendOwner(storageSessionId, agentSessionId), + pendingReceipt: PendingSend? = null, + rollbackReceipt: PendingSend? = null, + deleteSnapshotOnRollback: Boolean = false, + outboundAlreadyStarted: Boolean = false, + ): Job { + val dispatchGeneration = owner.generation AuthManager.setLastOpenedSessionId(storageSessionId) val msgToPersist = userMessage ?: ChatMessage( @@ -1894,17 +3108,54 @@ class ChatViewModel( content = text, attachments = if (attachments.isNotEmpty()) attachments else null, tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }, + messageProvenance = MessageProvenance.LOCAL_PENDING, ) + val attemptedReceipt = + pendingReceipt + ?: sendStore.all().firstOrNull { it.id == msgToPersist.id }?.copy(state = PendingSendState.SENDING) + ?: PendingSend( + id = msgToPersist.id, + scope = owner.scope, + sessionId = owner.storageSessionId, + text = text, + attachments = attachments, + mode = mode, + state = PendingSendState.SENDING, + ) - // Upload attachments then submit prompt - viewModelScope.launch(ioDispatcher) { + // PR #1254: callers draining FIFO work retain ownership until preparation/enqueue ends. + return viewModelScope.launch(ioDispatcher) { val fileRefs = mutableListOf() val preparedAttachments = mutableListOf() + var receiptCommitted = outboundAlreadyStarted + var outboundStarted = outboundAlreadyStarted + + suspend fun enqueueOwned(action: () -> T): T? { + val value = + if (!receiptCommitted) { + commitReceiptAndEnqueue( + owner, + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + action, + ) + } else { + withCurrentSendOwner(owner, action) + } + if (value == null) { + if (outboundStarted) { + sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) } + publishPendingSends() + } + return null + } + receiptCommitted = true + outboundStarted = true + return value + } try { - // Snapshot every attachment before the first RPC. This closes the - // content-URI TOCTOU window without retaining multiple Base64 - // strings in the heap: encoded snapshots live in private cache. for (attachment in attachments) { when (val result = prepareAttachment(attachment)) { is PrepareAttachmentResult.Success -> { @@ -1912,30 +3163,81 @@ class ChatViewModel( } PrepareAttachmentResult.TooLarge -> { - rejectOversizedAttachment( - attachment = attachment, - attachments = attachments, - message = msgToPersist, - wasStreaming = wasStreaming, - generation = dispatchGeneration, - ) + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + if (!outboundStarted && rollbackReceipt != null) { + withCurrentSendOwner(owner) { + removeUnconfirmedBubble(msgToPersist.id) + _uiState.update { it.copy(errorMessage = attachmentTooLargeMessage(attachment)) } + } + } else { + rejectOversizedAttachment( + attachment, + attachments, + msgToPersist, + wasStreaming, + dispatchGeneration, + ) + } return@launch } PrepareAttachmentResult.Unreadable -> { - Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}") + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + if (!outboundStarted && rollbackReceipt != null) { + withCurrentSendOwner(owner) { + removeUnconfirmedBubble(msgToPersist.id) + _uiState.update { + it.copy( + errorMessage = "Attachment unavailable: ${attachment.name}", + isAgentTyping = mainTurnBusy, + ) + } + } + } else { + failOutgoingBeforeSubmit( + msgToPersist.id, + "Attachment unavailable: ${attachment.name}", + dispatchGeneration, + ) + } + return@launch } } } - // Persist only after every readable attachment has a bounded, - // immutable snapshot and no partial server upload can occur. + if (withCurrentSendOwner(owner) { true } != true) { + if (!outboundStarted) { + restoreKnownUnsentReceipt( + attemptedReceipt, + rollbackReceipt, + deleteSnapshotOnRollback, + ) + } + return@launch + } try { - repo.persistMessage(msgToPersist, storageSessionId) + repo.persistMessage(msgToPersist, owner.storageSessionId) } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Log.e(TAG, "Failed to persist outgoing message", e) - if (dispatchGeneration == sessionGeneration) { + if (outboundStarted) { + sendStore.update(msgToPersist.id) { it.copy(state = PendingSendState.UNKNOWN) } + } else { + restoreKnownUnsentReceipt(attemptedReceipt, rollbackReceipt, deleteSnapshotOnRollback) + } + if (isCurrentSendContext(owner)) { _uiState.update { state -> state.copy( messages = state.messages.filterNot { it.id == msgToPersist.id }, @@ -1951,68 +3253,58 @@ class ChatViewModel( for ((attachment, encodedFile) in preparedAttachments) { try { - // Materialize one bounded Base64 payload at a time. The - // awaited RPC completes before the next snapshot is read. val b64 = encodedFile.readText(Charsets.US_ASCII) - if (attachment.isImage) { - // Await so the backend stages the image into - // session["attached_images"] BEFORE prompt.submit runs - // (a fire-and-forget send raced prompt.submit and the - // image was dropped). Requires session_id or the gateway - // 4001s "session not found" (desktop passes it too). - val result = - sendRpcAndAwait( - method = WsMethods.IMAGE_ATTACH_BYTES, - params = + val deferred = + enqueueOwned { + wsClient.request( + WsMethods.IMAGE_ATTACH_BYTES, mapOf( - "session_id" to agentSessionId, + "session_id" to owner.agentSessionId, "content_base64" to "data:${attachment.mimeType};base64,$b64", "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 - if (ok != true) { - Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result") - } - } - } else { - // Await the @file: ref text so we can embed it in the prompt. - // file.attach also requires session_id or the gateway 4001s - // "session not found" (same resolver as image.attach_bytes). - sendRpcAndAwait( - method = WsMethods.FILE_ATTACH, - params = - mapOf( - "session_id" to agentSessionId, - "data_url" to "data:${attachment.mimeType};base64,$b64", - "name" to attachment.name, - ), - )?.let { response -> - // request() returns raw JsonElement values; normalize before reading Kotlin types. - val result = if (response is JsonElement) response.toAny() else response + ) + } ?: return@launch + val response = deferred.await() + val result = if (response is JsonElement) response.toAny() else response - @Suppress("UNCHECKED_CAST") - val refText = - (result as? Map)?.get("ref_text") as? String - if (!refText.isNullOrBlank()) fileRefs.add(refText) - } + @Suppress("UNCHECKED_CAST") + val ok = (result as? Map)?.get("attached") as? Boolean + if (ok != true) error("Image attachment was not accepted") + } else { + val deferred = + enqueueOwned { + wsClient.request( + WsMethods.FILE_ATTACH, + mapOf( + "session_id" to owner.agentSessionId, + "data_url" to "data:${attachment.mimeType};base64,$b64", + "name" to attachment.name, + ), + ) + } ?: return@launch + val response = deferred.await() + val result = if (response is JsonElement) response.toAny() else response + + @Suppress("UNCHECKED_CAST") + val refText = (result as? Map)?.get("ref_text") as? String + if (refText.isNullOrBlank()) error("File attachment was not accepted") + fileRefs.add(refText) } } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Log.e(TAG, "Failed to upload attachment ${attachment.name}", e) - if (dispatchGeneration == sessionGeneration) { - _uiState.update { - it.copy(errorMessage = "Upload failed: ${attachment.name}") - } - } + failOutgoingBeforeSubmit( + msgToPersist.id, + "Upload failed: ${attachment.name}", + dispatchGeneration, + ) + return@launch } } - // Build prompt text — prepend @file: refs for non-image files val fullText = if (fileRefs.isEmpty()) { text @@ -2020,53 +3312,69 @@ class ChatViewModel( fileRefs.joinToString("\n") + if (text.isNotBlank()) "\n\n$text" else "" } - - if (dispatchGeneration != sessionGeneration) return@launch - - // While a turn is actively streaming and this is a plain text prompt - // (no attachments — session.redirect carries text only), steer the - // in-flight turn via session.redirect instead of queueing a fresh - // prompt.submit. The backend rewrites the live turn when it can, or - // queues the correction as the next turn otherwise (issue #710). - if (dispatchGeneration == sessionGeneration) { - ActiveSessionHolder.set(agentSessionId, storageSessionId) + if (isCurrentSendContext(owner)) { + ActiveSessionHolder.set( + owner.agentSessionId, + owner.storageSessionId, + ) } captureTurnUsageBaselineIfNeeded() - if (wasStreaming && attachments.isEmpty()) { - wsClient.sendRedirect( - agentSessionId, - fullText, - onSent = { id -> - trackSessionRequest( - id = id, - method = WsMethods.SESSION_REDIRECT, - generation = dispatchGeneration, - sessionId = storageSessionId, - ) - }, - ) + if (wasStreaming && attachments.isEmpty() && !queued) { + val sent = + if (mode == BusySendMode.GUIDE) { + enqueueOwned { + wsClient.send( + WsMethods.SESSION_STEER, + mapOf("session_id" to owner.agentSessionId, "text" to fullText), + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.SESSION_STEER, + dispatchGeneration, + owner.storageSessionId, + ) + }, + ) + } + } else { + enqueueOwned { + wsClient.sendRedirect( + owner.agentSessionId, + fullText, + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.SESSION_REDIRECT, + dispatchGeneration, + owner.storageSessionId, + ) + }, + ) + } + } + if (sent == null) return@launch } else { - // Arm the durable turn boundary BEFORE prompt.submit leaves - // the device: the REST high-watermark has to be read while - // the reply row cannot exist yet. A failed capture is - // non-fatal — the prompt is still sent, the turn just - // becomes uncorrelatable and its reply notification is never - // auto-dismissed from hydration instead of being bound to a - // guessed row. - prepareTurnCorrelation(storageSessionId) - if (dispatchGeneration != sessionGeneration) return@launch - wsClient.sendMessage( - agentSessionId, - fullText, - onSent = { id -> - trackSessionRequest( - id = id, - method = WsMethods.PROMPT_SUBMIT, - generation = dispatchGeneration, - sessionId = storageSessionId, + if (!queued) prepareTurnCorrelation(owner.storageSessionId) + val sent = + enqueueOwned { + wsClient.sendMessage( + owner.agentSessionId, + fullText, + onSent = { id -> + trackOutgoingRequest( + id, + msgToPersist.id, + WsMethods.PROMPT_SUBMIT, + dispatchGeneration, + owner.storageSessionId, + ) + }, + queued = queued, ) - }, - ) + } + if (sent == null) return@launch } } finally { preparedAttachments.forEach { it.encodedFile.delete() } @@ -2139,6 +3447,7 @@ class ChatViewModel( ) { Log.w(TAG, "Rejecting oversized attachment: ${attachment.name}") if (generation != sessionGeneration) return + removePendingSend(message.id) _uiState.update { state -> state.copy( messages = state.messages.filterNot { it.id == message.id }, @@ -2221,13 +3530,21 @@ class ChatViewModel( role = MessageRole.USER, content = displayContent, tokenCount = TokenEstimator.estimate(displayContent).takeIf { it > 0 }, + // Follow-up to #1253: stripped /queue text is an unconfirmed prompt, + // not a permanently-local command or an ambiguous legacy cache row. + messageProvenance = + if (result is SlashResult.QueuePrompt && displayContent != command) { + MessageProvenance.LOCAL_PENDING + } else { + MessageProvenance.UNKNOWN + }, ) val sessionId = _uiState.value.currentSessionId _uiState.update { it.copy(messages = it.messages + userMsg) } // Persist — OUTSIDE update{} - if (sessionId != null) { + if (sessionId != null && result !is SlashResult.QueuePrompt) { viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMsg, sessionId) } @@ -2306,7 +3623,7 @@ class ChatViewModel( } is SlashResult.QueuePrompt -> { - handleQueueCommand(command) + handleQueueCommand(command, userMsg) } is SlashResult.SideQuestion -> { @@ -2332,13 +3649,54 @@ class ChatViewModel( * `command.dispatch` `queue` shim only echoes the text back as a plain * submit, which loses the queued flag and hijacks the live turn. */ - private fun handleQueueCommand(command: String) { + private fun handleQueueCommand( + command: String, + userMessage: ChatMessage, + ) { val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() if (arg.isBlank()) { addAssistantMessage("usage: /queue ") return } - submitPrompt(arg, queued = true) + val storageId = _uiState.value.currentSessionId ?: return + val runtimeId = runtimeSessionId ?: return + val busy = + mainTurnBusy || + sendStore.all().any { + it.scope == sendScope() && it.sessionId == storageId && + it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + } + try { + sendStore.put( + PendingSend( + id = userMessage.id, + scope = sendScope(), + sessionId = storageId, + text = arg, + mode = BusySendMode.QUEUE, + state = if (busy) PendingSendState.QUEUED else PendingSendState.SENDING, + ), + ) + } catch (e: Exception) { + removeUnconfirmedBubble(userMessage.id) + _uiState.update { it.copy(errorMessage = "Could not save message for delivery") } + return + } + publishPendingSends() + if (busy) { + viewModelScope.launch(ioDispatcher) { repo.persistMessage(userMessage, storageId) } + } else { + dispatchPrompt( + text = arg, + attachments = emptyList(), + wasStreaming = false, + storageSessionId = storageId, + agentSessionId = runtimeId, + userMessage = userMessage, + mode = BusySendMode.QUEUE, + queued = true, + ) + } } private fun handleReasoningSlashCommand(arg: String) { @@ -2737,6 +4095,10 @@ class ChatViewModel( fun interruptSession() { val sessionId = runtimeSessionId ?: return + _uiState.value.currentSessionId?.let { storageId -> + sendStore.park(sendScope(), storageId) + publishPendingSends() + } viewModelScope.launch(ioDispatcher) { wsClient.send( WsMethods.SESSION_INTERRUPT, @@ -2895,6 +4257,7 @@ class ChatViewModel( _uiState.update { state -> state.copy( typingEffectEnabled = AuthManager.isTypingEffectEnabled(), + busySendMode = AuthManager.getBusySendMode(), typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), messageStatsEnabled = AuthManager.isMessageStatsEnabled(), showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(), @@ -2903,6 +4266,7 @@ class ChatViewModel( showModelProvider = AuthManager.isModelProviderShown(), ) } + publishPendingSends() } /** @@ -3254,19 +4618,93 @@ class ChatViewModel( ): List? { while (isCurrent()) { val snapshot = _uiState.value + // Receipt bubbles can be absent after rejection/reconnect. Offer their identities + // to the canonical matcher without displaying unconfirmed messages again. + val visibleIds = snapshot.messages.map { it.id }.toSet() + val currentSendScope = sendScope() + val receiptRows = + sendStore + .all() + .filter { it.scope == currentSendScope && it.sessionId == snapshot.currentSessionId } + val receiptBackedIds = + receiptRows + .filter { + it.state in + setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.mapTo(mutableSetOf()) { it.id } + val receiptCandidates = + if (cached) { + emptyList() + } else { + receiptRows + .filter { + it.id !in visibleIds && + it.state in + setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN) + }.map { + ChatMessage( + id = it.id, + role = MessageRole.USER, + content = it.text, + attachments = it.attachments, + timestamp = it.createdAt, + ) + } + } + val receiptCandidateIds = receiptCandidates.map { it.id }.toSet() val computed = withContext(historyDispatcher) { - val page = mapPage(snapshot.messages) + val mapped = mapPage(snapshot.messages) + val currentById = snapshot.messages.associateBy { it.id } + // Durable provenance distinguishes new unsent prompts after process death. + // Legacy UUID-only USER rows remain unconfirmed, but restored placement is + // independent of delivery: UNKNOWN must not pin old prompts after fresh replies. + val page = + if (cached) { + mapped.map { message -> + message.copy( + isHistoricalCache = + when { + currentById[message.id]?.isHistoricalCache == false -> false + message.isPermanentlyLocal() -> false + message.messageProvenance == MessageProvenance.LOCAL_PENDING -> false + message.canonicalRestId != null -> true + message.role == MessageRole.USER -> false + else -> true + }, + isRestoredUnconfirmed = + when { + message.role != MessageRole.USER || + message.canonicalRestId != null || + message.isPermanentlyLocal() || + message.messageProvenance == MessageProvenance.LOCAL_PENDING || + message.id in receiptBackedIds -> { + false + } + + currentById[message.id] != null -> { + currentById.getValue(message.id).isRestoredUnconfirmed + } + + else -> { + true + } + }, + ) + } + } else { + mapped + } val merged = if (cached) { mergeCachedTranscriptPage(page, snapshot.messages) } else { mergeTranscriptWithLive( page, - snapshot.messages, + snapshot.messages + receiptCandidates, chronological = !prepend, preserveLiveIds = true, - ) + ).filterNot { it.id in receiptCandidateIds && it.canonicalRestId == null } } val stableMessages = if (merged == snapshot.messages) snapshot.messages else merged Triple(page, stableMessages, hydrateTodosFromMessages(merged).ifEmpty { snapshot.todos }) @@ -3446,6 +4884,12 @@ class ChatViewModel( ) repo.confirmIdentities(aliases, sessionId) } + val scope = sendScope() + val scopedPending = + sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } + pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend) + publishPendingSends() + drainPendingQueue() } // ── Session resume recovery (desktop parity) ───────────────────────── @@ -3481,7 +4925,12 @@ class ChatViewModel( isLoading: Boolean, ): Long { val generation = ++sessionGeneration + // A slow drain belongs to the old session and must not block the resumed queue. + queueDrainJob?.cancel() + queueDrainJob = null connectionOperationDelegate.reset() + mainTurnBusy = false + lastMainCompletionAt = 0L cancelResumeRetry() contextUsageJob?.cancel() contextUsageJob = null @@ -3515,6 +4964,7 @@ class ChatViewModel( currentSessionId = sessionId, chatTitle = title, isAgentTyping = false, + isSending = false, isThinking = false, thinkingText = "", isLoading = isLoading, @@ -3522,6 +4972,8 @@ class ChatViewModel( hasOlderMessages = false, streamingMessage = null, errorMessage = null, + replyFailure = null, + replyFailureProjection = null, openError = null, clarifyRequest = null, sudoPrompt = null, @@ -3553,6 +5005,7 @@ class ChatViewModel( pendingPrefillText = null, ) } + publishPendingSends() return generation } @@ -3618,6 +5071,7 @@ class ChatViewModel( isSessionReady = runtimeSessionId != null, ) } + drainPendingQueue() } private fun resumeRetryDelayMs(attempt: Int): Long = 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 4f9088cd..28d248be 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 @@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix( * Android or ViewModel classes. Easy to unit test. */ object ChatWsEventReducer { + /** + * Projects retained terminal state without consulting any live streaming buffers. + * Only `inflight.assistant` may become assistant prose; stale UI text, reasoning, + * and message ids are deliberately excluded. + */ + fun reduceRetainedReplyFailure( + state: ChatUiState, + inflight: Map, + currentSessionId: String, + ): ReducerResult { + if (inflight["status"] != "error") return ReducerResult(state, StreamingState()) + val assistant = (inflight["assistant"] as? String).orEmpty() + val payload = inflight.toMutableMap().apply { put("partial", assistant.isNotBlank()) } + val cleanState = + state.copy( + isAgentTyping = false, + isThinking = false, + thinkingText = "", + streamingMessage = null, + ) + return reduce( + cleanState, + StreamingState(), + WsEvent.MessageComplete( + text = assistant, + sessionId = currentSessionId, + // Retained inflight has no completion identity; suppress the event's random default. + completionId = "", + rawPayload = payload, + ), + currentSessionId, + ) + } + fun reduce( state: ChatUiState, streamingState: StreamingState, @@ -239,6 +273,18 @@ object ChatWsEventReducer { streamingState: StreamingState, event: WsEvent.MessageStart, ): ReducerResult { + val previousFailureMessageId = state.replyFailureProjection?.messageId + val turnState = + state.copy( + messages = + if (previousFailureMessageId == null) { + state.messages + } else { + state.messages.filterNot { it.id == previousFailureMessageId } + }, + replyFailure = null, + replyFailureProjection = null, + ) val msg = ChatMessage( role = MessageRole.ASSISTANT, @@ -252,10 +298,10 @@ object ChatWsEventReducer { val effects = mutableListOf() val cleanedSubagents = - if (state.subagentIndicators.all { it.isComplete || it.isFailed }) { + if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) { emptyList() } else { - state.subagentIndicators + turnState.subagentIndicators } // Build new state: finalize any orphan streaming message, then set the new one @@ -270,13 +316,13 @@ object ChatWsEventReducer { tps = null, ) orphan = finalized - state.copy( - messages = state.messages.upsertById(finalized), + turnState.copy( + messages = turnState.messages.upsertById(finalized), isAgentTyping = true, subagentIndicators = cleanedSubagents, ) } else { - state.copy( + turnState.copy( isAgentTyping = true, subagentIndicators = cleanedSubagents, ) @@ -406,6 +452,8 @@ object ChatWsEventReducer { streamingState: StreamingState, event: WsEvent.MessageComplete, ): ReducerResult { + val failure = replyFailureFromPayload(event.rawPayload, event.text) + if (failure != null) return onReplyFailure(state, streamingState, event, failure) val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) @@ -520,6 +568,86 @@ object ChatWsEventReducer { ) } + // Keep terminal diagnostics out of normal assistant messages and persistence. + private fun onReplyFailure( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.MessageComplete, + failure: ReplyFailure, + ): ReducerResult { + val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state + val streaming = streamingState.streamingMessage + val text = + if (event.rawPayload?.get("partial") == true) { + event.text + .takeIf { it.isNotBlank() } + ?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) } + ?: streaming?.content.orEmpty() + } else { + streaming?.content.orEmpty() + } + val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() } + val existingProjection = state.replyFailureProjection + val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId + val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty() + val failureId = + existingProjection?.failureId + ?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" } + ?: failure.id + val projectionMessageId = + existingProjection?.messageId + ?: streaming?.id + ?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" } + ?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null + val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId) + val existingProjectedMessage = + projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } } + val partial = + if (text.isNotBlank() || reasoning.isNotBlank()) { + val base = + streaming + ?: existingProjectedMessage + ?: ChatMessage( + id = checkNotNull(projectionMessageId), + role = MessageRole.ASSISTANT, + content = "", + ) + base.copy( + id = checkNotNull(projectionMessageId), + content = text, + reasoningText = reasoning, + isStreaming = false, + finishTimestamp = System.currentTimeMillis(), + completionId = completionId, + ) + } else { + null + } + val messagesWithoutOldProjection = + existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } } + ?: usageState.messages + val projectedMessages = + partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection + // A failed partial is a transient projection of gateway `inflight`, not a + // successful transcript row. Persisting it would erase failure provenance on + // hydration and make a later session reopen look healthy. + val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage) + return ReducerResult( + state = + usageState.copy( + messages = projectedMessages, + replyFailure = failure.copy(id = failureId), + replyFailureProjection = projection, + isAgentTyping = false, + isThinking = false, + streamingMessage = null, + clarifyRequest = null, + ), + streamingState = StreamingState(), + effects = effects, + ) + } + // ── MessageDone ─────────────────────────────────────────────────── private fun onMessageDone( @@ -681,6 +809,7 @@ object ChatWsEventReducer { messages[toolIdx].copy( toolStatus = ToolStatus.COMPLETED, content = contentJson, + isHistoricalCache = false, ) messages[toolIdx] = updated @@ -928,7 +1057,11 @@ object ChatWsEventReducer { val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) - messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "") + messages[toolIdx] = + messages[toolIdx].copy( + progressPreview = event.preview ?: "", + isHistoricalCache = false, + ) return ReducerResult( state = state.copy(messages = messages), streamingState = streamingState, @@ -944,14 +1077,14 @@ object ChatWsEventReducer { val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState) - messages[toolIdx] = messages[toolIdx].copy(progressPreview = "") + messages[toolIdx] = messages[toolIdx].copy(progressPreview = "", isHistoricalCache = false) return ReducerResult( state = state.copy(messages = messages), streamingState = streamingState, ) } - /** Match stable tool IDs first, with name matching for legacy events. */ + /** Match an explicit stable ID exclusively; use name matching only when legacy events omit the ID. */ private fun findToolIndex( messages: List, name: String?, @@ -965,7 +1098,8 @@ object ChatWsEventReducer { it.toolCallId == toolId && it.toolStatus == status } - if (byId >= 0) return byId + // An explicit identity must never claim a different historical invocation. + return byId } return messages.indexOfLast { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 6a1feeb2..836712b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -443,7 +443,15 @@ fun MarkdownText( } is MdBlock.Table -> { - MarkdownTable(block = block, textColor = textColor) + MarkdownTable( + block = block, + textColor = textColor, + latexMeasurer = latexMeasurer, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) } is MdBlock.Footnotes -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt new file mode 100644 index 00000000..cca7527c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ReplyFailure.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.ui.chat + +import java.util.UUID + +/** Session-scoped presentation, never a send receipt or a persisted transcript row. */ +data class ReplyFailure( + val details: String, + val id: String = UUID.randomUUID().toString(), +) + +/** Stable identity for the one transient failed-reply projection in a session lifecycle. */ +data class ReplyFailureProjection( + val messageId: String?, + val failureId: String, + val completionId: String? = null, +) + +/** Only a terminal turn failure is a failed reply; RPC/transport errors are separate. */ +internal fun replyFailureFromPayload( + payload: Map?, + fallback: String, +): ReplyFailure? { + if (payload?.get("status") != "error") return null + val surface = payload["error_surface"] as? Map<*, *> + val details = + buildList { + for (key in listOf("layer", "code", "provider", "model")) { + (surface?.get(key) as? String)?.takeIf { it.isNotBlank() }?.let { add("$key: $it") } + } + val error = (payload["error"] as? String)?.takeIf { it.isNotBlank() } + add(error ?: if (payload["partial"] == true) "Unknown gateway error" else fallback) + }.joinToString("\n") + return ReplyFailure(sanitizeReplyErrorDetails(details)) +} + +/** Best-effort scrubbing before display/copy/share; never includes a transcript or request payload. */ +internal fun sanitizeReplyErrorDetails(raw: String?): String { + if (raw.isNullOrBlank()) return "Unknown gateway error" + var safe = raw.take(8_192) + safe = PRIVATE_KEY.replace(safe, "[REDACTED]") + safe = HEADER.replace(safe) { "${it.groupValues[1]}[REDACTED]" } + safe = URL.replace(safe, "[REDACTED URL]") + safe = SECRET_ASSIGNMENT.replace(safe) { "${it.groupValues[1]}[REDACTED]" } + safe = BEARER.replace(safe, "Bearer [REDACTED]") + safe = PROVIDER_KEY.replace(safe, "[REDACTED]") + return safe.take(8_192) +} + +private val PRIVATE_KEY = Regex("-----BEGIN [^-]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)") +private val HEADER = Regex("(?im)^([ \\t]*(?:authorization|proxy-authorization|set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*") +private val URL = Regex("(?i)(?:https?|wss?)://[^\\s<>\\\"']+") +private val SECRET_ASSIGNMENT = + Regex( + "(?i)((?= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + mediaRecorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(16_000) + setAudioEncodingBitRate(32_000) + setAudioChannels(1) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorder = mediaRecorder + outputFile = file + startedAtMs = SystemClock.elapsedRealtime() + Log.i(TAG, "Voice note recording started: ${file.name}") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to start voice note recording", e) + releaseQuietly() + file.delete() + false + } + } + + /** + * Stops recording and returns the clip when it is long enough to send. + * Returns null for too-short or failed recordings — the temp file is + * removed in both cases. + */ + fun stop(): File? { + val active = recorder ?: return null + val file = outputFile + val durationMs = SystemClock.elapsedRealtime() - startedAtMs + val stopped = runCatching { active.stop() }.isSuccess + releaseQuietly() + val byteCount = file?.length() ?: -1L + Log.i(TAG, "Voice note stop: durationMs=$durationMs stopped=$stopped bytes=$byteCount") + if (!stopped || file == null || durationMs < MIN_DURATION_MS) { + file?.delete() + return null + } + return file + } + + /** Aborts the recording and deletes the clip. */ + fun cancel() { + if (recorder != null) { + runCatching { recorder?.stop() } + } + releaseQuietly() + outputFile?.delete() + } + + /** Peak amplitude of the current recording window — 0 when idle. */ + fun currentAmplitude(): Int = runCatching { recorder?.maxAmplitude ?: 0 }.getOrDefault(0) + + private fun releaseQuietly() { + runCatching { recorder?.release() } + recorder = null + outputFile = null + startedAtMs = 0L + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index cf67124f..ea3651f2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -37,6 +38,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog @@ -75,9 +79,12 @@ fun ChatInputBar( inputFieldValue: TextFieldValue, onInputChange: (TextFieldValue) -> Unit, onSend: () -> Unit, + onBusySend: (BusySendMode) -> Unit = {}, onMicTap: () -> Unit, isListening: Boolean, isAgentTyping: Boolean, + isMainTurnBusy: Boolean = false, + canInterrupt: Boolean = false, isConnected: Boolean, commandCatalog: CommandCatalog, isSessionReady: Boolean, @@ -103,6 +110,12 @@ fun ChatInputBar( showModelProvider: Boolean = false, reasoningWireLevel: String? = null, pendingReasoningLevel: String? = null, + onMicHoldStart: () -> Unit = {}, + onMicHoldEnd: () -> Unit = {}, + onMicHoldCancel: () -> Unit = {}, + isRecordingVoice: Boolean = false, + voiceNoteAmplitude: State = remember { mutableStateOf(0f) }, + onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -111,10 +124,14 @@ fun ChatInputBar( val canSend = pendingReasoningLevel == null && ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) + val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty() + val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/") + val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } val palette = composerPalette() + BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } AnimatedVisibility( visible = true, @@ -260,55 +277,64 @@ fun ChatInputBar( } val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = onInputChange, - modifier = - Modifier - .weight(1f) - .heightIn(min = 42.dp, max = 200.dp) - .padding(vertical = 4.dp) - .testTag("chat_input"), - enabled = isConnected, - textStyle = - MaterialTheme.typography.bodyLarge.copy( - color = palette.text, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 8, - cursorBrush = SolidColor(palette.text), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyLarge, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - color = palette.placeholder, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + if (isRecordingVoice) { + VoiceNoteRecordingPanel( + amplitude = voiceNoteAmplitude, + modifier = Modifier.weight(1f), + ) + } else { + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + BasicTextField( + value = inputFieldValue, + onValueChange = onInputChange, + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .testTag("chat_input"), + enabled = isConnected, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 8, + cursorBrush = SolidColor(palette.text), + decorationBox = { innerTextField -> + CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + innerTextField() } - innerTextField() } } - } - }, - ) + }, + ) + } } } @@ -319,12 +345,20 @@ fun ChatInputBar( reasoningLevel = reasoningLevel, isListening = isListening, canSend = canSend, - showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(), + showSend = hasDraft, + showQueue = showBusyActions, onSend = onSend, + onQueue = { onBusySend(BusySendMode.QUEUE) }, + onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) }, + canInterrupt = canInterrupt, + onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, onReasoningSelected = onReasoningTap, onMicTap = onMicTap, + onMicHoldStart = onMicHoldStart, + onMicHoldEnd = onMicHoldEnd, + onMicHoldCancel = onMicHoldCancel, modifier = Modifier.testTag("chat_composer_toolbar"), canDisableReasoning = canDisableReasoning, supportsReasoning = supportsReasoning, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt index afb68002..fe8dd7d8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMediaLaunchers.kt @@ -12,6 +12,9 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +30,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.SpeechInputHelper +import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder +import kotlinx.coroutines.delay import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -34,10 +39,15 @@ import java.util.Locale class ChatMediaLaunchers( val isListening: Boolean, + val isRecordingVoice: Boolean, + val voiceNoteAmplitude: State, val onMicTap: () -> Unit, val onCameraTap: () -> Unit, val onImageTap: () -> Unit, val onFileTap: () -> Unit, + val onMicHoldStart: () -> Unit = {}, + val onMicHoldEnd: () -> Unit = {}, + val onMicHoldCancel: () -> Unit = {}, ) @Composable @@ -46,8 +56,10 @@ fun rememberChatMediaLaunchers( onInputFieldValueChange: (TextFieldValue) -> Unit, onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, onAddAttachments: (List) -> Unit, + onVoiceNoteRecorded: (file: File) -> Unit, onShowMessage: (String) -> Unit, launchExternalActivity: (() -> Unit) -> Unit, + isTranscribingVoiceNote: Boolean, context: Context = LocalContext.current, ): ChatMediaLaunchers { var isListening by remember { mutableStateOf(false) } @@ -57,6 +69,7 @@ fun rememberChatMediaLaunchers( val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange) val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) + val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded) val currentOnShowMessage by rememberUpdatedState(onShowMessage) val currentLaunchExternalActivity by rememberUpdatedState(launchExternalActivity) @@ -64,6 +77,59 @@ fun rememberChatMediaLaunchers( val sttNotAvailableMsg = stringResource(R.string.stt_not_available) val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) val cameraErrorMsg = stringResource(R.string.chat_camera_error) + val voiceRecordFailedMsg = stringResource(R.string.chat_voice_record_failed) + + // Hold-to-record voice note recorder: the clip uploads to the dashboard + // for server-side transcription, unlike the flat tap path below, which + // still drives the on-device recognizer. + val voiceNoteRecorder = remember { VoiceNoteRecorder(context) } + var isRecordingVoice by remember { mutableStateOf(false) } + + // Live mic level for the recording panel — rises fast, decays slowly so + // the meter reads as voice activity instead of flicker. + val voiceNoteAmplitude = remember { mutableStateOf(0f) } + LaunchedEffect(Unit) { + while (true) { + delay(90) + val previous = voiceNoteAmplitude.value + val next = + if (voiceNoteRecorder.isActive) { + maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f) + } else { + 0f + } + if (next != previous) { + voiceNoteAmplitude.value = next + } + } + } + + fun finishVoiceRecording() { + val recordedFile = voiceNoteRecorder.stop() + isRecordingVoice = false + if (recordedFile != null) { + currentOnVoiceNoteRecorded(recordedFile) + } + } + + // App-owned max-duration cap: the timer drives the same + // finishVoiceRecording() path as the hold release, so exactly one code + // path ever calls stop() — MediaRecorder's own auto-stop used to race + // the release and discard a valid max-length clip (review, PR #1250). + LaunchedEffect(isRecordingVoice) { + if (isRecordingVoice) { + delay(VoiceNoteRecorder.MAX_DURATION_MS.toLong()) + if (voiceNoteRecorder.isActive) { + finishVoiceRecording() + } + } + } + + DisposableEffect(voiceNoteRecorder) { + onDispose { + voiceNoteRecorder.cancel() + } + } // Speech-to-text recognition launcher (issue #194) val speechLauncher = @@ -117,6 +183,18 @@ fun rememberChatMediaLaunchers( } } + // Hold-to-record permission: unlike the launcher above, granting does NOT + // auto-start dictation — the user records by holding the mic again. + val voiceNotePermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + ExternalActivityLifecycleGuard.externalActivityReturned() + if (!granted) { + currentOnShowMessage(sttPermissionDeniedMsg) + } + } + // Multi-file picker for attachments (issue #195) val filePickerLauncher = rememberLauncherForActivityResult( @@ -183,7 +261,14 @@ fun rememberChatMediaLaunchers( } val onMicTap: () -> Unit = { - if (isListening) { + if (isTranscribingVoiceNote) { + // Single-flight: a server transcription owns the voice pipeline + // until it lands (review, PR #1250). + } else if (isRecordingVoice) { + // A tap while recording discards the in-flight voice note. + voiceNoteRecorder.cancel() + isRecordingVoice = false + } else if (isListening) { isListening = false } else if ( ContextCompat.checkSelfPermission( @@ -212,6 +297,45 @@ fun rememberChatMediaLaunchers( } } + val onMicHoldStart: () -> Unit = { + if (isTranscribingVoiceNote) { + // Single-flight: no new recording while a transcription runs. + } else if (voiceNoteRecorder.isActive || isListening) { + // A voice note or a dictation session is already running. + } else if ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) { + if (voiceNoteRecorder.start()) { + isRecordingVoice = true + } else { + currentOnShowMessage(voiceRecordFailedMsg) + } + } else { + // The denial message comes from voiceNotePermissionLauncher's + // result callback only — showing it here fired it before the + // dialog even opened (review, PR #1250). + currentLaunchExternalActivity { + voiceNotePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + val onMicHoldEnd: () -> Unit = { + if (voiceNoteRecorder.isActive) { + finishVoiceRecording() + } + } + + val onMicHoldCancel: () -> Unit = { + if (voiceNoteRecorder.isActive) { + voiceNoteRecorder.cancel() + } + isRecordingVoice = false + } + val onCameraTap: () -> Unit = { try { val timeStamp = @@ -245,13 +369,18 @@ fun rememberChatMediaLaunchers( } } - return remember(isListening) { + return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) { ChatMediaLaunchers( - isListening = isListening, + isListening = isListening || isRecordingVoice, + isRecordingVoice = isRecordingVoice, + voiceNoteAmplitude = voiceNoteAmplitude, onMicTap = onMicTap, onCameraTap = onCameraTap, onImageTap = onImageTap, onFileTap = onFileTap, + onMicHoldStart = onMicHoldStart, + onMicHoldEnd = onMicHoldEnd, + onMicHoldCancel = onMicHoldCancel, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index bfee7e24..9fcd992b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -6,8 +6,12 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,6 +31,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddToQueue import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Stop @@ -48,20 +53,27 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics 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 kotlinx.coroutines.withTimeoutOrNull /** * Bottom controls row for the chat composer, rendered inside the composer card. @@ -83,6 +95,7 @@ import com.m57.hermescontrol.R * When [supportsReasoning] is false the model takes no reasoning parameter * and the level menu is disabled. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun ComposerToolbar( isConnected: Boolean, @@ -96,7 +109,10 @@ fun ComposerToolbar( modifier: Modifier = Modifier, canSend: Boolean = false, showSend: Boolean = canSend, + showQueue: Boolean = false, onSend: () -> Unit = {}, + onQueue: () -> Unit = {}, + onStopAndSend: () -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, fastMode: Boolean = false, @@ -107,12 +123,50 @@ fun ComposerToolbar( reasoningWireLevel: String? = null, pendingReasoningLevel: String? = null, isSessionReady: Boolean = true, + canInterrupt: Boolean = false, + onMicHoldStart: () -> Unit = {}, + onMicHoldEnd: () -> Unit = {}, + onMicHoldCancel: () -> Unit = {}, + onStopGeneration: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } val palette = composerPalette() val reasoningDisabledForModel = supportsReasoning == false val canDisable = canDisableReasoning + // Telegram-style voice notes: hold to record, release to send, slide away + // to cancel. Both mic controls run the same single pointer loop + // (micHoldHandler), which owns the whole press until finger-up — so the + // release that submits the note can never be lost between recompositions. + val currentIsConnected = rememberUpdatedState(isConnected) + val currentShowSend = rememberUpdatedState(showSend) + val currentCanInterrupt = rememberUpdatedState(canInterrupt) + val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart) + val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd) + val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel) + val flatMicGesture = + Modifier.pointerInput(Unit) { + micHoldHandler( + isEnabled = { + currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value + }, + onHoldStart = { currentOnMicHoldStart.value() }, + onHoldEnd = { currentOnMicHoldEnd.value() }, + onHoldCancel = { currentOnMicHoldCancel.value() }, + ) + } + val actionMicGesture = + Modifier.pointerInput(Unit) { + micHoldHandler( + isEnabled = { + currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value + }, + onHoldStart = { currentOnMicHoldStart.value() }, + onHoldEnd = { currentOnMicHoldEnd.value() }, + onHoldCancel = { currentOnMicHoldCancel.value() }, + ) + } + Row( modifier = modifier @@ -408,55 +462,118 @@ fun ComposerToolbar( } } - // Flat mic / stop button — only while the action button is in send mode + // Flat slot — mic while idle; while a generation can be interrupted it + // keeps queue-send available (the action button carries Stop). During + // session preparation the mic stays here and the action button is a + // disabled send: Stop must not appear when there is nothing to + // interrupt (review, PR #1250). AnimatedVisibility( visible = showSend, enter = fadeIn() + scaleIn(initialScale = 0.8f), exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + if (canInterrupt) { + FilledIconButton( + onClick = onSend, + enabled = canSend, + colors = flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag("send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + } else { + FilledIconButton( + onClick = onMicTap, + enabled = isConnected, + colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag(if (isListening) "mic_stop_button" else "mic_button") + .then(flatMicGesture), + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, + contentDescription = if (isListening) "Stop listening" else "Mic", + ) + } + } + } + + AnimatedVisibility( + visible = showQueue, + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), ) { FilledIconButton( - onClick = onMicTap, - enabled = isConnected, - colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), - modifier = - Modifier - .size(ControlSize) - .testTag(if (isListening) "mic_stop_button" else "mic_button"), + onClick = onQueue, + enabled = canSend, + colors = flatIconButtonColors(palette), + modifier = Modifier.size(48.dp).testTag("queue_button"), ) { Icon( - imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, - contentDescription = if (isListening) "Stop listening" else "Mic", + imageVector = Icons.Default.AddToQueue, + contentDescription = stringResource(R.string.chat_busy_queue_action), + modifier = Modifier.size(20.dp), ) } } // Action button — send when a send is possible, mic / stop otherwise - FilledIconButton( - onClick = if (showSend) onSend else onMicTap, - enabled = if (showSend) canSend else isConnected, - colors = - if (!showSend && isListening) { - listeningIconButtonColors() - } else { - IconButtonDefaults.filledIconButtonColors( - containerColor = palette.action, - contentColor = palette.onAction, - ) - }, + val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send) + val actionEnabled = if (showSend) canSend else isConnected + Box( modifier = Modifier - .size(ControlSize) - .testTag( + .size(if (showSend) 48.dp else ControlSize) + .clip(CircleShape) + .background( + if (!showSend && isListening) { + MaterialTheme.colorScheme.errorContainer + } else { + palette.action + }, + ).semantics { + if (showSend && showQueue && canSend) { + customActions = + listOf( + CustomAccessibilityAction(stopAndSendLabel) { + onStopAndSend() + true + }, + ) + } + }.combinedClickable( + enabled = actionEnabled, + onClick = { + when { + canInterrupt -> onStopGeneration() + showSend -> onSend() + else -> onMicTap() + } + }, + onLongClick = if (showSend && showQueue) onStopAndSend else null, + onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null, + ).testTag( when { + canInterrupt -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" }, - ), + ).then(if (showSend || canInterrupt) Modifier else actionMicGesture), + contentAlignment = Alignment.Center, ) { Crossfade( targetState = when { + canInterrupt -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP else -> ActionGlyph.VOICE @@ -468,15 +585,34 @@ fun ComposerToolbar( Icon( imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.chat_send_desc), + tint = palette.onAction, ) } ActionGlyph.STOP -> { - Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening") + Icon( + imageVector = Icons.Default.Stop, + contentDescription = + if (canInterrupt) { + stringResource(R.string.chat_voice_stop_generating) + } else { + "Stop listening" + }, + tint = + if (isListening && !canInterrupt) { + MaterialTheme.colorScheme.onErrorContainer + } else { + palette.onAction + }, + ) } ActionGlyph.VOICE -> { - Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic") + Icon( + imageVector = Icons.Outlined.Mic, + contentDescription = "Mic", + tint = palette.onAction, + ) } } } @@ -581,3 +717,91 @@ fun buildReasoningLabel( } return reqLabel } + +/** + * Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS] + * records a voice note, release sends it, and sliding away (left or up) from + * the button cancels instead of sending. Short taps use the control's normal + * click handler for dictation, so touch and accessibility actions share one + * callback path. + * + * The whole down-to-up sequence lives in this one pointer loop, so the release + * that submits can never be lost to a recomposition between press and + * finger-up — an earlier interaction-source based approach could lose it and + * leave the recorder running until some later tap "sent" the stale clip. + */ +private suspend fun PointerInputScope.micHoldHandler( + isEnabled: () -> Boolean, + onHoldStart: () -> Unit, + onHoldEnd: () -> Unit, + onHoldCancel: () -> Unit, +) { + val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx() + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + if (!isEnabled()) { + return@awaitEachGesture + } + + fun slidAway(position: Offset): Boolean { + val dx = position.x - down.position.x + val dy = position.y - down.position.y + return dx < -cancelSlop || dy < -cancelSlop + } + + // Phase 1 — a finger-up before the threshold is a plain tap. + var earlyUp = false + var gestureAborted = false + withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) { + while (true) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { it.id == down.id } + ?: run { + gestureAborted = true + return@withTimeoutOrNull + } + if (!change.pressed) { + earlyUp = true + return@withTimeoutOrNull + } + if (change.isConsumed || slidAway(change.position)) { + gestureAborted = true + return@withTimeoutOrNull + } + } + } + if (gestureAborted) { + return@awaitEachGesture + } + if (earlyUp) { + return@awaitEachGesture + } + + // Phase 2 — the threshold passed with the finger down: record until + // the finger lifts (send) or slides away (cancel). No timeout here. + onHoldStart() + var send = false + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + send = true + break + } + if (change.isConsumed || slidAway(change.position)) { + break + } + } + } finally { + if (send) onHoldEnd() else onHoldCancel() + } + } +} + +/** A press shorter than this is a tap; longer arms voice-note recording. */ +private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L + +/** Sliding this far from the mic button (left or up) cancels the recording. */ +private const val MIC_SLIDE_CANCEL_DP = 64 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 6bd00aaa..df4038d9 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 @@ -89,6 +89,7 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.SubagentIndicator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -101,7 +102,8 @@ import kotlinx.coroutines.withContext * (reasoning-model thinking steps) before the final answer. * * Collapsed: "🧠 Reasoning · {N} steps" with chevron. - * Expanded: reasoning text in monospace bodySmall, capped at ~40% of the + * Expanded: completed reasoning is Markdown; streaming text stays raw, + * capped at ~40% of the * screen height with internal scroll; "Show full" lifts the cap. * Long-press anywhere: copies the reasoning trace to the clipboard. * Streaming: pulsing indicator at the bottom while [isStreaming]. @@ -180,26 +182,35 @@ fun ReasoningCard( } AnimatedVisibility(visible = expanded) { Column { - Text( - text = reasoningText, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier - .padding(top = 4.dp) - .then( - if (!fullHeight) { - // Capped at ~40% of the screen with internal scroll. - Modifier.heightIn(max = capHeight).verticalScroll(scrollState) - } else { - // "Show full": render the whole trace at natural height. - // A scrollable must NEVER be measured with an infinite - // max height — inside a LazyColumn item (unbounded main - // axis) that combination throws IllegalStateException. - Modifier - }, - ), - ) + val contentModifier = + Modifier + .padding(top = 4.dp) + .then( + if (!fullHeight) { + // Capped at ~40% of the screen with internal scroll. + Modifier.heightIn(max = capHeight).verticalScroll(scrollState) + } else { + // "Show full": render the whole trace at natural height. + // A scrollable must NEVER be measured with an infinite + // max height — inside a LazyColumn item (unbounded main + // axis) that combination throws IllegalStateException. + Modifier + }, + ) + if (isStreaming) { + Text( + text = reasoningText, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = contentModifier, + ) + } else { + MarkdownText( + text = reasoningText, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = contentModifier, + ) + } if (isStreaming) { ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp)) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt new file mode 100644 index 00000000..9147e2fe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/PendingSendPanel.kt @@ -0,0 +1,103 @@ +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.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.PendingSend +import com.m57.hermescontrol.ui.chat.PendingSendState + +@Composable +fun PendingSendPanel( + sends: List, + mainTurnBusy: Boolean, + onSendNow: (String) -> Unit, +) { + // Delivery receipts stay internal; only queued work and recovery need UI. + val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED } + if (visibleSends.isEmpty()) return + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall) + if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) { + Text( + stringResource(R.string.chat_pending_send_now_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) { + items(visibleSends, key = { it.id }) { send -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column(modifier = Modifier.weight(1f)) { + Text( + send.text.ifBlank { stringResource(R.string.chat_pending_attachments) }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + when (send.state) { + PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued) + PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked) + PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending) + PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted) + PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown) + PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (send.state in + setOf( + PendingSendState.QUEUED, + PendingSendState.PARKED, + PendingSendState.UNKNOWN, + PendingSendState.REJECTED, + PendingSendState.ACCEPTED, + ) + ) { + TextButton(onClick = { onSendNow(send.id) }) { + Text( + stringResource( + if (mainTurnBusy) { + R.string.chat_busy_stop_and_send + } else if (send.state in + setOf( + PendingSendState.UNKNOWN, + PendingSendState.ACCEPTED, + ) + ) { + R.string.chat_pending_send_again + } else { + R.string.chat_pending_send_now + }, + ), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCard.kt new file mode 100644 index 00000000..83e1e888 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReplyErrorCard.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ErrorOutline +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.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.ReplyFailure +import com.m57.hermescontrol.ui.chat.sanitizeReplyErrorDetails + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReplyErrorCard( + failure: ReplyFailure, + onDismiss: () -> Unit, + onOpenLogs: () -> Unit, + onCopy: (String) -> Unit, + onShare: (String) -> Unit, +) { + var expanded by rememberSaveable(failure.id) { mutableStateOf(false) } + + val details = remember(failure.details) { sanitizeReplyErrorDetails(failure.details) } + val detailsStateDescription = + stringResource( + if (expanded) { + R.string.chat_reply_failed_details_expanded + } else { + R.string.chat_reply_failed_details_collapsed + }, + ) + val colors = LocalHermesStatusColors.current + Surface( + modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("reply_error_card"), + shape = MaterialTheme.shapes.medium, + color = colors.errorContainer, + contentColor = colors.onErrorContainer, + border = BorderStroke(1.dp, colors.error), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.ErrorOutline, contentDescription = null) + Text( + stringResource(R.string.chat_reply_failed_title), + modifier = + Modifier + .weight( + 1f, + ).padding(start = 8.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + style = MaterialTheme.typography.titleSmall, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.chat_reply_failed_dismiss)) + } + } + Text(stringResource(R.string.chat_reply_failed_description), style = MaterialTheme.typography.bodyMedium) + TextButton( + onClick = { expanded = !expanded }, + modifier = Modifier.semantics { stateDescription = detailsStateDescription }, + ) { + Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null) + Text(stringResource(R.string.chat_reply_failed_details)) + } + if (expanded) { + SelectionContainer { + Text( + details, + modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + Text(stringResource(R.string.chat_reply_failed_share_note), style = MaterialTheme.typography.bodySmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onOpenLogs) { Text(stringResource(R.string.chat_reply_failed_logs)) } + OutlinedButton(onClick = { onCopy(details) }) { Text(stringResource(R.string.chat_reply_failed_copy)) } + OutlinedButton( + onClick = { onShare(details) }, + enabled = expanded, + ) { Text(stringResource(R.string.chat_reply_failed_share)) } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt new file mode 100644 index 00000000..1cca004a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteRecordingPanel.kt @@ -0,0 +1,128 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.os.SystemClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.unit.dp +import com.m57.hermescontrol.R +import kotlinx.coroutines.delay + +/** + * Telegram-style recording strip shown in place of the input field while a + * voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and + * a live mic level meter so the press and the captured audio are both visible + * even when the mic button itself is under the thumb. + */ +@Composable +internal fun VoiceNoteRecordingPanel( + amplitude: State, + modifier: Modifier = Modifier, +) { + val palette = composerPalette() + var elapsedSeconds by remember { mutableStateOf(0L) } + LaunchedEffect(Unit) { + val startedAt = SystemClock.elapsedRealtime() + while (true) { + elapsedSeconds = (SystemClock.elapsedRealtime() - startedAt) / 1000 + delay(200) + } + } + val levels = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + snapshotFlow { amplitude.value }.collect { level -> + levels.add(level) + while (levels.size > VOICE_NOTE_BARS) { + levels.removeAt(0) + } + } + } + Row( + modifier = + modifier + .heightIn(min = 42.dp) + .testTag("voice_note_recording_panel"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + // Decorative: the adjacent "Slide to cancel" text carries the + // instruction, so the arrow must not repeat it (review, PR #1250). + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(R.string.chat_voice_slide_to_cancel), + style = MaterialTheme.typography.labelMedium, + color = palette.placeholder, + maxLines = 1, + ) + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = + Modifier + .size(8.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), + ) + Text( + text = formatElapsed(elapsedSeconds), + style = MaterialTheme.typography.labelLarge, + color = palette.text, + ) + Row( + modifier = Modifier.height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + val filler = VOICE_NOTE_BARS - levels.size + repeat(VOICE_NOTE_BARS) { index -> + val level = if (index < filler) 0f else levels[index - filler] + Box( + modifier = + Modifier + .width(3.dp) + .height((4f + 18f * level).dp) + .background(palette.placeholder, RoundedCornerShape(2.dp)), + ) + } + } + } +} + +/** Number of level-meter bars in the recording panel. */ +private const val VOICE_NOTE_BARS = 14 + +/** Formats elapsed recording time as m:ss. */ +private fun formatElapsed(seconds: Long): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "$minutes:${secs.toString().padStart(2, '0')}" +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt index 1f6e1cc2..f9460c6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt @@ -20,8 +20,8 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry -import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.TypingIndicator +import com.m57.hermescontrol.ui.chat.isToolRunning import com.m57.hermescontrol.ui.chat.isUserTurnBoundary /** Live status for an agent turn before visible assistant prose is available. */ @@ -61,7 +61,7 @@ internal fun deriveAgentStatus( .asReversed() .firstOrNull { it.role == MessageRole.TOOL && - it.toolStatus == ToolStatus.RUNNING + it.isToolRunning } val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true 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 33d24c21..431b0195 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 @@ -104,8 +104,9 @@ fun FullBleedChatList( hasOlderMessages: Boolean = false, pagingSessionId: String? = null, onLoadOlder: () -> Unit = viewModel::loadOlderMessages, + replyErrorContent: (@Composable () -> Unit)? = null, ) { - if (messages.isEmpty() && !isLoading && !isAgentTyping) { + if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -145,6 +146,7 @@ fun FullBleedChatList( // A single ordered definition supplies both emitted tail rows and anchor keys. val tailItems = buildMap Unit> { + replyErrorContent?.let { put("reply_error", it) } agentStatus?.let { status -> put("agent_status") { AgentStatusIndicator(status = status) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt index 2aed606e..79c83b0c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -66,33 +66,84 @@ object MarkdownInlineStyler { return buildAnnotatedString { var i = 0 val src = text + while (i < src.length) { when { - // ***bold italic*** - src.startsWith("***", i) -> { - val end = src.indexOf("***", i + 3) - if (end != -1) { - val raw = src.substring(i + 3, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(toAppend) + // Inline code is opaque to emphasis parsing, and delimiters must match by run length. + src[i] == '`' -> { + var runEnd = i + while (runEnd < src.length && src[runEnd] == '`') runEnd++ + val runLength = runEnd - i + var end = runEnd + var matchingEnd = -1 + while (end < src.length) { + if (src[end] == '`') { + var candidateEnd = end + while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++ + if (candidateEnd - end == runLength) { + matchingEnd = end + break + } + end = candidateEnd + } else { + end++ } - i = end + 3 + } + if (matchingEnd >= 0) { + val raw = src.substring(runEnd, matchingEnd) + val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = textColor.copy(alpha = 0.08f), + ), + ) { + append(content) + } + i = matchingEnd + runLength } else { - append(src[i]) - i++ + append(src.substring(i, runEnd)) + i = runEnd + } + } + + // Combined / nested emphasis delimiters. + src.startsWith("***", i) -> { + val end = src.indexOf("***", i + 3) + if (end >= 0) { + val raw = src.substring(i + 3, end) + val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(content) + } + i = end + 3 + } else { + append("***") + i += 3 } } - // **bold** src.startsWith("**", i) -> { - val end = src.indexOf("**", i + 2) + var end = src.indexOf("**", i + 2) + if (end >= 0 && src.startsWith("***", end) && + src.substring(i + 2, end).count { it == '*' } % 2 == 1 + ) { + end++ // Inner italic closes with the first star of the trailing run. + } if (end != -1) { val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(toAppend) - } + val nested = + parseInlineSource( + raw, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) } i = end + 2 } else { append(src[i]) @@ -116,16 +167,24 @@ object MarkdownInlineStyler { } } - // *italic* + // *italic*; bold spans keep their styles while nested text is appended. src.startsWith("*", i) -> { - val end = src.indexOf('*', i + 1) - if (end != -1 && end > i + 1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 1 + val close = src.indexOf('*', i + 1) + if (close > i + 1) { + val raw = src.substring(i + 1, close) + val italic = SpanStyle(fontStyle = FontStyle.Italic) + val nested = + parseInlineSource( + raw, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(italic) { append(nested) } + i = close + 1 } else { append(src[i]) i++ @@ -225,16 +284,20 @@ object MarkdownInlineStyler { val urlEnd = src.indexOf(')', close + 2) if (urlEnd != -1) { val label = src.substring(i + 1, close) - val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label val url = src.substring(close + 2, urlEnd) pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(labelToAppend) + val labelText = + parseInlineSource( + label, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(labelText) } pop() i = urlEnd + 1 @@ -248,28 +311,6 @@ object MarkdownInlineStyler { } } - // `inline code` - src.startsWith("`", i) -> { - val end = src.indexOf('`', i + 1) - if (end != -1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - // bare URL URL_PATTERN.matchAt(src, i) != null -> { val match = URL_PATTERN.matchAt(src, i)!! diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt index 9d6ef28c..2d2cc26c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt @@ -10,18 +10,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import com.hrm.latex.renderer.measure.LatexMeasurerState +import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.util.BidiUtils private val TABLE_COL_WIDTH = 140.dp @@ -30,6 +31,11 @@ private val TABLE_COL_WIDTH = 140.dp fun MarkdownTable( block: MdBlock.Table, textColor: Color, + latexMeasurer: LatexMeasurerState, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, modifier: Modifier = Modifier, ) { val isRtl = @@ -49,18 +55,21 @@ fun MarkdownTable( .padding(vertical = 4.dp), ) { // Header row - Row(modifier = Modifier.background(headerBg)) { + Row(modifier = Modifier.background(headerBg), verticalAlignment = Alignment.Top) { block.header.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, + MarkdownInlineText( + text = cell, + textColor = textColor, + latexMeasurer = latexMeasurer, style = MaterialTheme.typography.bodySmall.copy( fontWeight = FontWeight.Bold, - textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + textAlign = tableTextAlign(alignments.getOrNull(idx)), ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, modifier = Modifier .width(TABLE_COL_WIDTH) @@ -71,17 +80,20 @@ fun MarkdownTable( HorizontalDivider(color = textColor.copy(alpha = 0.25f)) // Body rows block.rows.forEach { row -> - Row { + Row(verticalAlignment = Alignment.Top) { row.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, + MarkdownInlineText( + text = cell, + textColor = textColor, + latexMeasurer = latexMeasurer, style = MaterialTheme.typography.bodySmall.copy( - textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + textAlign = tableTextAlign(alignments.getOrNull(idx)), ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, modifier = Modifier .width(TABLE_COL_WIDTH) @@ -95,9 +107,9 @@ fun MarkdownTable( } } -private fun tableTextAlign(align: TableAlign?): TextAlign? = +private fun tableTextAlign(align: TableAlign?): TextAlign = when (align) { TableAlign.CENTER -> TextAlign.Center TableAlign.RIGHT -> TextAlign.End - else -> null + else -> TextAlign.Start } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 75344ee2..174605b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -193,6 +193,8 @@ internal fun SettingsChatPage( verticalArrangement = Arrangement.spacedBy(16.dp), ) { ChatSection( + busySendMode = state.busySendMode, + onBusySendModeChange = viewModel::onBusySendModeChange, typingEffectEnabled = state.typingEffectEnabled, onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, typingEffectDelayMs = state.typingEffectDelayMs, 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 c1e38f2f..411f45bb 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 @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.config.resolveBaseUrl import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.SessionListCacheStore +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkResult @@ -37,6 +38,7 @@ data class SettingsUiState( val testResult: String? = null, val isSaved: Boolean = false, val typingEffectEnabled: Boolean = false, + val busySendMode: BusySendMode = BusySendMode.CORRECT, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, val messageStatsEnabled: Boolean = false, @@ -85,6 +87,7 @@ class SettingsViewModel( val useDynamicColors = AuthManager.isUseDynamicColors() val themePreset = AuthManager.getThemePreset() val typingEffectEnabled = AuthManager.isTypingEffectEnabled() + val busySendMode = AuthManager.getBusySendMode() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() val chatFontScale = AuthManager.getChatFontScale() val messageStatsEnabled = AuthManager.isMessageStatsEnabled() @@ -112,6 +115,7 @@ class SettingsViewModel( useDynamicColors = useDynamicColors, themePreset = themePreset, typingEffectEnabled = typingEffectEnabled, + busySendMode = busySendMode, typingEffectDelayMs = typingEffectDelayMs, chatFontScale = chatFontScale, messageStatsEnabled = messageStatsEnabled, @@ -336,6 +340,11 @@ class SettingsViewModel( AuthManager.setTypingEffectEnabled(enabled) } + fun onBusySendModeChange(mode: BusySendMode) { + _uiState.update { it.copy(busySendMode = mode, isSaved = false) } + AuthManager.setBusySendMode(mode) + } + fun onTypingEffectDelayMsChange(delayMs: Int) { _uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) } AuthManager.setTypingEffectDelayMs(delayMs) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 87d2e175..e59f37a6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.settings.SectionCard import kotlin.math.abs import kotlin.math.roundToInt @@ -215,6 +217,8 @@ internal fun ChatSection( onTokensPerSecondChange: (Boolean) -> Unit = {}, showModelProvider: Boolean = false, onShowModelProviderChange: (Boolean) -> Unit = {}, + busySendMode: BusySendMode = BusySendMode.CORRECT, + onBusySendModeChange: (BusySendMode) -> Unit = {}, ) { val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) val currentIndex = @@ -223,6 +227,38 @@ internal fun ChatSection( .takeIf { it >= 0 } ?: 1 SectionCard { + var busyModeExpanded by remember { mutableStateOf(false) } + Text( + text = stringResource(R.string.chat_busy_mode_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.chat_busy_mode_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box { + OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) { + Text(busySendMode.label()) + } + DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) { + BusySendMode.entries.forEach { mode -> + DropdownMenuItem( + text = { Text(mode.label()) }, + onClick = { + onBusySendModeChange(mode) + busyModeExpanded = false + }, + ) + } + } + } + Text( + text = stringResource(R.string.chat_busy_interrupt_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1f8b9619..858a5b53 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1543,6 +1543,7 @@ تشغيل الفيديو جارٍ تجهيز الجلسة… الجلسة غير جاهزة — أعد المحاولة للمتابعة + إعداد %1$s %1$d من %2$d تم التفويض، لكن الأدوات غير متاحة @@ -1556,4 +1557,25 @@ %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 1d7a72c3..d85ee3c7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1199,6 +1199,7 @@ 비디오 재생 세션 준비 중… 세션이 준비되지 않았습니다. 다시 시도하세요 + %1$s 설정 %2$d개 중 %1$d개 인증되었지만 도구를 사용할 수 없습니다 @@ -1239,4 +1240,25 @@ 첨부 파일 오류: %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 01a08977..43541c9f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1509,4 +1509,124 @@ %1$s为必填项 无法发送设置响应,请重试。 正在等待 Hermes… + 智能体工作时 + 选择新消息如何送达当前聊天。空闲时正常发送。 + 修正当前轮次 + 排队等待下一轮 + 在下一个安全点提供指导 + 强制中断后发送 + 强制中断可能停止子智能体。排队和指导会让当前工作继续。 + 本条消息的发送方式 + 此选择仅生效一次,不会更改保存的默认值。 + 待发送消息 + 已在本机排队 + 停止后已暂停 + 正在发送… + 服务器已接收,等待写入聊天记录。再次发送可能造成重复。 + 送达状态未知;再次发送可能重复 + 未送达;点按“立即发送”重试 + 立即发送 + 现在发送待处理消息可能会中断当前任务并停止子代理。 + 再次发送 + 附件将随这条消息发送。 + 时间线 + 回到最新 + 此窗口之前和之后还有更多消息。 + 此窗口之后还有更新的消息。 + 此窗口之前还有更早的消息。 + 正在查看更早的消息 + 推理选项 + 还没有提示词。 + 加载更多 + 更早的提示词 + 刷新 + 重试 + 不用加载完整对话,就能跳到某条提示词。 + 对话时间线 + 本地网络访问被拒绝。请在系统设置中授予“本地网络”权限,或改用远程网关地址连接。 + 能力 + 提交 SHA + 文档 + 维护者 + 仓库 + 需要 Hermes + 层级 + 停止生成 + %1$s 本轮未发言 + 用户停止了讨论 + 无输出 + 退出码 0(无输出) + 运行中… + 完成 %1$d 项任务 + Hermes 需要你的输入 + 没有匹配筛选条件或搜索词的插件 + 未找到目录插件 + 全部 + 社区 + 官方 + 已安装 + 插件“%1$s”安装成功 + 正在安装 %1$s… + 作者 %1$s + 搜索目录插件… + 社区 + 官方 + 更新 + 目录 + 已安装 + 由定时自动化启动的运行会显示在这里。 + 暂无自动化历史 + 任务 %1$s + 已加载 %1$d 次运行 + 自动运行 + 已加载消息 + 自动化 + 对话 + 凭据保险库 + 密码管理器与凭据 + 隐藏转录 + 查看转录 + 子代理 + 暂无转录输出。 + 无法加载转录 + 正在加载实时转录… + 重试 + 滚动显示最近 16KB(更早输出已截断) + 下载并安装 + 稍后提醒 + 跳过此版本 + 更新内容 + 更新到 %1$s + 提交 + 输入 %1$s 的两步验证或校验码。 + 跳过 + 验证码 + 验证码 + 保险库中暂无条目 + 已存条目 + 锁定全部管理器 + 已锁定全部密码管理器 + 保存并填充 + 将 %1$s 的账号密码保存到凭据保险库? + 不保存 + 用户名或邮箱 + 密码 + 保存登录信息到保险库 + 已安装 + 锁定 + 已锁定 + 未安装 + 解锁 + 已解锁 + 外部密码管理器为智能体提供自动填充凭据。 + 密码管理器 + 解锁 + 智能体需要凭据才能继续。输入主密码以解锁 %1$s。 + 解锁 + 解锁 %1$s + 保持锁定 + 无法解锁 %1$s + 主密码 + %1$s 解锁成功 + 解锁 %1$s diff --git a/app/src/main/res/values/reply_errors.xml b/app/src/main/res/values/reply_errors.xml new file mode 100644 index 00000000..9c3b9ced --- /dev/null +++ b/app/src/main/res/values/reply_errors.xml @@ -0,0 +1,16 @@ + + + Hermes couldn\'t finish this reply + Something went wrong while Hermes was replying. Open the details or logs to understand what happened. + Details + Collapsed + Expanded + Copy error details + Open logs + Share diagnostics + Review details before sharing. Diagnostics include this error and the app version, not your conversation or attachments. + Dismiss error + + Error details copied + No app available to share diagnostics + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1074ec6e..5fcd2512 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,9 @@ Send Listening… + Could not start voice recording + Slide to cancel + Stop generating Microphone permission is required for voice input Speech recognition is not available on this device Send @@ -194,6 +197,29 @@ Waiting for response\u2026 Queued \u2014 sends when agent is free\u2026 Type a message\u2026 + While the agent is working + Choose how a new message reaches the current chat. Idle messages send normally. + Correct current turn + Queue next turn + Guide at next safe point + Hard interrupt, then send + Hard interrupt can stop child agents. Queue and guide let current work continue. + Queue + Stop & send + More + Send mode for this message + This choice applies once; the saved default stays the same. + Pending messages + Queued on this device + Parked after Stop + Sending\u2026 + Accepted by server; awaiting transcript. Sending again may duplicate it. + Delivery unknown; sending again may duplicate it + Not delivered; choose Send now to retry + Send now + Sending a pending message now may interrupt active work and stop child agents. + Send again + Attachments will be sent with this message. Execution Error: %1$s Exit Code: %1$d %1$d characters omitted from process output diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt index 5ce02d57..5c9dcc21 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/ChatMessageMapperTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.local import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageProvenance import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.ToolStatus import org.junit.Assert.assertEquals @@ -187,6 +188,32 @@ class ChatMessageMapperTest { assertEquals(MessageRole.USER, roundTripped.role) } + @Test + fun roundTripPreservesLocalPendingProvenance() { + val roundTripped = + ChatMessage( + id = "pending", + role = MessageRole.USER, + content = "not submitted", + messageProvenance = MessageProvenance.LOCAL_PENDING, + ).toEntity("s").toUiModel() + assertEquals(MessageProvenance.LOCAL_PENDING, roundTripped.messageProvenance) + } + + @Test + fun unknownPersistedProvenanceDoesNotInventDeliveryEvidence() { + val entity = + ChatMessageEntity( + id = "legacy", + sessionId = "s", + role = "USER", + content = "ambiguous", + timestamp = 1L, + messageProvenance = "future-value", + ) + assertEquals(MessageProvenance.UNKNOWN, entity.toUiModel().messageProvenance) + } + @Test fun roundTripWithoutDisplayKindStaysNull() { val ui = diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt index f57f20ac..c4a9e72e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/HermesDatabaseMigrationsTest.kt @@ -97,4 +97,16 @@ class HermesDatabaseMigrationsTest { ) } } + + @Test + fun migration9to10_addsDurableProvenanceWithoutInventingDeliveryState() = + runBlocking { + val connection = mockk(relaxed = true) + HermesDatabase.MIGRATION_9_10.migrate(connection) + verify(exactly = 1) { + connection.execSQL( + "ALTER TABLE chat_messages ADD COLUMN message_provenance TEXT NOT NULL DEFAULT 'UNKNOWN'", + ) + } + } } 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..e7096b39 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 @@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.local.AuthManager import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest { server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val builder = Request.Builder().url(server.url(path)) when (method) { + "POST" -> builder.post("{}".toRequestBody("application/json".toMediaType())) "DELETE" -> builder.delete() else -> builder.get() } @@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest { assertEquals("work", url.queryParameter("profile")) assertEquals("/api/status/health", url.encodedPath) } + + @Test + fun transcribeRoute_getsTheActiveNonDefaultProfile() { + // Review (PR #1250): /api/audio/transcribe resolves STT through the + // request's profile on the backend — the desktop sends this route + // `...profileScoped()`. Unscoped, a multi-profile host would + // transcribe under the launch profile instead of the user's pick. + val client = clientFor("work") + + val url = requestedUrl(client, "api/audio/transcribe", method = "POST") + + assertEquals("work", url.queryParameter("profile")) + assertEquals("/api/audio/transcribe", url.encodedPath) + } + + @Test + fun lookalikeAudioPath_notScoped() { + val client = clientFor("work") + + val url = requestedUrl(client, "api/audio-extras") + + assertNull(url.queryParameter("profile")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt new file mode 100644 index 00000000..e1387b9a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/VoiceNoteRepositoryTest.kt @@ -0,0 +1,133 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.model.AudioTranscriptionRequest +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.remote.NetworkResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response +import java.io.File +import java.net.SocketTimeoutException + +class VoiceNoteRepositoryTest { + private val api = mockk() + + private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File = + File.createTempFile("voice-note-test", ".m4a").apply { + writeBytes(bytes) + deleteOnExit() + } + + @Test + fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() = + runTest { + val file = tempVoiceFile(byteArrayOf(1, 2, 3)) + val bodySlot = slot() + coEvery { api.transcribeAudio(capture(bodySlot)) } returns + Response.success( + AudioTranscriptionResponse(ok = true, transcript = " hello world "), + ) + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + val success = result as NetworkResult.Success + assertEquals("hello world", success.data) + assertEquals("audio/mp4", bodySlot.captured.mimeType) + assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl) + } + + @Test + fun transcribe_propagatesServerFailureWithoutRetrying() = + runTest { + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } returns + Response.error(422, "no speech".toResponseBody("text/plain".toMediaType())) + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_doesNotRetryThePostAfterASocketTimeout() = + runTest { + // safeApiCall's default policy retries socket timeouts and 5xx; a + // blind re-POST would repeat provider STT work after the request + // may already have reached the server (review, PR #1250). + val file = tempVoiceFile() + coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout") + + val result = VoiceNoteRepository { _ -> api }.transcribe(file) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + } + + @Test + fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() = + runTest { + val firstApi = mockk() + val secondApi = mockk() + coEvery { firstApi.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server")) + coEvery { secondApi.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server")) + var currentApi = firstApi + val repository = VoiceNoteRepository { _ -> currentApi } + + val before = repository.transcribe(tempVoiceFile()) + // Simulate ApiClient.rebuild() after a connection/profile switch: + // the next call must use the new service, not the captured one. + currentApi = secondApi + val after = repository.transcribe(tempVoiceFile()) + + assertEquals("old server", (before as NetworkResult.Success).data) + assertEquals("new server", (after as NetworkResult.Success).data) + coVerify(exactly = 1) { firstApi.transcribeAudio(any()) } + coVerify(exactly = 1) { secondApi.transcribeAudio(any()) } + } + + @Test + fun transcribe_requestsTheDesktopParitySttTimeout() = + runTest { + val requestedTimeouts = mutableListOf() + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok")) + + VoiceNoteRepository { timeout -> + requestedTimeouts += timeout + api + }.transcribe(tempVoiceFile()) + + // A tiny clip must still get the 180s floor, not the shared 30s client. + assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts) + } + + @Test + fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() { + assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID")) + assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000))) + assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000))) + } + + @Test + fun transcribe_unreadableFileReturnsFailureWithoutUploading() = + runTest { + val missing = tempVoiceFile().apply { delete() } + + val result = VoiceNoteRepository { _ -> api }.transcribe(missing) + + assertTrue(result is NetworkResult.Failure) + coVerify(exactly = 0) { api.transcribeAudio(any()) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt index 4d4fca1c..71e116e7 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt @@ -1,9 +1,13 @@ package com.m57.hermescontrol.notification import android.content.Context +import com.m57.hermescontrol.data.ws.WsEvent import io.mockk.mockk import io.mockk.verify import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -104,4 +108,59 @@ class ChatNotificationServiceTest { atomicBoolean.get(), ) } + + @Test + fun `terminal reply error uses generic failure notification without success affordances`() { + val rawDiagnostic = "Authorization: Bearer secret-token" + val plan = + messageCompleteNotificationPlan( + event = + WsEvent.MessageComplete( + text = rawDiagnostic, + sessionId = "runtime-1", + storedSessionId = "stored-1", + completionId = "completion-1", + rawPayload = + mapOf( + "status" to "error", + "error" to rawDiagnostic, + ), + ), + targetSessionId = "stored-1", + newMessageText = "New message", + failureText = "Hermes couldn't finish this reply", + ) + + assertEquals("Hermes couldn't finish this reply", plan.text) + assertFalse(plan.text.contains(rawDiagnostic)) + assertEquals("stored-1", plan.sessionId) + assertFalse(plan.isReplyMessage) + assertFalse(plan.allowInlineReply) + assertNull(plan.completionId) + assertNull(plan.correlationText) + } + + @Test + fun `successful completion keeps reply correlation and inline reply`() { + val plan = + messageCompleteNotificationPlan( + event = + WsEvent.MessageComplete( + text = "Answer\ncontinued", + sessionId = "runtime-1", + completionId = "completion-1", + rawPayload = mapOf("status" to "ok"), + ), + targetSessionId = "stored-1", + newMessageText = "New message", + failureText = "Hermes couldn't finish this reply", + ) + + assertEquals("Answer continued", plan.text) + assertEquals("stored-1", plan.sessionId) + assertTrue(plan.isReplyMessage) + assertTrue(plan.allowInlineReply) + assertEquals("completion-1", plan.completionId) + assertEquals("Answer\ncontinued", plan.correlationText) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt new file mode 100644 index 00000000..1e446aec --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCachedHistoryTest.kt @@ -0,0 +1,187 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.ws.WsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatCachedHistoryTest { + private val historicalTool = + ChatMessage( + id = "cached-tool", + role = MessageRole.TOOL, + content = "{}", + toolName = "terminal", + toolCallId = "call-old", + toolStatus = ToolStatus.RUNNING, + isHistoricalCache = true, + ) + + @Test + fun exactToolIdPromotesOnlyItsHistoricalIdentity() { + val other = historicalTool.copy(id = "other", toolCallId = "call-other") + val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool, other)) + val events = + listOf( + WsEvent.ToolProgress(name = "terminal", preview = "working", sessionId = "s", toolId = "call-old"), + WsEvent.ToolGenerating(name = "terminal", sessionId = "s", toolId = "call-old"), + WsEvent.ToolComplete(name = "terminal", data = mapOf("tool_id" to "call-old"), sessionId = "s"), + ) + events.forEach { event -> + val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s") + assertFalse( + "$event must supersede historical provenance", + result.state.messages + .first() + .isHistoricalCache, + ) + assertTrue( + result.state.messages + .last() + .isHistoricalCache, + ) + } + } + + @Test + fun toolProgressWithNonmatchingExplicitIdDoesNotPromoteHistoricalSameNameCall() { + val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool)) + + val result = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.ToolProgress(name = "terminal", preview = "new call", sessionId = "s", toolId = "call-new"), + "s", + ) + + assertEquals(listOf(historicalTool), result.state.messages) + } + + @Test + fun toolCompleteWithNonmatchingExplicitIdDoesNotOverwriteHistoricalSameNameCall() { + val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool)) + + val result = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.ToolComplete( + name = "terminal", + data = mapOf("tool_id" to "call-new", "output" to "new call"), + sessionId = "s", + ), + "s", + ) + + assertEquals(listOf(historicalTool), result.state.messages) + assertTrue(result.effects.isEmpty()) + } + + @Test + fun legacyIdlessToolEventsStillPromoteHistoricalSameNameCall() { + val state = ChatUiState(currentSessionId = "s", messages = listOf(historicalTool)) + val events = + listOf( + WsEvent.ToolProgress(name = "terminal", preview = "legacy", sessionId = "s"), + WsEvent.ToolComplete(name = "terminal", data = mapOf("output" to "legacy"), sessionId = "s"), + ) + + events.forEach { event -> + val result = ChatWsEventReducer.reduce(state, StreamingState(), event, "s") + assertFalse( + "$event must retain the legacy name fallback", + result.state.messages + .single() + .isHistoricalCache, + ) + } + } + + @Test + fun lateCacheCannotDemoteAnObservedLiveIdentity() { + val live = historicalTool.copy(isHistoricalCache = false, progressPreview = "new progress") + val merged = mergeCachedTranscriptPage(listOf(historicalTool), listOf(live)) + assertEquals(listOf(live), merged) + } + + @Test + fun cachedSessionStartMarkerStaysFirstBeforeHistoricalUuidAndCanonicalRestWindow() { + val start = + ChatMessage( + id = "cached-session-start", + role = MessageRole.SYSTEM, + content = "Session created", + isHistoricalCache = true, + ) + val historicalUser = + ChatMessage( + id = "5df43b6f-98ea-46db-9fc4-d1e4cf11d43d", + role = MessageRole.USER, + content = "older cached prompt", + isHistoricalCache = true, + ) + val canonical = ChatMessage(id = "rest-s-0", role = MessageRole.ASSISTANT, content = "canonical reply") + + val merged = mergeTranscriptWithLive(listOf(canonical), listOf(start, historicalUser)) + + assertEquals(listOf(start.id, historicalUser.id, canonical.id), merged.map { it.id }) + } + + @Test + fun restoredRepeatedUsersKeepBothOccurrencesUntilEachCanonicalPageConfirmsOne() { + val first = + ChatMessage( + id = "restored-first", + role = MessageRole.USER, + content = "continue", + localOrder = 1, + isRestoredUnconfirmed = true, + ) + val second = first.copy(id = "restored-second", localOrder = 2) + val latest = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "Latest answer") + val restored = mergeCachedTranscriptPage(listOf(first, second), listOf(latest)) + assertEquals(listOf(first.id, second.id, latest.id), restored.map { it.id }) + assertTrue(restored.take(2).all { it.canonicalRestId == null }) + + val older = ChatMessage(id = "rest-s-20", role = MessageRole.USER, content = "continue") + val once = mergeTranscriptWithLive(listOf(older), restored, chronological = false) + assertEquals(2, once.count { it.role == MessageRole.USER }) + assertEquals(1, once.count { it.canonicalRestId == older.id }) + val confirmed = once.single { it.canonicalRestId == older.id } + assertFalse(confirmed.isRestoredUnconfirmed) + val remaining = once.single { it.role == MessageRole.USER && it.canonicalRestId == null } + assertTrue(remaining.isRestoredUnconfirmed) + assertEquals(MessageProvenance.UNKNOWN, remaining.messageProvenance) + + val twice = mergeTranscriptWithLive(listOf(older.copy(id = "rest-s-21")), once, chronological = false) + assertEquals(listOf(first.id, second.id, latest.id), twice.map { it.id }) + assertEquals(listOf("rest-s-20", "rest-s-21"), twice.take(2).map { it.canonicalRestId }) + assertTrue(twice.none { it.isRestoredUnconfirmed }) + } + + @Test + fun historicalRowsPrecedeCanonicalWindowWithoutChangingLiveTailOrAttachments() { + val attachment = Attachment("content://test/file", "file.txt", "text/plain") + val old = + ChatMessage( + id = "cached-user", + role = MessageRole.USER, + content = "old", + timestamp = Long.MAX_VALUE, + attachments = listOf(attachment), + isHistoricalCache = true, + ) + val pending = old.copy(id = "pending", content = "pending", isHistoricalCache = false) + val recent = ChatMessage(id = "rest-s-100", role = MessageRole.ASSISTANT, content = "recent", timestamp = 1) + val live = historicalTool.copy(id = "live-tool", toolCallId = "call-live", isHistoricalCache = false) + val resume = ChatMessage(id = "resume", role = MessageRole.SYSTEM, content = "Session resumed") + val merged = mergeTranscriptWithLive(listOf(recent), listOf(old, historicalTool, pending, live, resume)) + assertEquals(listOf(old.id, historicalTool.id, recent.id, pending.id, live.id, resume.id), merged.map { it.id }) + assertEquals(listOf(attachment), merged.first().attachments) + assertEquals(listOf(attachment), merged.single { it.id == pending.id }.attachments) + assertEquals(merged, mergeTranscriptWithLive(listOf(recent), merged)) + } +} 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 index def65f96..815a315f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPagingMergeTest.kt @@ -12,6 +12,63 @@ import org.junit.Assert.assertTrue import org.junit.Test class ChatPagingMergeTest { + @Test + fun lateCachedRunningToolCannotReplaceCanonicalServerResult() { + val canonical = + ChatMessage( + id = "rest-s-42", + role = MessageRole.TOOL, + content = """{"output":"server result","exit_code":0}""", + toolCallId = "call-42", + toolStatus = ToolStatus.COMPLETED, + ) + val cached = + ChatMessage( + id = "cached-tool", + role = MessageRole.TOOL, + content = """{"name":"terminal","args":{"command":"pwd"}}""", + toolName = "terminal", + toolCallId = "call-42", + toolStatus = ToolStatus.RUNNING, + isHistoricalCache = true, + tokenCount = 7, + tps = 2.5, + finishTimestamp = 123L, + ) + + val merged = mergeCachedTranscriptPage(listOf(cached), listOf(canonical)).single() + + assertEquals(canonical.id, merged.id) + assertEquals(canonical.content, merged.content) + assertEquals(ToolStatus.COMPLETED, merged.toolStatus) + assertEquals("terminal", merged.toolName) + assertEquals("call-42", merged.toolCallId) + assertEquals(cached.tokenCount, merged.tokenCount) + assertEquals(cached.tps, merged.tps) + assertEquals(cached.finishTimestamp, merged.finishTimestamp) + } + + @Test + fun canonicalResultSettlesHistoricalRunningToolWithServerOutput() { + val cached = + ChatMessage( + id = "old-tool", + role = MessageRole.TOOL, + content = """{"name":"terminal","args":{"command":"pwd"}}""", + toolName = "terminal", + toolCallId = "call-42", + toolStatus = ToolStatus.RUNNING, + isHistoricalCache = true, + ) + val page = mapServerMessages("s", listOf(serverTool(42, "call-42")), 0, true, listOf(cached)) + val merged = mergeTranscriptWithLive(page, listOf(cached), preserveLiveIds = true).single() + assertEquals(cached.id, merged.id) + assertEquals("rest-s-42", merged.canonicalRestId) + assertEquals(ToolStatus.COMPLETED, merged.toolStatus) + assertTrue(merged.content.contains("exit_code")) + assertTrue(!merged.isHistoricalCache) + } + @Test fun confirmedLiveOccurrenceCannotConsumeEarlierIdenticalPage() { for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT, MessageRole.TOOL)) { @@ -598,6 +655,50 @@ class ChatPagingMergeTest { assertTrue(sameLogicalMessage(canonical, live)) } + @Test + fun verifierFooterReplyMatchesCanonicalCounterpartWithoutFooter() { + val footer = + """ + ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed. + • /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix. + """.trimIndent() + val body = "I completed the audit. Here are the findings." + val liveAssistant = + ChatMessage( + id = "uuid-123", + role = MessageRole.ASSISTANT, + content = "$body\n\n$footer", + completionId = "comp-1", + ) + val serverRow = + SessionMessage( + id = 42, + role = "assistant", + content = JsonPrimitive(body), + ) + + // 1. mapServerMessages correctly matches liveAssistant and acquires comp-1 and rich footer content + val mapped = mapServerMessages("session", listOf(serverRow), 0, true, listOf(liveAssistant)) + assertEquals(1, mapped.size) + val canonical = mapped.single() + assertEquals("rest-session-42", canonical.id) + assertEquals("comp-1", canonical.completionId) + assertTrue("Canonical mapped row should retain verifier footer", canonical.content.contains(footer)) + + // 2. mergeTranscriptWithLive merges into a single message preserving footer and acquiring restId + val merged = + mergeTranscriptWithLive( + restMessages = mapped, + currentMessages = listOf(liveAssistant), + preserveLiveIds = true, + ) + assertEquals(1, merged.size) + val single = merged.single() + assertEquals("uuid-123", single.id) + assertEquals("rest-session-42", single.restId) + assertTrue("Merged message must keep the verifier warning footer", single.content.contains(footer)) + } + @Test fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() { val localFeedback = 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 bb26ccd7..7761d123 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 @@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test @@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest { assertEquals(1000L, entity.timestamp) } + @Test + fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() = + runTest { + repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s") + repository.persistMessage( + ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"), + "s", + ) + + repository.deleteMessage("local") + repository.deleteMessage("confirmed") + + val rows = repository.loadMessages("s") + assertFalse(rows.any { it.id == "local" }) + assertEquals("confirmed", rows.single().id) + } + @Test fun persistMessages_upsertsAllToDao() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt new file mode 100644 index 00000000..e11e1dc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatQueuePresentationTest.kt @@ -0,0 +1,48 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.BusySendMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatQueuePresentationTest { + private val message = ChatMessage("queued-id", MessageRole.USER, "Later") + + private fun pending(state: PendingSendState) = + PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state) + + @Test + fun queuedAndParkedMessagesRemainInternalUntilDispatch() { + val messages = listOf(message) + for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) { + val receipts = listOf(pending(state)) + assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty()) + assertEquals(listOf(message), messages) + assertEquals(state, receipts.single().state) + } + } + + @Test + fun queuedMessageAppearsOnceWhenSendingStarts() { + val messages = listOf(message) + assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty()) + for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) { + assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state)))) + } + } + + @Test + fun ordinarySendAndOtherMessagesAreUnchanged() { + val other = ChatMessage("other", MessageRole.USER, "Send now") + val messages = listOf(other, message) + assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList())) + assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) + } + + @Test + fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() { + val messages = listOf(message.copy(restId = "rest-session-12")) + assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED)))) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt new file mode 100644 index 00000000..9ce48c95 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatReplyErrorTest.kt @@ -0,0 +1,315 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatReplyErrorTest { + @Test + fun failedCompletionKeepsPartialReplyAndStopsThinking() { + val partial = + ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "Partial reply", isStreaming = true) + val state = + ChatUiState( + currentSessionId = "runtime", + isAgentTyping = true, + isThinking = true, + streamingMessage = partial, + ) + val streaming = StreamingState(streamingMessage = partial, isThinking = true) + + val event = + WsEvent.MessageComplete( + text = "Partial reply", + sessionId = "runtime", + completionId = "completion-failed-1", + rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true), + ) + val result = ChatWsEventReducer.reduce(state, streaming, event, "runtime") + + assertFalse(result.state.isThinking) + assertEquals( + "Partial reply", + result.state.messages + .single { it.id == "partial" } + .content, + ) + assertNotNull(result.state.replyFailure) + assertFalse(result.state.isAgentTyping) + assertNull(result.streamingState.streamingMessage) + assertEquals( + "completion-failed-1", + result.state.messages + .single { it.id == "partial" } + .completionId, + ) + assertTrue(result.effects.none { it is ReducerEffect.PersistMessage }) + } + + @Test + fun terminalErrorIsNeverPersistedAsAssistantProse() { + val event = + WsEvent.MessageComplete( + text = "Provider refused API_KEY=private-value", + sessionId = "runtime", + rawPayload = mapOf("status" to "error"), + ) + val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event, "runtime") + assertNotNull(result.state.replyFailure) + assertFalse( + result.state.replyFailure!! + .details + .contains("private-value"), + ) + assertTrue(result.state.messages.isEmpty()) + assertTrue(result.effects.none { it is ReducerEffect.PersistMessage }) + } + + @Test + fun errorWithoutPartialFlagStillKeepsAlreadyStreamedProse() { + val streaming = + StreamingState(streamingMessage = ChatMessage(role = MessageRole.ASSISTANT, content = "Visible")) + val event = WsEvent.MessageComplete("Error!", null, rawPayload = mapOf("status" to "error")) + val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event) + assertEquals( + "Visible", + result.state.messages + .single() + .content, + ) + assertNotNull(result.state.replyFailure) + } + + @Test + fun otherSessionCannotShowFailureOrChangeStreaming() { + val state = ChatUiState(isAgentTyping = true) + val streaming = StreamingState(isThinking = true) + val event = WsEvent.MessageComplete("Error", "other", rawPayload = mapOf("status" to "error")) + val result = ChatWsEventReducer.reduce(state, streaming, event, "current") + assertEquals(state, result.state) + assertEquals(streaming, result.streamingState) + assertTrue(result.effects.isEmpty()) + } + + @Test + fun missingPartialTextPreservesAlreadyVisibleOutput() { + for (text in listOf("", " ")) { + val streaming = + StreamingState( + streamingMessage = + ChatMessage( + id = "visible", + role = MessageRole.ASSISTANT, + content = "Visible output", + ), + ) + val event = + WsEvent.MessageComplete( + text, + null, + rawPayload = mapOf("status" to "error", "partial" to true), + ) + val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event) + assertEquals( + "Visible output", + result.state.messages + .single() + .content, + ) + } + } + + @Test + fun partialConversationIsNeverUsedAsDiagnosticFallback() { + val event = + WsEvent.MessageComplete( + "Private conversational content", + null, + rawPayload = mapOf("status" to "error", "partial" to true), + ) + val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event) + assertEquals( + "Private conversational content", + result.state.messages + .single() + .content, + ) + assertFalse( + result.state.replyFailure!! + .details + .contains("Private conversational content"), + ) + assertTrue( + result.state.replyFailure!! + .details + .isNotBlank(), + ) + } + + @Test + fun normalReplyAndGatewayErrorAreNotTerminalReplyFailures() { + for (event in listOf(WsEvent.MessageComplete("Success", null), WsEvent.GatewayError("Connection failed"))) { + val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event) + assertNull(result.state.replyFailure) + } + } + + @Test + fun nextTurnClearsFailureButDoneDoesNot() { + val failure = ReplyFailure("Failed") + val failedPartial = + ChatMessage(id = "failed-partial", role = MessageRole.ASSISTANT, content = "Partial") + val state = + ChatUiState( + messages = listOf(ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep"), failedPartial), + replyFailure = failure, + replyFailureProjection = ReplyFailureProjection("failed-partial", failure.id), + ) + val done = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageDone(null)) + assertEquals(failure, done.state.replyFailure) + val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart(null)) + assertNull(start.state.replyFailure) + assertNull(start.state.replyFailureProjection) + assertEquals(listOf("normal"), start.state.messages.map { it.id }) + } + + @Test + fun retainedFailureWithEmptyAssistantNeverInheritsUnrelatedStream() { + val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep") + val stale = + ChatMessage( + id = "unrelated-stream", + role = MessageRole.ASSISTANT, + content = "Stale text", + reasoningText = "Stale reasoning", + isStreaming = true, + ) + val state = + ChatUiState( + messages = listOf(normal), + isAgentTyping = true, + isThinking = true, + thinkingText = "Stale thinking", + streamingMessage = stale, + ) + + val result = + ChatWsEventReducer.reduceRetainedReplyFailure( + state = state, + inflight = mapOf("assistant" to "", "status" to "error", "error" to "Provider failed"), + currentSessionId = "runtime", + ) + + assertEquals(listOf(normal), result.state.messages) + assertFalse(result.state.isAgentTyping) + assertFalse(result.state.isThinking) + assertEquals("", result.state.thinkingText) + assertNull(result.state.streamingMessage) + assertEquals(StreamingState(), result.streamingState) + assertNotNull(result.state.replyFailure) + assertNull(result.state.replyFailureProjection?.messageId) + } + + @Test + fun duplicateRetainedFailureKeepsOneStableProjection() { + val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep") + val inflight = + mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed") + + val first = + ChatWsEventReducer.reduceRetainedReplyFailure( + ChatUiState(messages = listOf(normal)), + inflight, + "runtime", + ) + val second = ChatWsEventReducer.reduceRetainedReplyFailure(first.state, inflight, "runtime") + + assertEquals(2, second.state.messages.size) + assertEquals(listOf("Keep", "Retained partial"), second.state.messages.map { it.content }) + assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection) + assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id) + } + + @Test + fun duplicateLiveFailureKeepsOneStableProjection() { + val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep") + val partial = + ChatMessage(id = "live-partial", role = MessageRole.ASSISTANT, content = "Partial", isStreaming = true) + val event = + WsEvent.MessageComplete( + text = "Partial", + sessionId = "runtime", + completionId = "completion-1", + rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true), + ) + + val first = + ChatWsEventReducer.reduce( + ChatUiState(messages = listOf(normal), currentSessionId = "runtime"), + StreamingState(streamingMessage = partial), + event, + "runtime", + ) + val second = ChatWsEventReducer.reduce(first.state, first.streamingState, event, "runtime") + + assertEquals(listOf("normal", "live-partial"), second.state.messages.map { it.id }) + assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection) + assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id) + } + + @Test + fun liveFailureThenResumeUpdatesSameProjection() { + val partial = + ChatMessage( + id = "live-partial", + role = MessageRole.ASSISTANT, + content = "Live partial", + isStreaming = true, + ) + val live = + ChatWsEventReducer.reduce( + ChatUiState(currentSessionId = "stored"), + StreamingState(streamingMessage = partial), + WsEvent.MessageComplete( + text = "Live partial", + sessionId = "runtime", + completionId = "completion-1", + rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true), + ), + "runtime", + ) + + val resumed = + ChatWsEventReducer.reduceRetainedReplyFailure( + live.state, + mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed"), + "runtime", + ) + + assertEquals(1, resumed.state.messages.size) + assertEquals( + "live-partial", + resumed.state.messages + .single() + .id, + ) + assertEquals( + "Retained partial", + resumed.state.messages + .single() + .content, + ) + assertEquals( + "completion-1", + resumed.state.messages + .single() + .completionId, + ) + assertEquals(live.state.replyFailureProjection, resumed.state.replyFailureProjection) + assertEquals(live.state.replyFailure?.id, resumed.state.replyFailure?.id) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt new file mode 100644 index 00000000..89d3e122 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSendStoreTest.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.SharedPreferences +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.BusySendMode +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatSendStoreTest { + private fun pending( + id: String, + text: String, + state: PendingSendState = PendingSendState.ACCEPTED, + ) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state) + + private fun mappedHistoryAliases( + incoming: List, + existing: List, + ): List = + incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match -> + match?.copy(restId = rest.canonicalRestId) ?: rest + } + + @Test + fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() { + val local = ChatMessage("send-1", MessageRole.USER, "See attachment") + val rest = + ChatMessage( + "rest-session-10", + MessageRole.USER, + """ + [OUT-OF-BAND USER MESSAGE — direct steering] + @file:files/agent-vault/hermes/attachments/note.txt + + See attachment + [/OUT-OF-BAND USER MESSAGE] + """.trimIndent(), + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + mappedHistoryAliases(listOf(rest), listOf(local)), + listOf(pending("send-1", "See attachment")), + ) + + assertEquals(setOf("send-1"), confirmed) + } + + @Test + fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() { + val existing = + listOf( + ChatMessage("send-1", MessageRole.USER, "repeat"), + ChatMessage("send-2", MessageRole.USER, "repeat"), + ) + val aliases = + mappedHistoryAliases( + listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")), + existing, + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf(pending("send-1", "repeat"), pending("send-2", "repeat")), + ) + + assertEquals(setOf("send-1"), confirmed) + } + + @Test + fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() { + val aliases = + mappedHistoryAliases( + listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")), + listOf(ChatMessage("send-1", MessageRole.USER, "repeat")), + ) + val first = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf(pending("send-1", "repeat"), pending("send-2", "repeat")), + ) + val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first } + + assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty()) + } + + @Test + fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() { + val aliases = + listOf( + ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"), + ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"), + ) + + val confirmed = + pendingSendIdsConfirmedByDurableAliases( + aliases, + listOf( + pending("queued", "same", PendingSendState.QUEUED), + pending("parked", "same", PendingSendState.PARKED), + ), + ) + + assertTrue(confirmed.isEmpty()) + } + + @Test + fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() { + val store = ChatSendStore() + store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE)) + store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE)) + + store.promote("second") + + assertEquals(listOf("second", "first"), store.all().map { it.id }) + } + + @Test + fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() { + var saved: String? = null + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } answers { saved } + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + + val first = ChatSendStore(prefs) + first.put( + PendingSend( + "queued", + "connection-a", + "session-a", + "later", + attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)), + mode = BusySendMode.QUEUE, + ), + ) + first.put( + PendingSend( + "sending", + "connection-b", + "session-b", + "maybe", + mode = BusySendMode.CORRECT, + state = PendingSendState.SENDING, + ), + ) + first.park("connection-a", "session-a") + + val restored = ChatSendStore(prefs) + assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state) + assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state) + assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope) + assertEquals( + "file:///private/photo", + restored + .all() + .single { it.id == "queued" } + .attachments + .single() + .uri, + ) + assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId) + + restored.remove("queued") + assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" }) + assertEquals(1, ChatSendStore(prefs).all().size) + } + + @Test + fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() { + var saved: String? = null + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } answers { saved } + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + + ChatSendStore(prefs).put( + PendingSend( + "accepted", + "connection-a", + "session-a", + "possibly accepted", + mode = BusySendMode.QUEUE, + state = PendingSendState.ACCEPTED, + ), + ) + + val restored = ChatSendStore(prefs).all().single() + + assertEquals(PendingSendState.UNKNOWN, restored.state) + assertEquals(0, restored.attempts) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt index daf6f132..f3360b34 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatUpdateCommandTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -76,6 +77,14 @@ class ChatUpdateCommandTest { every { android.util.Log.e(any(), any(), any()) } returns 0 mockkObject(AuthManager) + // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models + // when the catalog load succeeds; without this stub the spy falls + // through to the real AuthManager and throws "not initialized". + every { AuthManager.getPinnedModels() } returns emptyList() + // The delegate and the shared catalog store both collect this flow; + // park them on a never-emitting state so a later real-AuthManager + // emission cannot resume a stale Main-dispatched collector. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(HermesWsClient) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -87,6 +96,10 @@ class ChatUpdateCommandTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.getBaseUrl() } returns "http://test.local/" + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false @@ -148,7 +161,15 @@ class ChatUpdateCommandTest { // threads — with a relaxed-mock app that NPEs on cacheDir and leaks // an uncaught exception into the NEXT runTest class (CI flakes: // UncaughtExceptionsBeforeTest). - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) @@ -185,7 +206,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate triggers the REST action and starts the progress popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) // One running poll, then the action exits — runTest's teardown @@ -224,7 +253,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate settles on success when the action exits cleanly`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.success(ActionResponse(ok = true, name = "hermes-update")) coEvery { mockApi.getActionStatus("hermes-update") } returns @@ -252,7 +289,15 @@ class ChatUpdateCommandTest { @Test fun `applyUpdate surfaces a rejected trigger in the popup`() = runTest { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) coEvery { mockApi.updateHermes() } returns Response.error(404, "no update endpoint".toResponseBody()) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt new file mode 100644 index 00000000..7e2fcc63 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatVerifierFooterTest.kt @@ -0,0 +1,90 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatVerifierFooterTest { + private val sampleFooter = + """ + ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run git status or read_file to confirm what actually landed. + • /tmp/skill_lang_audit.py — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix. + """.trimIndent() + + private val backtickedFooter = + """ + ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed. + • `/tmp/skill_lang_audit.py` — [write_file] Write denied: '/tmp/skill_lang_audit.py' is outside HERMES_WRITE_SAFE_ROOT (/opt/data). Unset the variable or add this path's directory prefix. + """.trimIndent() + + @Test + fun `split extracts body and footer for exact reporter format`() { + val body = "I completed the audit. Here are the findings." + val full = "$body\n\n$sampleFooter" + + val result = ChatVerifierFooter.split(full) + assertNotNull(result) + assertEquals(body, result?.body) + assertEquals(sampleFooter, result?.footer?.trim()) + } + + @Test + fun `split extracts body and footer for backend backticked format`() { + val body = "Audit summary:\n- item 1\n- item 2" + val full = "$body\n\n$backtickedFooter" + + val result = ChatVerifierFooter.split(full) + assertNotNull(result) + assertEquals(body, result?.body) + assertEquals(backtickedFooter, result?.footer?.trim()) + } + + @Test + fun `split handles multiple bullets and overflow note`() { + val footer = + """ + ⚠️ File-mutation verifier: 3 file edit(s) FAILED this turn despite any wording above that may suggest otherwise. Run `git status` or `read_file` to confirm what actually landed. + • `/a.py` — [patch] failed + • `/b.py` — [patch] failed + • … and 1 more + """.trimIndent() + val body = "Done" + val result = ChatVerifierFooter.split("$body\n\n$footer") + assertNotNull(result) + assertEquals("Done", result?.body) + } + + @Test + fun `split rejects footer when body is empty`() { + val result = ChatVerifierFooter.split(sampleFooter) + assertNull(result) + } + + @Test + fun `split rejects footer when inside a line without newline before it`() { + val text = "Look at this: ⚠️ File-mutation verifier: 1 file edit(s) FAILED this turn despite any wording..." + val result = ChatVerifierFooter.split(text) + assertNull(result) + } + + @Test + fun `split rejects text with unrelated trailing prose`() { + val full = "Body text\n\n$sampleFooter\nAnd here is more stuff after the footer." + val result = ChatVerifierFooter.split(full) + assertNull(result) + } + + @Test + fun `matchesBase compares body ignoring verifier footer`() { + val body = "Here is the response without errors." + val withFooter = "$body\n\n$sampleFooter" + + assertTrue(ChatVerifierFooter.matchesBase(body, withFooter)) + assertTrue(ChatVerifierFooter.matchesBase(withFooter, body)) + assertTrue(ChatVerifierFooter.matchesBase(withFooter, withFooter)) + assertFalse(ChatVerifierFooter.matchesBase("Different body", withFooter)) + } +} 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 3e77cf3e..c2a9f6f5 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 @@ -3,13 +3,17 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import android.content.ContentResolver import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.util.Log import androidx.core.content.FileProvider import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource +import com.m57.hermescontrol.data.model.AudioTranscriptionResponse +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination @@ -28,6 +32,7 @@ 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 +import com.m57.hermescontrol.data.ws.ModelCatalogStore import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker @@ -46,6 +51,7 @@ import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -62,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertArrayEquals @@ -72,6 +79,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class ChatViewModelTest { @@ -83,6 +91,7 @@ class ChatViewModelTest { private lateinit var fakeRepo: FakeChatPersistenceRepository private lateinit var fakeSlashUsageStore: FakeSlashUsageStore private lateinit var attachmentCacheDir: java.io.File + private lateinit var attachmentFilesDir: java.io.File /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 @@ -279,7 +288,12 @@ class ChatViewModelTest { java.nio.file.Files .createTempDirectory("chat-attachments-test") .toFile() + attachmentFilesDir = + java.nio.file.Files + .createTempDirectory("chat-send-files-test") + .toFile() every { app.cacheDir } returns attachmentCacheDir + every { app.filesDir } returns attachmentFilesDir fakeRepo = FakeChatPersistenceRepository() fakeSlashUsageStore = FakeSlashUsageStore() ActiveSessionHolder.set(null) @@ -289,10 +303,23 @@ class ChatViewModelTest { every { AuthManager.getToken() } returns "test-token" every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getSelectedProfileId() } returns null + // sendVoiceNote snapshots the data scope for its ownership check + // (review, PR #1250). Default it to null: an unstubbed mockk call + // used to throw and be swallowed by the runCatching callers, which + // kept SwrCache keys unscoped in this class; returning a concrete + // scope here enables cross-test cache hits and skips sends other + // tests count. Scope-specific tests re-stub this with real scopes. + every { AuthManager.currentDataScope() } returns null + // ChatViewModel's model-switch delegate and the shared catalog store + // both collect this flow; park them on a never-emitting state so a + // later real-AuthManager emission cannot resume a stale + // Main-dispatched collector between test classes. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(ProfileSwitchCoordinator) every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow() every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getBusySendMode() } returns BusySendMode.CORRECT every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false every { AuthManager.isUserMessageTokensEnabled() } returns true @@ -357,6 +384,7 @@ class ChatViewModelTest { fun tearDown() { Dispatchers.resetMain() attachmentCacheDir.deleteRecursively() + attachmentFilesDir.deleteRecursively() unmockkAll() } @@ -366,9 +394,9 @@ class ChatViewModelTest { private fun createViewModel( startCleanup: Boolean = false, historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, + sendStore: ChatSendStore = ChatSendStore(), ): ChatViewModel = - // Both dispatchers injected — a real ioDispatcher would race the - // test scheduler (repo writes hop it) and shuffle RPC ordering. + // All injected dispatchers share the test scheduler so RPC ordering is deterministic. ChatViewModel( app, startCleanup, @@ -377,6 +405,7 @@ class ChatViewModelTest { testDispatcher, testDispatcher, historyDispatcher, + sendStore, ) /** @@ -389,8 +418,9 @@ class ChatViewModelTest { private suspend fun TestScope.createViewModelWithSession( startCleanup: Boolean = false, historyDispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatcher, + sendStore: ChatSendStore = ChatSendStore(), ): Pair { - val viewModel = createViewModel(startCleanup, historyDispatcher) + val viewModel = createViewModel(startCleanup, historyDispatcher, sendStore) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED @@ -460,6 +490,104 @@ class ChatViewModelTest { assertEquals("op-1218", operation?.opId) } + @Test + fun sessionResume_restoresRetainedFailureAsNonDurablePartial() = + 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", + "resumed" to "stored-session", + "inflight" to + mapOf( + "assistant" to "Retained partial answer", + "user" to "Private user prompt", + "streaming" to false, + "status" to "error", + "error" to "Provider failed", + "recoverable" to true, + "error_surface" to mapOf("provider" to "example", "code" to "rate_limit"), + ), + ), + ), + ) + advanceUntilIdle() + + assertEquals("stored-session", viewModel.uiState.value.currentSessionId) + assertEquals( + "Retained partial answer", + viewModel.uiState.value.messages + .last() + .content, + ) + assertEquals( + "code: rate_limit\nprovider: example\nProvider failed", + viewModel.uiState.value.replyFailure + ?.details, + ) + assertFalse( + viewModel.uiState.value.replyFailure!! + .details + .contains("Private user prompt"), + ) + assertTrue( + fakeRepo.dao + .getMessagesForSession("stored-session") + .none { it.content == "Retained partial answer" }, + ) + } + + @Test + fun staleSessionResumeFailureCannotClearHealthyCurrentStream() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.switchSession("stored-old") + advanceUntilIdle() + val staleResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + + viewModel.switchSession("stored-current") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart("stored-current")) + mockEventsFlow.emit(WsEvent.MessageToken("Healthy current stream", "stored-current")) + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.RpcResult( + staleResumeId, + mapOf( + "session_id" to "runtime-old", + "inflight" to + mapOf( + "assistant" to "Old failed partial", + "status" to "error", + "error" to "Old provider failure", + ), + ), + ), + ) + advanceUntilIdle() + + assertEquals("stored-current", viewModel.uiState.value.currentSessionId) + assertNull(viewModel.uiState.value.replyFailure) + assertEquals( + "Healthy current stream", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertTrue( + viewModel.uiState.value.messages + .none { it.content == "Old failed partial" }, + ) + } + private fun connectionOperationSnapshot( sessionId: String, seq: Long = 1L, @@ -730,7 +858,7 @@ class ChatViewModelTest { advanceUntilIdle() // 3. WS returns interrupt success -> adds "Session interrupted" - mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true))) + mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("status" to "interrupted"))) advanceUntilIdle() // 4. Server transcript sync arrives with newer turns @@ -987,6 +1115,13 @@ class ChatViewModelTest { runTest { val (viewModel, _) = createViewModelWithSession() + // The shared catalog store may still hold another class's cached + // (possibly empty) state — reset it so the picker's load below + // fetches through this class's stubbed API. + ModelCatalogStore.shared.onScopeChanged( + DataScope("preload-reset", "http://preload-reset.test/", AuthManager.DEFAULT_PROFILE_ID), + ) + // A bare "/model" must NOT dispatch a slash command; it opens the picker. viewModel.sendMessage("/model") advanceUntilIdle() @@ -1387,6 +1522,96 @@ class ChatViewModelTest { // ── Streaming tests ────────────────────────────────────────────────────── + @Test + fun terminalReplyFailureIsScopedDismissibleAndNeverResends() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("Partial", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.MessageComplete("Other error", "other-session", rawPayload = mapOf("status" to "error")), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isAgentTyping) + assertNull(viewModel.uiState.value.replyFailure) + assertEquals( + "Partial", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + mockEventsFlow.emit( + WsEvent.MessageComplete("Provider error", sessionId, rawPayload = mapOf("status" to "error")), + ) + advanceUntilIdle() + val failure = viewModel.uiState.value.replyFailure!! + assertFalse(viewModel.uiState.value.isAgentTyping) + assertFalse(viewModel.uiState.value.isThinking) + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Partial" }, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Provider error" }, + ) + viewModel.dismissReplyFailure("stale-card") + advanceUntilIdle() + assertEquals(failure, viewModel.uiState.value.replyFailure) + viewModel.dismissReplyFailure(failure.id) + advanceUntilIdle() + assertNull(viewModel.uiState.value.replyFailure) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun unscopedFailureCannotCrossSessionSwitch() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val failure = WsEvent.MessageComplete("Error", null, rawPayload = mapOf("status" to "error")) + mockEventsFlow.emit(failure) + advanceUntilIdle() + assertNull(viewModel.uiState.value.replyFailure) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.switchSession("another-session") + advanceUntilIdle() + // A delayed start without identity cannot authorize a failed turn on this chat. + mockEventsFlow.emit(WsEvent.MessageStart(null)) + advanceUntilIdle() + mockEventsFlow.emit(failure) + advanceUntilIdle() + assertNull(viewModel.uiState.value.replyFailure) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Error" }, + ) + } + + @Test + fun unscopedFailureAfterForeignStartCannotUsePreviousSessionPin() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("Current partial", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart("foreign-session")) + mockEventsFlow.emit( + WsEvent.MessageComplete("Foreign diagnostic", null, rawPayload = mapOf("status" to "error")), + ) + advanceUntilIdle() + assertNull(viewModel.uiState.value.replyFailure) + assertEquals( + "Current partial", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Foreign diagnostic" }, + ) + } + @Test fun testMessageStreamingFlow() = runTest { @@ -1626,6 +1851,36 @@ class ChatViewModelTest { // ── Clarify tests ──────────────────────────────────────────────────────── + @Test + fun mainTurnBusyTracksRuntimeEventsNotClarificationTyping() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertFalse(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.isMainTurnBusy) + + mockEventsFlow.emit( + WsEvent.ClarifyRequest( + text = "Continue?", + options = listOf("Yes"), + clarifyId = "clarify-busy-state", + sessionId = sessionId, + ), + ) + advanceUntilIdle() + viewModel.respondToClarify("Yes") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isAgentTyping) + assertFalse(viewModel.uiState.value.isMainTurnBusy) + } + @Test fun testClarifyRequestAndRespond() = runTest { @@ -2107,20 +2362,16 @@ class ChatViewModelTest { size = MAX_CHAT_ATTACHMENT_BYTES + 1, ) - viewModel.sendMessage("Inspect this archive") + assertFalse(viewModel.sendMessage("Inspect this archive")) advanceUntilIdle() assertEquals(2, viewModel.uiState.value.pendingAttachments.size) - assertEquals("Inspect this archive", viewModel.uiState.value.composerTextToRestore) + assertNull(viewModel.uiState.value.composerTextToRestore) assertTrue( viewModel.uiState.value.errorMessage ?.contains("too large") == true, ) verify(exactly = 0) { contentResolver.openInputStream(any()) } - - viewModel.consumeComposerTextRestore() - advanceUntilIdle() - assertNull(viewModel.uiState.value.composerTextToRestore) } @Test @@ -2272,11 +2523,24 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val uriString = "content://test/note" val uri = mockk() + val snapshotUri = mockk() val resolver = mockk() + lateinit var snapshotFile: java.io.File mockkStatic(Uri::class) every { Uri.parse(uriString) } returns uri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-note-copy" + every { Uri.parse("file://private-note-copy") } returns snapshotUri every { app.contentResolver } returns resolver every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns CompletableDeferred( buildJsonObject { @@ -2598,7 +2862,7 @@ class ChatViewModelTest { viewModel.uiState.value.messages[1] .role, ) - assertTrue(viewModel.uiState.value.isAgentTyping) + assertTrue(viewModel.uiState.value.isSending) assertEquals(sessionId, ActiveSessionHolder.resolveStoredSessionId(sessionId)) verify { HermesWsClient.sendMessage(sessionId, "Hello Hermes", any()) } @@ -2612,6 +2876,8 @@ class ChatViewModelTest { // First send starts streaming viewModel.sendMessage("Hello Hermes") advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() assertTrue(viewModel.uiState.value.isAgentTyping) // Second send while streaming triggers redirect @@ -2621,6 +2887,1834 @@ class ChatViewModelTest { verify { HermesWsClient.sendRedirect(sessionId, "Wait, correction", any()) } } + @Test + fun rejectedRedirectRemovesGhostAndQueuesRecoverableText() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + var redirectId = "" + every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers { + redirectId = "rejected-redirect" + arg<((String) -> Unit)?>(2)?.invoke(redirectId) + redirectId + } + + viewModel.sendMessage("First turn") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Correction") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected"))) + advanceUntilIdle() + + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Correction" }, + ) + assertFalse(fakeRepo.loadMessages(sessionId).any { it.content == "Correction" }) + assertTrue( + viewModel.uiState.value.pendingSends + .any { it.text == "Correction" }, + ) + assertTrue(viewModel.uiState.value.isAgentTyping) + } + + @Test + fun rejectedRedirectFallsBackToQueueAndRestHistoryRecoversOneUserRow() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + var redirectId = "" + var queuedId = "" + var historyHasCorrection = false + every { HermesWsClient.sendRedirect(sessionId, any(), any()) } answers { + redirectId = "redirect-for-history" + arg<((String) -> Unit)?>(2)?.invoke(redirectId) + redirectId + } + every { HermesWsClient.sendMessage(sessionId, "Correction", any(), true) } answers { + queuedId = "queued-for-history" + arg<((String) -> Unit)?>(2)?.invoke(queuedId) + queuedId + } + coEvery { + ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any()) + } answers { + retrofit2.Response.success( + SessionMessagesResponse( + messages = + if (historyHasCorrection) { + listOf( + SessionMessage( + id = 12, + role = "user", + content = JsonPrimitive("Correction"), + timestamp = JsonPrimitive(System.currentTimeMillis()), + ), + ) + } else { + emptyList() + }, + pagination = PaginationInfo(order = "latest"), + ), + ) + } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Correction") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(redirectId, mapOf("status" to "rejected"))) + advanceUntilIdle() + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Correction" }, + ) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals("queued-for-history", queuedId) + mockEventsFlow.emit(WsEvent.RpcResult(queuedId, mapOf("status" to "queued"))) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + + historyHasCorrection = true + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Answered", sessionId)) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals( + 1, + viewModel.uiState.value.messages + .count { it.content == "Correction" }, + ) + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Correction" && it.canonicalRestId != null }, + ) + } + + @Test + fun busyQueueWaitsForCompletionAndUsesQueuedSubmit() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + assertTrue(viewModel.sendMessage("Next turn", BusySendMode.QUEUE)) + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Next turn", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Next turn", any(), true) } + } + + @Test + fun queueTapAfterTurnCompletionSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + assertTrue(viewModel.sendMessage("Late queue tap", BusySendMode.QUEUE)) + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Late queue tap", any(), false) } + assertFalse( + viewModel.uiState.value.pendingSends + .any { it.text == "Late queue tap" && it.state == PendingSendState.QUEUED }, + ) + } + + @Test + fun queueDrainKeepsOwnershipWhileFirstReceiptPersistenceIsSuspended() = + runTest { + fakeRepo = spyk(fakeRepo) + val releasePersistence = CompletableDeferred() + var holdPersistence = false + var preparationCount = 0 + coEvery { fakeRepo.persistMessage(match { it.content == "FIFO first" }, any()) } coAnswers { + if (holdPersistence) { + preparationCount++ + releasePersistence.await() + } + Unit + } + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("FIFO first", BusySendMode.QUEUE) + viewModel.sendMessage("FIFO second", BusySendMode.QUEUE) + advanceUntilIdle() + + holdPersistence = true + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals(1, preparationCount) + // A duplicate completion must not start another drain during pre-enqueue IO. + mockEventsFlow.emit(WsEvent.MessageComplete("Done again", sessionId)) + advanceUntilIdle() + val preparationsBeforeRelease = preparationCount + releasePersistence.complete(Unit) + advanceUntilIdle() + + assertEquals(1, preparationsBeforeRelease) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "FIFO first", any(), true) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "FIFO second", any(), any()) } + } + + @Test + fun slashQueueSharesDurableLocalQueueWhileBusy() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("/queue after tools") + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "after tools", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "after tools", any(), true) } + } + + @Test + fun busyAttachmentIsSnapshottedAndQueuedWithoutRedirect() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queued/photo") } returns sourceUri + every { Uri.parse("file://queued-copy") } returns snapshotUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://queued-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + io.mockk.mockkConstructor(android.util.Base64OutputStream::class) + every { + anyConstructed().write(any(), any(), any()) + } returns Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queued/photo", "photo.png", "image/png", 3) + viewModel.sendMessage("See photo") + assertTrue("The receipt must not exist before the private snapshot completes", store.all().isEmpty()) + advanceUntilIdle() + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + assertTrue(snapshotFile.exists()) + assertEquals( + "file://queued-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 0) { HermesWsClient.sendRedirect(any(), any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "See photo", any(), true) } + } + + @Test + fun manualAttachmentRetryCannotOverlapActiveQueuePreparation() = + runTest { + fakeRepo = spyk(fakeRepo) + val releasePersistence = CompletableDeferred() + var holdPersistence = false + var preparations = 0 + coEvery { fakeRepo.persistMessage(match { it.content == "Queued attachment" }, any()) } coAnswers { + if (holdPersistence) { + preparations++ + releasePersistence.await() + } + Unit + } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshot: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queue/overlap") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshot = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://queue-overlap" + every { Uri.parse("file://queue-overlap") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshot.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:overlap.txt")) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queue/overlap", "overlap.txt", "text/plain", 3) + viewModel.sendMessage("Queued attachment", BusySendMode.QUEUE) + advanceUntilIdle() + val receipt = store.all().single() + holdPersistence = true + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + assertEquals(1, preparations) + + viewModel.sendQueuedNow(receipt.id) + viewModel.sendQueuedNow(receipt.id) + advanceUntilIdle() + val preparationsBeforeRelease = preparations + releasePersistence.complete(Unit) + advanceUntilIdle() + + assertEquals(1, preparationsBeforeRelease) + assertEquals(1, store.all().size) + assertEquals(PendingSendState.SENDING, store.all().single().state) + verify(exactly = 1) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), true) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(sessionId, "@file:overlap.txt\n\nQueued attachment", any(), false) + } + } + + @Test + fun idleAttachmentReceiptIsCommittedOnlyWithPrivateSnapshotUri() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://idle/photo") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-idle-copy" + every { Uri.parse("file://private-idle-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + viewModel.addAttachment("content://idle/photo", "photo.png", "image/png", 3) + assertTrue(viewModel.sendMessage("Idle attachment")) + assertTrue(store.all().isEmpty()) + advanceUntilIdle() + + assertTrue(snapshotFile.exists()) + assertEquals( + "file://private-idle-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle attachment", any(), false) } + } + + @Test + fun interruptAttachmentReceiptIsPrivateBeforeInterruptRequest() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://interrupt/note") } returns sourceUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://private-interrupt-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(4, 5, 6).inputStream() } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://interrupt/note", "note.txt", "text/plain", 3) + assertTrue(viewModel.sendMessage("Replace with file", BusySendMode.INTERRUPT)) + assertTrue(store.all().isEmpty()) + advanceUntilIdle() + + assertEquals( + "file://private-interrupt-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replace with file", any(), any()) } + } + + @Test + fun slowAttachmentPrecommitKeepsLaterTextBehindItsReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://slow/first") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-first-copy" + every { Uri.parse("file://private-first-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + assertTrue(viewModel.sendMessage("Later text")) + byteArrayOf(1, 2, 3).inputStream() + } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any(), any()) } returns + CompletableDeferred(mapOf("attached" to true)) + + viewModel.addAttachment("content://slow/first", "first.png", "image/png", 3) + assertTrue(viewModel.sendMessage("First attachment")) + advanceUntilIdle() + + assertEquals(listOf("First attachment", "Later text"), store.all().map { it.text }) + assertTrue(store.all().zipWithNext().all { (a, b) -> a.createdAt < b.createdAt }) + assertEquals(PendingSendState.SENDING, store.all()[0].state) + assertEquals(PendingSendState.QUEUED, store.all()[1].state) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "First attachment", any(), false) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later text", any(), any()) } + } + + @Test + fun busyAttachmentSnapshotFailureRestoresDraftWithoutReceiptOrGhost() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://queued/missing") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } returns null + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://queued/missing", "missing.txt", "text/plain", 4) + + assertTrue(viewModel.sendMessage("Keep my draft", BusySendMode.QUEUE)) + advanceUntilIdle() + + assertTrue(store.all().isEmpty()) + assertEquals("Keep my draft", viewModel.uiState.value.composerTextToRestore) + assertEquals( + "content://queued/missing", + viewModel.uiState.value.pendingAttachments + .single() + .uri, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Keep my draft" }, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep my draft", any(), any()) } + } + + @Test + fun busyAttachmentSnapshotFinishingAfterSessionSwitchDoesNotCommitOrSubmit() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var stagedFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://queued/session-switch") } returns sourceUri + every { Uri.fromFile(any()) } answers { + stagedFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://stale-copy" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + viewModel.switchSession("session-456") + byteArrayOf(1, 2, 3).inputStream() + } + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment( + "content://queued/session-switch", + "switch.txt", + "text/plain", + 3, + ) + assertTrue(viewModel.sendMessage("Old session queued", BusySendMode.QUEUE)) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Old session queued" }, + ) + + advanceUntilIdle() + + assertEquals("session-456", viewModel.uiState.value.currentSessionId) + assertTrue(store.all().isEmpty()) + assertFalse(stagedFile.exists()) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Old session queued", any(), any()) } + } + + @Test + fun idleIgnoresBusyModeAndSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertTrue(viewModel.sendMessage("Idle", BusySendMode.GUIDE)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Idle", any(), false) } + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + } + + @Test + fun subagentActivityAloneDoesNotTurnAnIdleMainSessionBusy() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.progress", + payload = mapOf("subagent_id" to "child-1", "status" to "running"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + viewModel.sendMessage("Main prompt", BusySendMode.GUIDE) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Main prompt", any(), false) } + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + } + + @Test + fun toolStartMarksMainTurnBusyEvenWithoutMessageStart() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.ToolStart("shell", emptyMap(), sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Guide", BusySendMode.GUIDE) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.send(WsMethods.SESSION_STEER, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Guide", any(), any()) } + } + + @Test + fun refreshSettingsPicksUpChangedSavedBusyMode() = + runTest { + var savedMode = BusySendMode.CORRECT + every { AuthManager.getBusySendMode() } answers { savedMode } + val viewModel = createViewModel() + advanceUntilIdle() + assertEquals(BusySendMode.CORRECT, viewModel.uiState.value.busySendMode) + + savedMode = BusySendMode.GUIDE + viewModel.refreshSettings() + advanceUntilIdle() + assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode) + } + + @Test + fun rapidDuplicateTapCreatesOneReceiptAndOneRpc() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + assertTrue(viewModel.sendMessage("One tap")) + assertFalse(viewModel.sendMessage("One tap")) + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "One tap", any(), any()) } + } + + @Test + fun stalePromptAckAfterSessionSwitchKeepsOldReceiptAndCurrentSessionClear() = + runTest { + stubSession456Rests(success = true) + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + val requestId = "old-prompt" + every { HermesWsClient.sendMessage(sessionId, "Old session", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(requestId) + requestId + } + viewModel.sendMessage("Old session") + advanceUntilIdle() + viewModel.switchSession("session-456") + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals(PendingSendState.UNKNOWN, sendStore.all().single().state) + } + + @Test + fun busyGuideUsesSteerAndUnsupportedFallsBackToQueueWithoutInterrupt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Keep the tests", BusySendMode.GUIDE) + advanceUntilIdle() + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second + mockEventsFlow.emit(WsEvent.RpcError(id, JsonRpcError(4010, "steer unsupported"))) + advanceUntilIdle() + + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + } + + @Test + fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit") + "uncertain-submit" + } + + viewModel.sendMessage("Uncertain") + advanceUntilIdle() + viewModel.expireOutgoingRequest("uncertain-submit") + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + + val replacement = + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + assertEquals(BusySendMode.QUEUE, replacement.mode) + assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single { it.text == "Uncertain" } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + } + + @Test + fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("accepted-submit") + "accepted-submit" + } + + viewModel.sendMessage("Accepted") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("accepted-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + + val replacement = + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + assertEquals(BusySendMode.QUEUE, replacement.mode) + assertEquals(PendingSendState.QUEUED, replacement.state) + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single { it.text == "Accepted" } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + } + + @Test + fun interruptAfterCompletedTurnSubmitsNormally() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Fresh turn", BusySendMode.INTERRUPT) + advanceUntilIdle() + + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) } + } + + @Test + fun hardInterruptWaitsForAckBeforeSubmitting() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Urgent", BusySendMode.INTERRUPT) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Urgent", any(), any()) } + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted"))) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Urgent", any(), true) } + } + + @Test + fun notInterruptedQueuesKnownUnsentReplacementAndSubmitsExactlyOnce() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + assertEquals( + PendingSendState.SENDING, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + } + + @Test + fun lateNotInterruptedDoesNotClearOrRaceANewerSameSessionTurn() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "not_interrupted"))) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isMainTurnBusy) + assertEquals( + "new turn", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + } + + @Test + fun lateInterruptedDoesNotClearOrDispatchAcrossANewerSameSessionTurn() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Replacement", BusySendMode.INTERRUPT) + advanceUntilIdle() + val interruptId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("new turn", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(interruptId, mapOf("status" to "interrupted"))) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isMainTurnBusy) + assertEquals( + "new turn", + viewModel.streamingState.value.streamingMessage + ?.content, + ) + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.text == "Replacement" } + .state, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Replacement", any(), true) } + } + + @Test + fun attachmentInterruptUsesTurnCapturedAtClickAndQueuesIfCopyOutlivesIt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://interrupt/slow") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-slow-copy" + every { Uri.parse("file://private-slow-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + mockEventsFlow.tryEmit(WsEvent.MessageStart(sessionId)) + byteArrayOf(1, 2, 3).inputStream() + } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:slow.txt")) + + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.addAttachment("content://interrupt/slow", "slow.txt", "text/plain", 3) + assertTrue(viewModel.sendMessage("Captured turn", BusySendMode.INTERRUPT)) + advanceUntilIdle() + + val receipt = store.all().single() + assertEquals(PendingSendState.QUEUED, receipt.state) + assertEquals(BusySendMode.QUEUE, receipt.mode) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify( + exactly = 1, + ) { HermesWsClient.sendMessage(sessionId, "@file:slow.txt\n\nCaptured turn", any(), true) } + } + + @Test + fun stopParksLocalQueueAndSendNowUnparksIt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Later", BusySendMode.QUEUE) + advanceUntilIdle() + val queuedId = + viewModel.uiState.value.pendingSends + .single() + .id + + viewModel.interruptSession() + advanceUntilIdle() + assertEquals( + PendingSendState.PARKED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + val stopId = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(stopId, mapOf("status" to "interrupted"))) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Later", any(), any()) } + + viewModel.sendQueuedNow(queuedId) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Later", any(), true) } + } + + @Test + fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers { + arg<((String) -> Unit)?>(2)?.invoke("first-queued-submit") + "first-queued-submit" + } + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("First", BusySendMode.QUEUE) + viewModel.sendMessage("Second", BusySendMode.QUEUE) + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("first-queued-submit", mapOf("status" to "queued"))) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + val second = + viewModel.uiState.value.pendingSends + .single { it.text == "Second" } + viewModel.sendQueuedNow(second.id) + advanceUntilIdle() + + assertEquals( + PendingSendState.QUEUED, + viewModel.uiState.value.pendingSends + .single { it.id == second.id } + .state, + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + } + + @Test + fun manualRetryRecommitsAttachmentOnlyAfterPrivateStaging() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/note") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-retry-copy" + every { Uri.parse("file://private-retry-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(7, 8, 9).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:note.txt")) + store.put( + PendingSend( + id = "legacy-retry", + scope = scope, + sessionId = sessionId, + text = "Retry attachment", + attachments = listOf(Attachment("content://retry/note", "note.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("legacy-retry") + viewModel.sendQueuedNow("legacy-retry") + assertEquals( + "content://retry/note", + store + .all() + .single() + .attachments + .single() + .uri, + ) + assertTrue(store.all().single().requiresAttachmentRecovery) + advanceUntilIdle() + + assertEquals( + "file://private-retry-copy", + store + .all() + .single() + .attachments + .single() + .uri, + ) + assertFalse(store.all().single().requiresAttachmentRecovery) + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "@file:note.txt\n\nRetry attachment", any(), false) + } + } + + @Test + fun manualRetryPreparationOversizePreservesOriginalReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/growing") } returns sourceUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-growing-copy" + every { Uri.parse("file://retry-growing-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + remaining -= count + return count + } + } + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + store.put( + PendingSend( + id = "retry-growing", + scope = scope, + sessionId = sessionId, + text = "Keep recovery receipt", + attachments = + listOf( + Attachment("content://retry/growing", "growing.bin", "application/octet-stream", 1), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + val original = store.all().single() + viewModel.refreshSettings() + advanceUntilIdle() + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("too large") == true, + ) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep recovery receipt", any(), any()) } + } + + @Test + fun manualRetryOwnerChangePreservesExistingPrivateSnapshot() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-existing/0") + snapshot.parentFile.mkdirs() + snapshot.writeBytes(byteArrayOf(1, 2, 3)) + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("file://retry-existing") } returns snapshotUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-existing" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(snapshotUri) } answers { + baseUrl = "http://other-profile.local/" + snapshot.inputStream() + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + val original = + PendingSend( + id = "retry-existing", + scope = scope, + sessionId = sessionId, + text = "Keep existing snapshot", + attachments = listOf(Attachment("file://retry-existing", "note.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + store.put(original) + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + assertTrue("Rollback must retain the original receipt's private bytes", snapshot.exists()) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun manualRetryUnreadablePreparationPreservesUnknownReceipt() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val snapshot = java.io.File(attachmentFilesDir, "chat-send/retry-unreadable/0") + snapshot.parentFile.mkdirs() + snapshot.writeBytes(byteArrayOf(1)) + val snapshotUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("file://retry-unreadable") } returns snapshotUri + every { Uri.fromFile(any()) } returns snapshotUri + every { snapshotUri.toString() } returns "file://retry-unreadable" + every { app.contentResolver } returns resolver + every { resolver.openInputStream(snapshotUri) } returns null + val original = + PendingSend( + id = "retry-unreadable", + scope = scope, + sessionId = sessionId, + text = "Keep unknown receipt", + attachments = listOf(Attachment("file://retry-unreadable", "note.txt", "text/plain", 1)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ) + store.put(original) + viewModel.sendQueuedNow(original.id) + advanceUntilIdle() + + assertEquals(listOf(original), store.all()) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun legacyContentUriReceiptIsQuarantinedUntilExplicitRecovery() = + runTest { + val store = ChatSendStore() + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put( + PendingSend( + id = "legacy-auto-drain", + scope = scope, + sessionId = "session-123", + text = "Needs attachment recovery", + attachments = listOf(Attachment("content://legacy/lost", "lost.txt", "text/plain", 4)), + mode = BusySendMode.QUEUE, + state = PendingSendState.QUEUED, + ), + ) + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + + val receipt = + viewModel.uiState.value.pendingSends + .single() + assertEquals(PendingSendState.REJECTED, receipt.state) + assertTrue(receipt.requiresAttachmentRecovery) + assertEquals("Needs attachment recovery", receipt.text) + assertEquals("lost.txt", receipt.attachments.single().name) + + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Needs attachment recovery", any(), any()) } + } + + @Test + fun manualAttachmentRetryKeepsOriginalReceiptAcrossSessionChange() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/session") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + viewModel.switchSession("session-456") + byteArrayOf(1, 2, 3).inputStream() + } + store.put( + PendingSend( + id = "retry-session", + scope = scope, + sessionId = sessionId, + text = "Keep old session receipt", + attachments = listOf(Attachment("content://retry/session", "session.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ), + ) + viewModel.refreshSettings() + + viewModel.sendQueuedNow("retry-session") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-session" } + assertEquals("content://retry/session", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old session receipt", any(), any()) } + } + + @Test + fun manualAttachmentRetryKeepsOriginalReceiptAcrossProfileScopeChange() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val resolver = mockk() + mockkStatic(Uri::class) + every { Uri.parse("content://retry/profile") } returns sourceUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { + baseUrl = "http://other-profile.local/" + byteArrayOf(1, 2, 3).inputStream() + } + store.put( + PendingSend( + id = "retry-profile", + scope = scope, + sessionId = sessionId, + text = "Keep old profile receipt", + attachments = listOf(Attachment("content://retry/profile", "profile.txt", "text/plain", 3)), + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ), + ) + viewModel.refreshSettings() + + viewModel.sendQueuedNow("retry-profile") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-profile" } + assertEquals(scope, preserved.scope) + assertEquals("content://retry/profile", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Keep old profile receipt", any(), any()) } + } + + // ── Send ownership linearization regressions ──────────────────────────── + + @Test + fun manualAttachmentRetryContextChangeAfterValidationPreservesOriginalReceiptAndDoesNotDispatch() = + runTest { + var baseUrl = "http://test.local/" + var changeContextOnNextStoreEdit = false + every { AuthManager.getBaseUrl() } answers { baseUrl } + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } returns null + every { prefs.edit() } answers { + if (changeContextOnNextStoreEdit) { + changeContextOnNextStoreEdit = false + baseUrl = "http://other-profile.local/" + } + editor + } + every { editor.putString("rows", any()) } returns editor + every { editor.commit() } returns true + val store = ChatSendStore(prefs) + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/post-validation") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-post-validation-copy" + every { Uri.parse("file://private-post-validation-copy") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:post-validation.txt")) + store.put( + PendingSend( + id = "retry-post-validation", + scope = scope, + sessionId = sessionId, + text = "Keep post-validation receipt", + attachments = + listOf( + Attachment( + "content://retry/post-validation", + "post-validation.txt", + "text/plain", + 3, + ), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + // processPendingSendReservation has already validated the old owner when it + // opens the store transaction that would replace the recovery receipt. + changeContextOnNextStoreEdit = true + viewModel.sendQueuedNow("retry-post-validation") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-post-validation" } + assertEquals("http://other-profile.local/", baseUrl) + assertEquals(scope, preserved.scope) + assertEquals("content://retry/post-validation", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { + HermesWsClient.sendMessage( + any(), + "@file:post-validation.txt\n\nKeep post-validation receipt", + any(), + any(), + ) + } + } + + @Test + fun queuedClaimContextChangeBeforeFinalValidationRollsBackToQueuedWithoutDispatch() = + runTest { + var baseUrl = "http://test.local/" + var armClaimCommit = false + var changeContextOnNextScopeRead = false + every { AuthManager.getBaseUrl() } answers { + if (changeContextOnNextScopeRead) { + changeContextOnNextScopeRead = false + baseUrl = "http://other-profile.local/" + } + baseUrl + } + val prefs = mockk() + val editor = mockk() + every { prefs.getString("rows", null) } returns null + every { prefs.edit() } returns editor + every { editor.putString("rows", any()) } returns editor + every { editor.commit() } answers { + if (armClaimCommit) { + armClaimCommit = false + changeContextOnNextScopeRead = true + } + true + } + val store = ChatSendStore(prefs) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put( + PendingSend( + id = "queue-post-claim-context-change", + scope = scope, + sessionId = "session-123", + text = "Claimed then stale", + mode = BusySendMode.QUEUE, + state = PendingSendState.QUEUED, + ), + ) + + // The claim's durable commit arms the first scope read in publishPendingSends, + // after update() has installed SENDING but before drainPendingQueue's final check. + armClaimCommit = true + val (_, sessionId) = createViewModelWithSession(sendStore = store) + mockEventsFlow.emit(WsEvent.MessageComplete("Done", sessionId)) + advanceUntilIdle() + + val recoverable = store.all().single { it.id == "queue-post-claim-context-change" } + assertEquals("http://other-profile.local/", baseUrl) + assertEquals(PendingSendState.QUEUED, recoverable.state) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Claimed then stale", any(), any()) } + } + + @Test + fun manualAttachmentRetryContextChangeBeforeAttachmentRpcPreservesOriginalReceipt() = + runTest { + var baseUrl = "http://test.local/" + every { AuthManager.getBaseUrl() } answers { baseUrl } + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + baseUrl, + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + val sourceUri = mockk() + val snapshotUri = mockk() + val resolver = mockk() + lateinit var snapshotFile: java.io.File + mockkStatic(Uri::class) + every { Uri.parse("content://retry/pre-attachment-rpc") } returns sourceUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-pre-attachment-rpc" + every { Uri.parse("file://private-pre-attachment-rpc") } returns snapshotUri + every { app.contentResolver } returns resolver + every { resolver.openInputStream(sourceUri) } answers { byteArrayOf(1, 2, 3).inputStream() } + every { resolver.openInputStream(snapshotUri) } answers { + baseUrl = "http://other-profile.local/" + snapshotFile.inputStream() + } + mockkConstructor(android.util.Base64OutputStream::class) + every { anyConstructed().write(any(), any(), any()) } returns + Unit + every { anyConstructed().close() } returns Unit + every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns + CompletableDeferred(mapOf("ref_text" to "@file:pre-attachment-rpc.txt")) + store.put( + PendingSend( + id = "retry-pre-attachment-rpc", + scope = scope, + sessionId = sessionId, + text = "Keep pre-attachment receipt", + attachments = + listOf( + Attachment( + "content://retry/pre-attachment-rpc", + "pre-attachment-rpc.txt", + "text/plain", + 3, + ), + ), + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("retry-pre-attachment-rpc") + advanceUntilIdle() + + val preserved = store.all().single { it.id == "retry-pre-attachment-rpc" } + assertEquals(scope, preserved.scope) + assertEquals("content://retry/pre-attachment-rpc", preserved.attachments.single().uri) + assertEquals(PendingSendState.REJECTED, preserved.state) + assertTrue(preserved.requiresAttachmentRecovery) + verify(exactly = 0) { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun acceptedReceiptCanBeRetriedOnlyByExplicitSendAgain() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("buffered-submit") + "buffered-submit" + } + viewModel.sendMessage("Possibly buffered") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult("buffered-submit", mapOf("status" to "streaming"))) + advanceUntilIdle() + val receipt = + viewModel.uiState.value.pendingSends + .single() + assertEquals(PendingSendState.ACCEPTED, receipt.state) + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) } + + viewModel.sendQueuedNow(receipt.id) + advanceUntilIdle() + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Possibly buffered", any(), true) } + } + + @Test + fun disconnectQuarantinesAcceptedReceiptWithoutAutomaticResend() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + every { HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false) } answers { + arg<((String) -> Unit)?>(2)?.invoke("accepted-before-disconnect") + "accepted-before-disconnect" + } + viewModel.sendMessage("Accepted before disconnect") + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.RpcResult("accepted-before-disconnect", mapOf("status" to "streaming")), + ) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + advanceUntilIdle() + + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 1) { + HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), false) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(sessionId, "Accepted before disconnect", any(), true) + } + } + + @Test + fun sendNowPromotesTheSelectedQueuedMessageWhileIdle() = + runTest { + val store = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put(PendingSend("first", scope, sessionId, "First queued", mode = BusySendMode.QUEUE)) + store.put(PendingSend("second", scope, sessionId, "Second queued", mode = BusySendMode.QUEUE)) + viewModel.refreshSettings() + advanceUntilIdle() + + viewModel.sendQueuedNow("second") + advanceUntilIdle() + + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second queued", any(), true) } + verify(exactly = 0) { HermesWsClient.sendMessage(sessionId, "First queued", any(), any()) } + assertEquals(PendingSendState.QUEUED, store.all().single { it.id == "first" }.state) + } + + @Test + fun pendingQueueIsScopedByConnectionProfileAndStoredSession() = + runTest { + var connectionProfile = "first" + every { AuthManager.getSelectedProfileId() } answers { connectionProfile } + stubSession456Rests(success = true) + val sendStore = ChatSendStore() + val (viewModel, sessionId) = createViewModelWithSession(sendStore = sendStore) + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + advanceUntilIdle() + viewModel.sendMessage("Only here", BusySendMode.QUEUE) + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + + connectionProfile = "second" + viewModel.refreshSettings() + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + connectionProfile = "first" + viewModel.refreshSettings() + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.pendingSends.size) + + viewModel.switchSession("session-456") + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + } + + @Test + fun restoredQueueDrainsAfterResumeHydrationWhenAckArrivesFirst() = + runTest { + stubSession456Rests(success = true) + val response = + CompletableDeferred>() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + val store = ChatSendStore() + val (viewModel, _) = createViewModelWithSession(sendStore = store) + val captured = captureSends() + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put(PendingSend("restored", scope, "session-456", "After relaunch", mode = BusySendMode.QUEUE)) + + viewModel.switchSession("session-456") + runCurrent() + val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit(WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false))) + runCurrent() + assertFalse(viewModel.uiState.value.isSessionReady) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), "After relaunch", any(), any()) } + + response.complete(retrofit2.Response.success(SessionMessagesResponse(messages = emptyList()))) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + verify(exactly = 1) { HermesWsClient.sendMessage("runtime-456", "After relaunch", any(), true) } + } + + @Test + fun sessionSwitchCancelsObsoleteDrainAndDispatchesRestoredQueue() = + runTest { + stubSession456Rests(success = true) + val store = ChatSendStore() + val (viewModel, _) = createViewModelWithSession(sendStore = store) + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + // Model an active session-A staging job without real threads or blocking I/O. + // Reset must release ownership before session B becomes ready to drain. + val obsoleteDrain = Job() + ChatViewModel::class.java + .getDeclaredField("queueDrainJob") + .apply { isAccessible = true } + .set(viewModel, obsoleteDrain) + every { HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true) } answers { + arg<((String) -> Unit)?>(2)?.invoke("session-b-submit") + "session-b-submit" + } + store.put( + PendingSend( + "session-b-restored", + scope, + "session-456", + "Session B queued", + mode = BusySendMode.QUEUE, + ), + ) + viewModel.switchSession("session-456") + runCurrent() + val resumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcResult(resumeId, mapOf("session_id" to "runtime-456", "running" to false)), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSessionReady) + assertTrue("Session switch must cancel the obsolete drain", obsoleteDrain.isCancelled) + verify(exactly = 1) { + HermesWsClient.sendMessage("runtime-456", "Session B queued", any(), true) + } + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "Session A queued", any(), any()) + } + } + + @Test + fun timeoutKeepsRecoverableReceiptAndLateAckDoesNotResend() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val requestId = "timeout-request" + every { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(requestId) + requestId + } + viewModel.sendMessage("Maybe") + advanceUntilIdle() + viewModel.expireOutgoingRequest(requestId) + advanceUntilIdle() + assertEquals( + PendingSendState.UNKNOWN, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Maybe" }, + ) + + mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertEquals( + PendingSendState.ACCEPTED, + viewModel.uiState.value.pendingSends + .single() + .state, + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Maybe", any(), any()) } + } + + @Test + fun restCompletionBeforePromptAckSettlesReceiptAndKeepsCompletionIdentity() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val promptId = "late-prompt-ack" + every { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(promptId) + promptId + } + coEvery { + ApiClient.hermesApi.getSessionMessages(sessionId, any(), any(), any(), any()) + } returns + retrofit2.Response.success( + SessionMessagesResponse( + messages = + listOf( + SessionMessage( + id = 4, + role = "user", + content = JsonPrimitive("Fast"), + timestamp = JsonPrimitive(System.currentTimeMillis()), + ), + ), + pagination = PaginationInfo(order = "latest"), + ), + ) + + viewModel.sendMessage("Fast") + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageComplete("Answer", sessionId, completionId = "completion-fast")) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertTrue( + viewModel.uiState.value.messages + .any { it.completionId == "completion-fast" }, + ) + + mockEventsFlow.emit(WsEvent.RpcResult(promptId, mapOf("status" to "streaming"))) + advanceUntilIdle() + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fast", any(), any()) } + } + // ── Session switch ─────────────────────────────────────────────────────── @Test @@ -4794,17 +6888,24 @@ class ChatViewModelTest { viewModel.sendMessage("Here is an image") advanceUntilIdle() - // Error path is logged so we can diagnose the failed read. - verify { Log.e(any(), match { it.contains("Permission denied") }, any()) } - - // Graceful handling: the message is still sent even though the - // attachment bytes couldn't be read (no crash, no lost message). - val sent = + // Private staging failed before a durable receipt or outbound RPC existed. + assertFalse( viewModel.uiState.value.messages - .firstOrNull { it.id == sessionId } != null || - viewModel.uiState.value.messages - .any { it.content == "Here is an image" } - assertTrue("Message should still be sent despite attachment read failure", sent) + .any { it.content == "Here is an image" }, + ) + assertTrue( + viewModel.uiState.value.pendingSends + .isEmpty(), + ) + assertEquals("Here is an image", viewModel.uiState.value.composerTextToRestore) + assertEquals( + "content://dummy", + viewModel.uiState.value.pendingAttachments + .single() + .uri, + ) + verify(exactly = 0) { Log.e(any(), match { it.contains("Permission denied") }, any()) } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } } /** @@ -4843,11 +6944,20 @@ class ChatViewModelTest { // The image bytes read via ContentResolver must succeed. mockkStatic(Uri::class) val mockUri = mockk() + val snapshotUri = mockk() + lateinit var snapshotFile: java.io.File every { Uri.parse("content://dummy") } returns mockUri + every { Uri.fromFile(any()) } answers { + snapshotFile = firstArg() + snapshotUri + } + every { snapshotUri.toString() } returns "file://private-image-copy" + every { Uri.parse("file://private-image-copy") } returns snapshotUri val contentResolver = mockk() every { app.contentResolver } returns contentResolver - every { contentResolver.openInputStream(any()) } returns + every { contentResolver.openInputStream(mockUri) } returns java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + every { contentResolver.openInputStream(snapshotUri) } answers { snapshotFile.inputStream() } viewModel.addAttachment("content://dummy", "test.png", "image/png", 1000) advanceUntilIdle() @@ -5389,6 +7499,401 @@ class ChatViewModelTest { ) } + @Test + fun paging_staleCacheBeforeRestDoesNotBecomeTheLatestTail() = + runTest { + val cachedIds = seedPagingCache(3) + val response = CompletableDeferred>() + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + + viewModel.switchSession("session-456") + runCurrent() + assertEquals( + cachedIds, + viewModel.uiState.value.messages + .map { it.id }, + ) + response.complete(pagingResponse(100..102)) + advanceUntilIdle() + + val expected = cachedIds + (100..102).map { "rest-session-456-$it" } + assertEquals( + expected, + viewModel.uiState.value.messages + .map { it.id }, + ) + viewModel.syncCurrentSession() + advanceUntilIdle() + assertEquals( + expected, + viewModel.uiState.value.messages + .map { it.id }, + ) + assertTrue(fakeRepo.dao.idsForSession("session-456").containsAll(cachedIds)) + } + + @Test + fun paging_staleCacheAfterRestDoesNotBecomeTheLatestTail() = + runTest { + val cachedIds = seedPagingCache(3) + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals( + cachedIds + (100..102).map { "rest-session-456-$it" }, + viewModel.uiState.value.messages + .map { it.id }, + ) + } + + @Test + fun paging_coldRestartPendingLocalRemainsAfterCanonicalWindow() = + runTest { + val pending = + ChatMessage( + id = "pending-local", + role = MessageRole.USER, + content = "not delivered yet", + messageProvenance = MessageProvenance.LOCAL_PENDING, + ) + fakeRepo.persistMessage(pending, "session-456") + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals( + (100..102).map { "rest-session-456-$it" } + pending.id, + viewModel.uiState.value.messages + .map { it.id }, + ) + } + + @Test + fun paging_restoredLegacyUserBlockBeforeRestDoesNotFollowNewestReply() = + runTest { verifyRestoredLegacyUserPlacement(cacheFirst = true) } + + @Test + fun paging_restoredLegacyUserBlockAfterRestDoesNotFollowNewestReply() = + runTest { verifyRestoredLegacyUserPlacement(cacheFirst = false) } + + private suspend fun TestScope.verifyRestoredLegacyUserPlacement(cacheFirst: Boolean) { + val restored = + (1..3).map { index -> + ChatMessage( + id = "restored-legacy-$index", + role = MessageRole.USER, + content = "Earlier prompt $index", + // Placement must not infer delivery or chronology from wall-clock age. + timestamp = Long.MAX_VALUE - index, + ) + } + val pending = + ChatMessage( + id = "real-pending", + role = MessageRole.USER, + content = "Unsent current prompt", + timestamp = 1L, + messageProvenance = MessageProvenance.LOCAL_PENDING, + ) + (restored + pending).forEach { fakeRepo.persistMessage(it, "session-456") } + val cacheRead = CompletableDeferred() + if (!cacheFirst) fakeRepo.dao.beforeRead = { cacheRead.await() } + val response = CompletableDeferred>() + val (viewModel, _) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } coAnswers { response.await() } + + viewModel.switchSession("session-456") + runCurrent() + response.complete(pagingResponse(100..102)) + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + val expected = restored.map { it.id } + (100..102).map { "rest-session-456-$it" } + pending.id + assertEquals( + "Restored legacy prompts must not form a stale block after the newest reply", + expected, + viewModel.uiState.value.messages + .map { it.id }, + ) + restored.forEach { original -> + val retained = + viewModel.uiState.value.messages + .single { it.id == original.id } + assertEquals(original.content, retained.content) + assertEquals(original.timestamp, retained.timestamp) + assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance) + assertNull("Moving a row must not invent delivery confirmation", retained.canonicalRestId) + assertFalse("Restored placement is not proven historical delivery", retained.isHistoricalCache) + } + assertTrue(fakeRepo.dao.idsForSession("session-456").containsAll(restored.map { it.id } + pending.id)) + assertEquals(0, fakeRepo.dao.fullSessionReads) + coVerify(exactly = 1) { + api.getSessionMessages("session-456", any(), any(), any(), any()) + } + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + + viewModel.syncCurrentSession() + advanceUntilIdle() + assertEquals( + expected, + viewModel.uiState.value.messages + .map { it.id }, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun paging_queuePromptsPersistPendingProvenanceAfterRestart() = runTest { verifyQueuedPromptRestoration("/queue") } + + @Test + fun paging_queueAliasPromptsPersistPendingProvenanceAfterRestart() = runTest { verifyQueuedPromptRestoration("/q") } + + private suspend fun TestScope.verifyQueuedPromptRestoration(command: String) { + val (viewModel, sessionId) = createViewModelWithSession() + assertTrue(viewModel.sendMessage("$command pending queued prompt")) + advanceUntilIdle() + val queued = + viewModel.uiState.value.messages + .single { it.role == MessageRole.USER } + assertEquals("pending queued prompt", queued.content) + assertEquals( + "Queued prompts must retain durable pending provenance", + MessageProvenance.LOCAL_PENDING.name, + fakeRepo.dao.getMessage(queued.id)?.messageProvenance, + ) + + val api = ApiClient.hermesApi + coEvery { + api.getSessionMessages(sessionId, any(), any(), any(), any()) + } returns pagingResponse(100..102) + val recreated = createViewModel() + advanceUntilIdle() + recreated.switchSession(sessionId) + advanceUntilIdle() + + val restored = recreated.uiState.value.messages + assertEquals(queued.id, restored.last().id) + assertTrue(restored.indexOfFirst { it.id == "rest-$sessionId-102" } in 0 until restored.lastIndex) + assertEquals(MessageProvenance.LOCAL_PENDING, restored.last().messageProvenance) + assertNull(restored.last().canonicalRestId) + assertFalse(restored.last().isHistoricalCache) + verify(exactly = 1) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun paging_coldRestartReceiptBackedUnknownUserRemainsLiveTail() = + runTest { + val receiptBacked = + ChatMessage( + id = "receipt-backed-unknown-user", + role = MessageRole.USER, + content = "possibly accepted before restart", + messageProvenance = MessageProvenance.UNKNOWN, + ) + val rejected = + ChatMessage( + id = "receipt-backed-rejected-user", + role = MessageRole.USER, + content = "rejected before restart", + messageProvenance = MessageProvenance.UNKNOWN, + ) + fakeRepo.persistMessage(receiptBacked, "session-456") + fakeRepo.persistMessage(rejected, "session-456") + val store = ChatSendStore() + val scope = + listOf( + AuthManager.getBaseUrl(), + AuthManager.getSelectedProfileId() ?: AuthManager.DEFAULT_PROFILE_ID, + AuthManager.activeProfileId.value ?: AuthManager.DEFAULT_PROFILE_ID, + ).joinToString("\u001f") + store.put( + PendingSend( + id = receiptBacked.id, + scope = scope, + sessionId = "session-456", + text = receiptBacked.content, + mode = BusySendMode.QUEUE, + state = PendingSendState.UNKNOWN, + ), + ) + store.put( + PendingSend( + id = rejected.id, + scope = scope, + sessionId = "session-456", + text = rejected.content, + mode = BusySendMode.QUEUE, + state = PendingSendState.REJECTED, + ), + ) + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession(sendStore = store) + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals( + listOf(rejected.id) + (100..102).map { "rest-session-456-$it" } + receiptBacked.id, + viewModel.uiState.value.messages + .map { it.id }, + ) + val rejectedRetained = + viewModel.uiState.value.messages + .single { it.id == rejected.id } + assertTrue(rejectedRetained.isRestoredUnconfirmed) + assertEquals(MessageProvenance.UNKNOWN, rejectedRetained.messageProvenance) + val retained = + viewModel.uiState.value.messages + .single { it.id == receiptBacked.id } + assertFalse(retained.isHistoricalCache) + assertFalse(retained.isRestoredUnconfirmed) + assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance) + assertNull(retained.canonicalRestId) + assertEquals(PendingSendState.UNKNOWN, store.all().single { it.id == receiptBacked.id }.state) + assertEquals(PendingSendState.REJECTED, store.all().single { it.id == rejected.id }.state) + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), receiptBacked.content, any(), any()) + } + } + + @Test + fun paging_coldRestartLegacyUnknownUserRemainsConservativelyUnconfirmed() = + runTest { + val ambiguous = + ChatMessage( + id = "legacy-unknown-user", + role = MessageRole.USER, + content = "possibly unsent before migration", + ) + fakeRepo.persistMessage(ambiguous, "session-456") + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + assertEquals( + listOf(ambiguous.id) + (100..102).map { "rest-session-456-$it" }, + viewModel.uiState.value.messages + .map { it.id }, + ) + val retained = + viewModel.uiState.value.messages + .single { it.id == ambiguous.id } + assertFalse(retained.isHistoricalCache) + assertTrue(retained.isRestoredUnconfirmed) + assertEquals(MessageProvenance.UNKNOWN, retained.messageProvenance) + assertNull(retained.canonicalRestId) + assertTrue(fakeRepo.dao.idsForSession("session-456").contains(ambiguous.id)) + } + + @Test + fun paging_coldRestartPermanentLocalKeepsLocalOrdering() = + runTest { + val localCommand = + ChatMessage( + id = "local-command", + role = MessageRole.USER, + content = "/help", + ) + fakeRepo.persistMessage(localCommand, "session-456") + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + + viewModel.switchSession("session-456") + runCurrent() + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals( + (100..102).map { "rest-session-456-$it" } + localCommand.id, + viewModel.uiState.value.messages + .map { it.id }, + ) + } + + @Test + fun paging_staleCacheDoesNotDemoteOptimisticIdentity() = + runTest { + val cachedIds = seedPagingCache(3) + val cacheRead = CompletableDeferred() + fakeRepo.dao.beforeRead = { cacheRead.await() } + val (viewModel, _) = createViewModelWithSession() + coEvery { + ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any()) + } returns pagingResponse(100..102) + + viewModel.switchSession("session-456") + runCurrent() + val attachment = Attachment("content://test/file", "file.txt", "text/plain") + val optimistic = + ChatMessage( + id = cachedIds[1], + role = MessageRole.USER, + content = "Cached message 2", + attachments = listOf(attachment), + ) + + @Suppress("UNCHECKED_CAST") + val state = + ChatViewModel::class.java + .getDeclaredField("_uiState") + .apply { isAccessible = true } + .get(viewModel) as MutableStateFlow + state.value = viewModel.uiState.value.copy(messages = viewModel.uiState.value.messages + optimistic) + cacheRead.complete(Unit) + advanceUntilIdle() + + assertEquals( + listOf(cachedIds[0], cachedIds[2]) + (100..102).map { "rest-session-456-$it" } + cachedIds[1], + viewModel.uiState.value.messages + .map { it.id }, + ) + val retained = + viewModel.uiState.value.messages + .last() + assertFalse(retained.isHistoricalCache) + assertEquals(listOf(attachment), retained.attachments) + } + // Chat paging: deterministic regressions, with no real network or wall-clock timing. private fun seedPagingCache( count: Int, @@ -6543,7 +9048,7 @@ class ChatViewModelTest { vm.uiState.value.messages .any { it.content == "My super important long prompt" }, ) - assertTrue(vm.uiState.value.isAgentTyping) + assertTrue(vm.uiState.value.isSending) // But wsClient.sendMessage not dispatched yet because session_id was null assertTrue(sentPrompts.isEmpty()) @@ -6618,7 +9123,7 @@ class ChatViewModelTest { vm.sendMessage("Prompt before failure") advanceUntilIdle() - assertTrue(vm.uiState.value.isAgentTyping) + assertTrue(vm.uiState.value.isSending) val createReqId = "req-id-$reqCount" // Emit RPC error for SESSION_CREATE @@ -7265,4 +9770,414 @@ class ChatViewModelTest { } assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId)) } + + @Test + fun sendVoiceNote_transcribesViaServerAndSubmitsTranscript() = + runTest { + TurnCorrelationTracker.resetForTest() + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "hello from voice"), + ) + val (viewModel, _) = createViewModelWithSession() + + val submittedTexts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + submittedTexts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_note.m4a").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertEquals("hello from voice", submittedTexts.lastOrNull()) + assertFalse(voiceFile.exists()) + assertNull(viewModel.uiState.value.errorMessage) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_reportsTranscriptionFailureAndDeletesClip() = + runTest { + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.error(500, "transcribe failed".toResponseBody("text/plain".toMediaType())) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_fail.m4a").apply { + writeBytes(byteArrayOf(9)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("Voice note transcription failed") == true, + ) + assertFalse(voiceFile.exists()) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_emptyTranscriptShowsNoSpeechAndDoesNotSubmit() = + runTest { + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = " ")) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_silent.m4a").apply { + writeBytes(byteArrayOf(0)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("No speech detected") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun sendVoiceNote_whileDisconnectedDropsClipLocallyWithoutUploading() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_offline.m4a").apply { + writeBytes(byteArrayOf(1)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + assertFalse(voiceFile.exists()) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("not connected") == true, + ) + } + + @Test + fun sendVoiceNote_whenSessionChangesMidTranscription_keepsTheTranscriptInTheComposer() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_session_race.m4a").apply { + writeBytes(byteArrayOf(7)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + + // The user moves to a new session while STT is still running. + viewModel.createNewSession() + advanceUntilIdle() + + transcription.complete( + Response.success(AudioTranscriptionResponse(ok = true, transcript = "late transcript")), + ) + advanceUntilIdle() + + // The recording belonged to the old session: the transcript must + // survive in the composer instead of landing in a session it was + // never recorded for, or vanishing (review, PR #1250). + assertEquals("late transcript", viewModel.uiState.value.composerTextToRestore) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun sendVoiceNote_whenTheSendIsRejected_keepsTheTranscriptInTheComposer() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + coEvery { api.transcribeAudio(any()) } returns + Response.success(AudioTranscriptionResponse(ok = true, transcript = "keep me")) + val (viewModel, _) = createViewModelWithSession() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_send_race.m4a").apply { + writeBytes(byteArrayOf(8)) + } + viewModel.sendVoiceNote(voiceFile) + // The connection drops while the transcript is being produced. + mockConnectionStatus.value = ConnectionStatus.DISCONNECTED + advanceUntilIdle() + + assertEquals("keep me", viewModel.uiState.value.composerTextToRestore) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun sendVoiceNote_whileATranscriptionIsInFlight_dropsTheSecondClip() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + val (viewModel, _) = createViewModelWithSession() + + val submittedTexts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + submittedTexts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + val firstFile = + java.io.File(attachmentCacheDir, "voice_first.m4a").apply { writeBytes(byteArrayOf(1)) } + val secondFile = + java.io.File(attachmentCacheDir, "voice_second.m4a").apply { writeBytes(byteArrayOf(2)) } + viewModel.sendVoiceNote(firstFile) + advanceUntilIdle() + + viewModel.sendVoiceNote(secondFile) + advanceUntilIdle() + + // Single-flight: the second clip is dropped without a second upload. + assertFalse(secondFile.exists()) + coVerify(exactly = 1) { api.transcribeAudio(any()) } + + transcription.complete( + Response.success(AudioTranscriptionResponse(ok = true, transcript = "only once")), + ) + advanceUntilIdle() + + assertEquals(listOf("only once"), submittedTexts) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + } + + @Test + fun sendVoiceNote_whenDataScopeChangesDuringTranscription_doesNotSendIntoNewScope() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = AuthManager.DEFAULT_PROFILE_ID, + ) + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + // A pending SESSION_CREATE is all this test needs; skipping the + // gateway preload keeps the shared ModelCatalogStore out of it. + viewModel.createNewSession() + advanceUntilIdle() + + // SESSION_CREATE is still pending, so the storage session ID is + // null — exactly the window where the session-ID snapshot alone + // cannot detect a scope move. + assertNull(viewModel.uiState.value.currentSessionId) + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_scope_switch.m4a").apply { + writeBytes(byteArrayOf(1, 2, 3)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isTranscribingVoiceNote) + + // The user switches to another server/profile while STT runs; the + // replacement session create is pending as well, so both session + // IDs can stay null/null and only the data scope moves. + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "beta", + baseUrl = "http://beta.test/", + activeProfileId = AuthManager.DEFAULT_PROFILE_ID, + ) + advanceUntilIdle() + + transcription.complete( + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "do not send this to the new server"), + ), + ) + advanceUntilIdle() + + // The transcript must not become the initial prompt of the new + // scope; it stays in the composer instead (review, PR #1250). + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "do not send this to the new server", any(), any()) + } + assertEquals( + "do not send this to the new server", + viewModel.uiState.value.composerTextToRestore, + ) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun sendVoiceNote_whenServerProfileChangesDuringTranscription_doesNotSendIntoNewScope() = + runTest { + stubActiveProfile() + val api = mockk(relaxed = true) + every { ApiClient.hermesApi } returns api + every { ApiClient.transcriptionService(any()) } returns api + val transcription = CompletableDeferred>() + coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() } + + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = "default", + ) + + val viewModel = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + // A pending SESSION_CREATE is all this test needs; skipping the + // gateway preload keeps the shared ModelCatalogStore out of it. + viewModel.createNewSession() + advanceUntilIdle() + + val voiceFile = + java.io.File(attachmentCacheDir, "voice_profile_switch.m4a").apply { + writeBytes(byteArrayOf(4, 5, 6)) + } + viewModel.sendVoiceNote(voiceFile) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isTranscribingVoiceNote) + + // Same server, different server-side Hermes profile: the session + // IDs cannot see this change either, so the scope must. + every { AuthManager.currentDataScope() } returns + DataScope( + connectionProfileId = "alpha", + baseUrl = "http://alpha.test/", + activeProfileId = "work", + ) + advanceUntilIdle() + + transcription.complete( + Response.success( + AudioTranscriptionResponse(ok = true, transcript = "profile-scoped transcript"), + ), + ) + advanceUntilIdle() + + verify(exactly = 0) { + HermesWsClient.sendMessage(any(), "profile-scoped transcript", any(), any()) + } + assertEquals( + "profile-scoped transcript", + viewModel.uiState.value.composerTextToRestore, + ) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("transcript kept") == true, + ) + assertFalse(viewModel.uiState.value.isTranscribingVoiceNote) + assertFalse(voiceFile.exists()) + } + + @Test + fun canInterrupt_staysFalseWhileTypingWithNoRuntimeSession_thenTracksTheGeneration() = + runTest { + stubActiveProfile() + val vm = createViewModel() + advanceUntilIdle() + + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + + val sentPrompts = mutableListOf() + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + reqCount++ + val id = "req-msg-$reqCount" + sentPrompts += arg(1) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + + // Queued while session create is in flight: sending is pending, + // but the session has no runtime to interrupt yet, so Stop must stay + // hidden and interruptSession() must not be reachable from it + // (review, PR #1250). + vm.sendMessage("held under session preparation") + advanceUntilIdle() + assertTrue(vm.uiState.value.isSending) + assertFalse(vm.uiState.value.canInterrupt) + + // Once the runtime session exists and the prompt is dispatched, + // the running generation is interruptible. Read the captured + // session.create id instead of counting sends: a singleton store + // warmed by an earlier test can fire an extra model.options send + // in this gateway flow and shift the counter (see the resume + // tests, same pattern). + val createReqId = + sentRequestMethods.last { it.first == WsMethods.SESSION_CREATE }.second + mockEventsFlow.emit( + WsEvent.RpcResult( + createReqId, + mapOf("session_id" to "session-969", "stored_session_id" to "session-storage-969"), + ), + ) + advanceUntilIdle() + + assertEquals(listOf("held under session preparation"), sentPrompts) + // A successful prompt.submit acknowledgement only means the + // gateway accepted it; the generation is interruptible only once + // the runtime reports message.start for its session. + mockEventsFlow.emit(WsEvent.MessageStart("session-969")) + advanceUntilIdle() + assertTrue(vm.uiState.value.canInterrupt) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt index aff84307..1b2ae77b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt @@ -26,6 +26,21 @@ class FullBleedTurnsTest { private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList()) + @Test + fun historicalRunningToolDoesNotClaimLiveActivity() { + val messages = + listOf( + msg("user", MessageRole.USER), + msg("old-tool", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING) + .copy(toolName = "terminal", isHistoricalCache = true), + ) + assertEquals(AgentStatus.Typing, deriveAgentStatus(true, StreamingState(), messages)) + assertEquals( + AgentStatus.Tool("terminal"), + deriveAgentStatus(true, StreamingState(), messages.map { it.copy(isHistoricalCache = false) }), + ) + } + @Test fun `empty list produces no turns`() { assertEquals(emptyList(), groupIntoTurns(emptyList())) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 6cd4de15..91bed590 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -568,6 +568,64 @@ class MarkdownTextFeatureTest { assertEquals("Run val x = 1 here", parsed.toString()) } + @Test + fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() { + val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + + assertEquals("bold code and italic", parsed.toString()) + assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold }) + assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace }) + assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic }) + val codeStart = parsed.indexOf("code") + assertTrue( + parsed.spanStyles.any { + it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold && + it.start <= codeStart && + it.end >= codeStart + 4 + }, + ) + assertTrue( + parsed.spanStyles.any { + it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace && + it.start <= codeStart && + it.end >= codeStart + 4 + }, + ) + } + + @Test + fun testLinkLabel_nestedEmphasisAndCode() { + val parsed = + parseInline( + "[**bold `code`**](https://example.com)", + Color.Black, + "", + false, + Color.Blue, + DEFAULT_HIGHLIGHTS, + ) + + assertEquals("bold code", parsed.toString()) + assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty()) + assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace }) + } + + @Test + fun testCodeSpan_doesNotParseInnerEmphasis() { + val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + + assertEquals("**literal**", parsed.toString()) + assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold }) + } + + @Test + fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() { + val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + + assertEquals("a ` tick and `unfinished", parsed.toString()) + assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace }) + } + @Test fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt new file mode 100644 index 00000000..fa646218 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReplyFailureDetailsTest.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReplyFailureDetailsTest { + @Test(timeout = 2_000) + fun longNonMatchingDiagnosticDoesNotBlockUi() { + assertEquals("x".repeat(8_192), sanitizeReplyErrorDetails("x".repeat(100_000))) + } + + @Test + fun redactsCredentialsAndUrlsBeforeDisplayOrExport() { + val raw = + """ + Provider failed with HTTP 429 + Authorization: Bearer private-bearer + Cookie: sid=private-cookie; csrf=private-csrf + {"api_key":"private-key", "password": "private password", "access_token": "private-access"} + https://user:private-pass@example.test/path?token=private-query + OPENAI_API_KEY=private-env + -----BEGIN PRIVATE KEY----- + private-key-material + -----END PRIVATE KEY----- + """.trimIndent() + val details = sanitizeReplyErrorDetails(raw) + assertTrue(details.contains("HTTP 429")) + assertTrue(details.contains("[REDACTED]")) + assertFalse(details.contains("private-")) + assertFalse(details.contains("private password")) + } + + @Test + fun limitsDetailsAndHandlesMissingMessage() { + assertTrue(sanitizeReplyErrorDetails("x".repeat(100_000)).length <= 8_192) + assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(null)) + assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(" ")) + } + + @Test + fun structuredSurfaceExportsOnlyAllowedMetadataAndSanitizedError() { + val failure = + replyFailureFromPayload( + mapOf( + "status" to "error", + "error" to "HTTP 429", + "error_surface" to + mapOf( + "provider" to "example", + "code" to "rate_limit", + "unknown_field" to "private-information", + "message" to "private-conversation", + ), + ), + "fallback", + ) + assertEquals("code: rate_limit\nprovider: example\nHTTP 429", failure?.details) + } +} 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 3b0c064b..1447a65e 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 @@ -2,7 +2,10 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.HermesDatabase +import com.m57.hermescontrol.data.model.PaginationInfo +import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.ConnectionStatus @@ -11,6 +14,7 @@ import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -74,6 +78,14 @@ class SlashCommandDispatchRpcTest { // COMMAND_DISPATCH captures in suite order). setMain alone is safe — // the slash-dispatch send path is synchronous (no IO hop). mockkObject(AuthManager) + // ChatModelSwitchDelegate.preloadModelOptions() reads pinned models + // when the catalog load succeeds; without this stub the spy falls + // through to the real AuthManager and throws "not initialized". + every { AuthManager.getPinnedModels() } returns emptyList() + // The delegate and the shared catalog store both collect this flow; + // park them on a never-emitting state so a later real-AuthManager + // emission cannot resume a stale Main-dispatched collector. + every { AuthManager.dataScopeFlow } returns MutableStateFlow(null) mockkObject(HermesWsClient) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -94,6 +106,10 @@ class SlashCommandDispatchRpcTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getPinnedModels() } returns emptyList() + every { AuthManager.getBaseUrl() } returns "http://test.local/" + every { AuthManager.getSelectedProfileId() } returns null + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.isMessageStatsEnabled() } returns false @@ -152,7 +168,15 @@ class SlashCommandDispatchRpcTest { } private suspend fun TestScope.createViewModelWithSession(): Pair { - val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) + val vm = + ChatViewModel( + app, + false, + fakeRepo, + FakeSlashUsageStore(), + ioDispatcher = testDispatcher, + sendStore = ChatSendStore(), + ) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) @@ -444,7 +468,11 @@ class SlashCommandDispatchRpcTest { every { HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { - val m = methodCalls.last() + // Read the method from THIS invocation, not from the shared + // capture list: a concurrent request() landing between capture + // and answer can leave .last() pointing at another call (CI: + // "Value not yet captured" for the submitted prompt). + val m = arg(0) val d = CompletableDeferred() if (m == WsMethods.COMMAND_DISPATCH) { // Backend rejects /status with the registry-miss 4018 (issue #576). @@ -714,7 +742,9 @@ class SlashCommandDispatchRpcTest { HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { val d = CompletableDeferred() - if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) { + // Read the method from THIS invocation, not from the shared + // capture list (see the 4018 test above). + if (arg(0) == WsMethods.COMMAND_DISPATCH) { // Backend /init returns type:"send" with the AGENTS.md prompt. d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md")) } else { @@ -730,6 +760,15 @@ class SlashCommandDispatchRpcTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } + coEvery { + ApiClient.hermesApi.getSessionMessages("session-xyz", any(), any(), any(), any()) + } returns + retrofit2.Response.success( + SessionMessagesResponse( + messages = emptyList(), + pagination = PaginationInfo(order = "latest"), + ), + ) vm.sendMessage("/init") advanceUntilIdle() 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 c886e921..a7a3a8e3 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 @@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao { messages.values.removeAll { it.sessionId == sessionId } } + override suspend fun deleteUnconfirmedMessage(id: String) { + if (messages[id]?.restId == null) messages.remove(id) + } + /** Direct access for test setup — bypasses the suspend modifier. */ fun addMessageDirect(message: ChatMessageEntity) { val existing = messages[message.id] 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 9df61ed2..2d678c93 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 @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings import com.m57.hermescontrol.data.config.ConnectionProfile import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.theme.ThemePreference @@ -47,6 +48,17 @@ class SettingsViewModelTest { return vm } + @Test + fun busySendDefaultLoadsAndSaves() { + every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE + val viewModel = createViewModel() + assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode) + + viewModel.onBusySendModeChange(BusySendMode.QUEUE) + assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode) + verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) } + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -71,6 +83,8 @@ class SettingsViewModelTest { every { AuthManager.isUseDynamicColors() } returns true every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT every { AuthManager.isTypingEffectEnabled() } returns true + every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT + every { AuthManager.setBusySendMode(any()) } returns Unit every { AuthManager.getTypingEffectDelayMs() } returns 30 every { AuthManager.getChatFontScale() } returns 1.0f every { AuthManager.isMessageStatsEnabled() } returns false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6841a43..190d0cc2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ coroutines = "1.11.0" junit = "4.13.2" junit5 = "6.1.3" junitPlatform = "6.1.3" -kotlin = "2.4.10" +kotlin = "2.4.20" # standalone per KSP 2.0+ guidance — NOT kotlinVersion-kspVersion; easy # to miss during Kotlin upgrades since it no longer tracks the Kotlin version. ksp = "2.3.12" @@ -30,7 +30,7 @@ coil = "3.6.3" room = "3.0.2" sqlcipher = "4.19.0" datastore = "1.1.2" -latex = "1.5.5" +latex = "1.5.6" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }