From 728daa9acdc812bd7d675e4cb2475c0d93060f30 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:09 +0300 Subject: [PATCH] release: promote dev to main for v2.0.0 (#1426) 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> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com> --- .github/FUNDING.yml | 4 + .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/android.yml | 37 +- .github/workflows/release.yml | 9 +- AGENTS.md | 37 +- CONTRIBUTING.md | 12 + DESIGN.md | 11 +- README.md | 219 +- app/build.gradle.kts | 19 +- .../data/ws/WsTransportDeviceTest.kt | 296 + .../ui/chat/ChatReadNotificationTest.kt | 10 +- .../hermescontrol/ui/chat/ChatScreenTest.kt | 9 + .../ui/chat/ChatSearchRenderingTest.kt | 49 + .../ui/chat/MarkdownHeadingRenderingTest.kt | 48 + .../ui/chat/MarkdownTableRenderingTest.kt | 30 + .../ui/chat/MediaViewerDeviceTest.kt | 53 + .../ui/chat/components/ClarifyBubbleTest.kt | 162 + .../chat/components/ComposerReadinessTest.kt | 28 +- .../ui/chat/components/CopyFeedbackTest.kt | 165 + .../chat/components/VoiceNoteGestureTest.kt | 4 + .../chat/fullbleed/FullBleedChatAnchorTest.kt | 63 +- .../chat/fullbleed/FullBleedChatListTest.kt | 118 +- .../chat/fullbleed/TranscriptTestActions.kt | 21 + .../ui/common/StatusBadgeColorTest.kt | 81 + .../ui/common/ThemeComponentGalleryTest.kt | 238 + app/src/debug/res/values-ar/strings.xml | 4 + app/src/debug/res/values-ko/strings.xml | 4 + app/src/debug/res/values-zh/strings.xml | 4 + app/src/debug/res/values/strings.xml | 4 + app/src/main/AndroidManifest.xml | 4 + .../java/com/m57/hermescontrol/Navigation.kt | 20 +- .../m57/hermescontrol/NavigationController.kt | 42 +- .../data/config/ServerStoreState.kt | 3 + .../hermescontrol/data/local/AuthManager.kt | 6 + .../data/local/ChatMessageEntity.kt | 4 +- .../hermescontrol/data/model/Attachment.kt | 4 + .../data/model/CredentialPoolAddRequest.kt | 11 + .../data/model/DebugShareResponse.kt | 5 + .../hermescontrol/data/model/HookResponse.kt | 16 + .../m57/hermescontrol/data/model/McpServer.kt | 28 +- .../data/model/MessageReaction.kt | 33 + .../data/model/ModelAssignment.kt | 18 +- .../hermescontrol/data/model/OAuthProvider.kt | 43 +- .../data/model/SessionListResponse.kt | 15 +- .../data/model/SessionLiveStatus.kt | 1 + .../data/model/SessionMessagesResponse.kt | 4 + .../data/remote/HermesApiService.kt | 29 +- .../data/remote/ProfileScopeInterceptor.kt | 4 + .../data/update/AppUpdateChecker.kt | 6 +- .../data/ws/AccountConnectorRepository.kt | 168 +- .../data/ws/BillingRepository.kt | 44 +- .../hermescontrol/data/ws/ConnectionHealth.kt | 140 + .../data/ws/ConnectorRepository.kt | 34 +- .../m57/hermescontrol/data/ws/EventParser.kt | 15 + .../hermescontrol/data/ws/HermesWsClient.kt | 1649 +- .../data/ws/ModelOptionsRepository.kt | 15 +- .../data/ws/PluginManageRepository.kt | 45 +- .../data/ws/PluginRemovalRepository.kt | 19 +- .../hermescontrol/data/ws/ProjectsSource.kt | 10 +- .../hermescontrol/data/ws/ReconnectPolicy.kt | 39 + .../hermescontrol/data/ws/ReplayTracker.kt | 245 + .../m57/hermescontrol/data/ws/RpcChannel.kt | 353 + .../data/ws/SessionLiveStatusSource.kt | 8 +- .../data/ws/SubagentRepository.kt | 37 +- .../hermescontrol/data/ws/VaultRepository.kt | 31 +- .../com/m57/hermescontrol/data/ws/WsEvent.kt | 12 + .../m57/hermescontrol/data/ws/WsMethods.kt | 3 + .../m57/hermescontrol/data/ws/WsTransport.kt | 912 + .../data/ws/contract/Approvals.kt | 28 + .../data/ws/contract/BillingVault.kt | 40 + .../data/ws/contract/ChatCommands.kt | 40 + .../data/ws/contract/ConfigModel.kt | 41 + .../data/ws/contract/ConnectionOperation.kt | 26 + .../data/ws/contract/Connectors.kt | 74 + .../data/ws/contract/PluginsManageParams.kt | 25 + .../data/ws/contract/ProcessKillParams.kt | 10 + .../data/ws/contract/ProcessStop.kt | 15 + .../ws/contract/ProfilesConfigureParams.kt | 16 + .../data/ws/contract/ProfilesListParams.kt | 7 + .../data/ws/contract/ProjectsListParams.kt | 7 + .../data/ws/contract/RpcMethod.kt | 539 + .../data/ws/contract/SessionControl.kt | 26 + .../data/ws/contract/SessionEventsSince.kt | 20 + .../data/ws/contract/SessionMisc.kt | 41 + .../data/ws/contract/SessionPrompt.kt | 43 + .../data/ws/contract/SubagentTailParams.kt | 10 + .../BackgroundConnectionController.kt | 12 + .../BackgroundConnectionPolicy.kt | 15 +- .../notification/ChatNotificationService.kt | 303 +- .../notification/NotificationReplyReceiver.kt | 48 +- .../notification/StopKeepConnectedReceiver.kt | 26 + .../java/com/m57/hermescontrol/theme/Color.kt | 3 + .../hermescontrol/theme/HermesStatusColors.kt | 9 +- .../hermescontrol/theme/PaletteTemplate.kt | 45 + .../com/m57/hermescontrol/theme/THEMES.md | 78 +- .../java/com/m57/hermescontrol/theme/Theme.kt | 25 +- .../m57/hermescontrol/theme/ThemeRegistry.kt | 28 + .../theme/presets/AmoledScheme.kt | 14 + .../theme/presets/CatppuccinScheme.kt | 76 +- .../theme/presets/DefaultScheme.kt | 30 +- .../theme/presets/GarnetScheme.kt | 185 + .../theme/presets/GruvboxScheme.kt | 79 +- .../theme/presets/MonochromeScheme.kt | 35 +- .../hermescontrol/theme/presets/NordScheme.kt | 76 +- .../hermescontrol/ui/bots/BotsViewModel.kt | 83 +- .../ui/bots/group/GroupChatViewModel.kt | 130 +- .../ui/chat/BrowserReturnTracker.kt | 44 + .../ui/chat/ChatApprovalsDelegate.kt | 42 +- .../m57/hermescontrol/ui/chat/ChatBubble.kt | 696 - .../ui/chat/ChatClarifyDelegate.kt | 2 +- .../chat/ChatConnectionOperationDelegate.kt | 88 +- .../ui/chat/ChatConnectorsViewModel.kt | 11 + .../hermescontrol/ui/chat/ChatListEntries.kt | 2 +- .../ui/chat/ChatMediaDelegate.kt | 13 +- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 5 +- .../ui/chat/ChatMessageIdentity.kt | 40 + .../ui/chat/ChatModelSwitchDelegate.kt | 62 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 159 +- .../ui/chat/ChatSearchController.kt | 97 +- .../ui/chat/ChatSearchDelegate.kt | 2 + .../ui/chat/ChatSearchHighlight.kt | 39 + .../hermescontrol/ui/chat/ChatSearchState.kt | 4 + .../ui/chat/ChatServerMessageMapper.kt | 41 +- .../ui/chat/ChatStreamingController.kt | 197 +- .../ui/chat/ChatSubagentsDelegate.kt | 2 - .../ui/chat/ChatTranscriptMerge.kt | 31 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 408 +- .../ui/chat/ChatWsEventReducer.kt | 23 +- .../ui/chat/ExpandedToolContent.kt | 237 + .../ui/chat/HostMediaExtractor.kt | 10 +- .../ui/chat/ImageBytesResolver.kt | 128 - .../ui/chat/ImageViewerDialog.kt | 238 - .../hermescontrol/ui/chat/InlineAttachment.kt | 189 + .../m57/hermescontrol/ui/chat/MarkdownText.kt | 271 +- .../hermescontrol/ui/chat/MediaImageStore.kt | 65 +- .../m57/hermescontrol/ui/chat/MediaTypes.kt | 84 +- .../hermescontrol/ui/chat/MessageSegment.kt | 84 + .../hermescontrol/ui/chat/MessageTimestamp.kt | 16 + .../ui/chat/SlashCommandDispatcher.kt | 9 +- .../hermescontrol/ui/chat/StreamingBuffer.kt | 58 + .../hermescontrol/ui/chat/StreamingState.kt | 2 + .../m57/hermescontrol/ui/chat/SystemBubble.kt | 272 + .../m57/hermescontrol/ui/chat/ToolBubble.kt | 415 +- .../hermescontrol/ui/chat/ToolBubbleHeader.kt | 136 + .../hermescontrol/ui/chat/ToolRawJsonView.kt | 111 + .../ui/chat/ToolSchemaRegistry.kt | 332 +- .../m57/hermescontrol/ui/chat/UserBubble.kt | 252 + .../ui/chat/components/ChatComposer.kt | 233 +- .../chat/components/ChatLifecycleEffects.kt | 25 +- .../ui/chat/components/ChatMediaLaunchers.kt | 24 + .../chat/components/ChatScrollController.kt | 79 +- .../ui/chat/components/ClarifyBubble.kt | 301 + .../ui/chat/components/CodeBlockCard.kt | 98 + .../chat/components/CodeSyntaxHighlighter.kt | 103 + .../ui/chat/components/ComposerPalette.kt | 29 +- .../ui/chat/components/ComposerToolbar.kt | 29 +- .../ui/chat/components/CopyFeedback.kt | 29 + .../ui/chat/components/ImageViewerContent.kt | 155 + .../ui/chat/components/InlineMediaPlayer.kt | 15 + .../ui/chat/components/InlineVideoPlayer.kt | 35 - .../ui/chat/components/MediaExportActions.kt | 217 + .../ui/chat/components/MediaViewerDialog.kt | 198 +- .../ui/chat/components/MessageCards.kt | 789 - .../chat/components/MessageReactionChips.kt | 44 + .../ui/chat/components/ReasoningCard.kt | 235 + .../ui/chat/components/TypingIndicator.kt | 94 + .../ui/chat/components/VideoViewerDialog.kt | 17 - .../components/VoiceNoteRecordingPanel.kt | 73 +- .../chat/fullbleed/ChatSearchScrollEffect.kt | 42 + .../chat/fullbleed/FullBleedAgentMessage.kt | 88 +- .../ui/chat/fullbleed/FullBleedChatList.kt | 284 +- .../ui/chat/fullbleed/FullBleedStreaming.kt | 20 +- .../ui/chat/fullbleed/FullBleedToolRow.kt | 4 +- .../ui/chat/fullbleed/FullBleedTurns.kt | 35 +- .../ui/chat/fullbleed/TimelineMarkerChip.kt | 17 +- .../ui/chat/fullbleed/TranscriptUiState.kt | 99 + .../ui/chat/markdown/MarkdownBulletRun.kt | 121 + .../ui/chat/markdown/MarkdownInlineStyler.kt | 47 +- .../ui/chat/markdown/MarkdownInlineText.kt | 79 +- .../ui/chat/markdown/MarkdownModels.kt | 3 + .../ui/chat/markdown/MarkdownParser.kt | 52 +- .../ui/chat/markdown/MarkdownTableView.kt | 32 +- .../hermescontrol/ui/chat/tool/ToolCatalog.kt | 144 + .../hermescontrol/ui/chat/tool/ToolCounts.kt | 37 +- .../ui/chat/tool/ToolRendererRegistry.kt | 107 +- .../ui/chat/tool/ToolResultSummary.kt | 17 +- .../ui/chat/tool/ToolViewCache.kt | 61 + .../ui/common/SharedComponents.kt | 39 +- .../m57/hermescontrol/ui/common/StateViews.kt | 23 + .../connectors/AccountConnectorsViewModel.kt | 9 +- .../hermescontrol/ui/mcp/McpServersScreen.kt | 291 +- .../ui/mcp/McpServersViewModel.kt | 27 +- .../ui/mcp/components/AddServerSection.kt | 40 - .../ui/mcp/components/CatalogSection.kt | 166 +- .../ui/mcp/components/McpSectionHeader.kt | 59 - .../ui/mcp/components/ServerCard.kt | 2 +- .../ui/process/ProcessesViewModel.kt | 23 +- .../ui/sessions/SessionsScreen.kt | 112 +- .../ui/sessions/SessionsViewModel.kt | 48 +- .../ui/sessions/components/SessionCard.kt | 16 +- .../components/SessionLiveStatusIndicator.kt | 22 +- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 8 + .../ui/settings/components/AboutSection.kt | 6 - .../settings/components/AppearanceSection.kt | 97 +- .../ui/settings/components/BehaviorSection.kt | 77 +- .../ui/system/SystemViewModel.kt | 32 +- app/src/main/res/values-ar/strings.xml | 8 +- app/src/main/res/values-ko/strings.xml | 8 +- app/src/main/res/values-zh/strings.xml | 8 +- app/src/main/res/values/strings.xml | 18 +- .../hermescontrol/NavigationControllerTest.kt | 88 + .../data/model/McpServerParsingTest.kt | 37 + .../data/model/ModelSerializationTest.kt | 29 + .../SessionDestructiveOpsSerializationTest.kt | 50 + .../remote/HermesApiServiceConverterTest.kt | 114 + .../HermesApiServiceMockWebServerTest.kt | 21 + .../update/AppUpdateCheckerNetworkTest.kt | 18 + .../data/ws/AccountConnectorRepositoryTest.kt | 53 +- .../data/ws/ConnectionHealthTest.kt | 518 + .../data/ws/ConnectorRepositoryTest.kt | 171 +- .../hermescontrol/data/ws/EventParserTest.kt | 47 + .../data/ws/FakeTypedRpcCaller.kt | 22 + .../hermescontrol/data/ws/GatewayContract.kt | 170 + .../data/ws/GatewayContractFramesTest.kt | 146 + .../data/ws/GatewayContractTest.kt | 113 + .../data/ws/HermesWsClientApprovalsTest.kt | 237 + .../data/ws/HermesWsClientBillingVaultTest.kt | 431 + .../ws/HermesWsClientCharacterizationTest.kt | 532 + .../HermesWsClientConnectionOperationTest.kt | 562 + .../data/ws/HermesWsClientConnectorsTest.kt | 358 + .../ws/HermesWsClientLoggingSecurityTest.kt | 79 +- .../ws/HermesWsClientSessionControlTest.kt | 281 + .../data/ws/HermesWsClientSessionMiscTest.kt | 584 + .../data/ws/HermesWsClientTest.kt | 465 +- .../data/ws/HermesWsClientTypedCallTest.kt | 532 + .../data/ws/HermesWsClientTypedSendTest.kt | 484 + .../data/ws/ModelOptionsRepositoryTest.kt | 3 +- .../data/ws/PluginManageRepositoryTest.kt | 23 +- .../data/ws/PluginRemovalRepositoryTest.kt | 9 +- .../data/ws/ProjectsSourceTest.kt | 28 +- .../data/ws/RawRpcParamsRatchetTest.kt | 318 + .../data/ws/ReconnectPolicyTest.kt | 98 + .../data/ws/ReplayTrackerTest.kt | 555 + .../hermescontrol/data/ws/RpcChannelTest.kt | 790 + .../data/ws/SessionLiveStatusSourceTest.kt | 14 +- .../data/ws/SubagentRepositoryTest.kt | 54 + .../hermescontrol/data/ws/WsTransportTest.kt | 489 + .../data/ws/contract/ApprovalsTypesTest.kt | 209 + .../data/ws/contract/BillingVaultTypesTest.kt | 333 + .../data/ws/contract/ChatCommandsTypesTest.kt | 103 + .../data/ws/contract/ConfigModelTypesTest.kt | 102 + .../contract/ConnectionOperationTypesTest.kt | 257 + .../data/ws/contract/ConnectorsTypesTest.kt | 539 + .../ws/contract/ParamsDescriptorCheckTest.kt | 107 + .../ws/contract/PluginsProjectsTypesTest.kt | 60 + .../data/ws/contract/ProcessTypesTest.kt | 44 + .../data/ws/contract/ProfilesTypesTest.kt | 54 + .../ws/contract/SessionControlTypesTest.kt | 119 + .../contract/SessionEventsSinceTypesTest.kt | 126 + .../data/ws/contract/SessionMiscTypesTest.kt | 295 + .../ws/contract/SessionPromptTypesTest.kt | 290 + .../data/ws/contract/SubagentTypesTest.kt | 44 + .../BackgroundConnectionControllerTest.kt | 55 + .../BackgroundConnectionPolicyTest.kt | 42 + .../ChatNotificationServiceTest.kt | 114 + .../NotificationReplyReceiverTest.kt | 30 +- .../theme/ThemeComponentContrastTest.kt | 64 + .../hermescontrol/theme/ThemePaletteTest.kt | 279 +- .../ui/chat/BrowserReturnTrackerTest.kt | 62 + .../ui/chat/ChatApprovalsDelegateTest.kt | 51 +- .../ui/chat/ChatCompressRpcTest.kt | 12 +- .../ChatConnectionOperationDelegateTest.kt | 208 +- .../ui/chat/ChatMessageIdentityTest.kt | 34 + .../ui/chat/ChatModelSwitchDelegateTest.kt | 85 +- .../ui/chat/ChatPagingMergeTest.kt | 149 + .../ui/chat/ChatPrefixDedupeTest.kt | 162 + .../ui/chat/ChatSearchControllerTest.kt | 26 +- .../ui/chat/ChatSearchDelegateTest.kt | 33 + .../chat/ChatServerMessageMapperPurityTest.kt | 97 + .../ui/chat/ChatStreamingControllerTest.kt | 30 +- .../ui/chat/ChatSubagentsDelegateTest.kt | 12 +- .../ui/chat/ChatViewModelTest.kt | 243 +- .../ui/chat/ChatWsEventReducerTest.kt | 94 + .../ui/chat/ComposerPaletteTest.kt | 15 + .../ui/chat/FullBleedItemKeysTest.kt | 32 + .../ui/chat/ImageBytesResolverTest.kt | 91 - .../ui/chat/MarkdownBulletRunBenchmark.kt | 76 + .../ui/chat/MarkdownBulletRunTest.kt | 240 + .../ui/chat/MarkdownTextBenchmark.kt | 16 + .../ui/chat/MarkdownTextFeatureTest.kt | 22 + .../ui/chat/MediaSegmentsTest.kt | 62 + .../hermescontrol/ui/chat/MediaTypesTest.kt | 24 + .../ReadNotificationReviewRegressionTest.kt | 85 +- .../ui/chat/SlashCommandDispatchRpcTest.kt | 143 +- .../ui/chat/SlashCommandDispatcherTest.kt | 4 +- .../ui/chat/StreamingBufferTest.kt | 56 + .../components/ChatScrollControllerTest.kt | 248 + .../chat/fullbleed/TranscriptUiStateTest.kt | 88 + .../ui/chat/tool/ToolCatalogTest.kt | 53 + .../ui/chat/tool/ToolViewCacheTest.kt | 51 + .../AccountConnectorsViewModelTest.kt | 17 +- .../ui/kanban/KanbanMutationTest.kt | 13 +- .../hermescontrol/ui/mcp/McpOwnershipTest.kt | 5 +- .../ui/process/ProcessesViewModelTest.kt | 82 +- .../ui/sessions/SessionSourceFilterTest.kt | 75 + .../ui/settings/SettingsViewModelTest.kt | 2 + .../test/resources/gateway-contract/SOURCE | 5 + .../resources/gateway-contract/openrpc.json | 40113 ++++++++++++++++ flake.nix | 8 +- gradle.properties | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew.bat | 52 +- scripts/sync-gateway-contract.sh | 19 + 315 files changed, 65173 insertions(+), 7909 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt create mode 100644 app/src/debug/res/values-ar/strings.xml create mode 100644 app/src/debug/res/values-ko/strings.xml create mode 100644 app/src/debug/res/values-zh/strings.xml create mode 100644 app/src/debug/res/values/strings.xml create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt delete mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt create mode 100644 app/src/test/resources/gateway-contract/SOURCE create mode 100644 app/src/test/resources/gateway-contract/openrpc.json create mode 100755 scripts/sync-gateway-contract.sh diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..18541822 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: hy4ri +ko_fi: m_5_7 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b2b73c01..93eb8de9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -27,6 +27,7 @@ Fixes # ## Checklist +- [ ] PR is focused on one feature or one fix (no unrelated changes) - [ ] Branch rebased onto `dev` (`git rebase origin/dev`) - [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first) - [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0697eb85..4783362d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -97,11 +97,8 @@ jobs: - name: Add local bin to PATH run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - name: Run ktlintCheck - run: ./gradlew ktlintCheck - - - name: Run checkColorLiterals (hardcoded Color guard, issue #622) - run: ./gradlew checkColorLiterals + - name: Run ktlintCheck + checkColorLiterals (hardcoded Color guard, issue #622) + run: ./gradlew ktlintCheck checkColorLiterals --continue - name: Upload ktlint reports if: always() @@ -185,6 +182,25 @@ jobs: - name: Run test run: ./gradlew test + # Telemetry only, never a gate: surfaces the slowest classes so perf work + # targets real offenders. + - name: Slowest unit-test classes + if: always() + run: | + python3 - <<'EOF' >> "$GITHUB_STEP_SUMMARY" + import glob, xml.etree.ElementTree as ET + rows = [] + for path in glob.glob("app/build/test-results/testDebugUnitTest/*.xml"): + root = ET.parse(path).getroot() + rows.append((float(root.get("time", 0)), int(root.get("tests", 0)), root.get("name", path))) + rows.sort(reverse=True) + print("### Slowest unit-test classes\n") + print(f"{len(rows)} classes, {sum(r[0] for r in rows):.0f}s summed across forks\n") + print("| Time | Tests | Class |\n|---:|---:|---|") + for secs, count, name in rows[:20]: + print(f"| {secs:.1f}s | {count} | `{name.rsplit('.', 1)[-1]}` |") + EOF + - name: Upload test reports if: always() uses: actions/upload-artifact@v7 @@ -194,11 +210,11 @@ jobs: retention-days: 7 # ───────────────────────────────────────────────────────────────────── - # Job 4: Build Debug APK — depends on all three above passing. + # Job 4: Build Debug APK — runs in parallel; CI Summary gates the result. # ───────────────────────────────────────────────────────────────────── build: name: Build Debug APK - needs: [changes, ktlint, android-lint, unit-tests] + needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -241,7 +257,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── release-compile: name: Release Compile Check - needs: [changes, ktlint, android-lint, unit-tests] + needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -339,7 +355,7 @@ jobs: # ───────────────────────────────────────────────────────────────────── ci-summary: name: CI Summary - needs: [changes, ktlint, android-lint, unit-tests, build, release-compile] + needs: [changes, ktlint, android-lint, unit-tests, build, release-compile, instrumented-tests] runs-on: ubuntu-latest timeout-minutes: 2 if: always() @@ -355,11 +371,12 @@ jobs: TEST_RESULT: ${{ needs.unit-tests.result }} BUILD_RESULT: ${{ needs.build.result }} RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }} + INSTRUMENTED_RESULT: ${{ needs.instrumented-tests.result }} run: | LABEL="ci-red" FAILED=0 FAILED_JOBS="" - for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT"; do + for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT" "instrumented-tests:$INSTRUMENTED_RESULT"; do name="${entry%%:*}" result="${entry#*:}" echo "::notice::$name = $result" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48ce2ab6..956384c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,10 +54,13 @@ jobs: # Strip leading 'v' for versionName VERSION="${TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # versionCode: strip pre-release suffixes (e.g. 1.25.0-rc1 -> 1.25.0) and dots + # versionCode: SemVer MAJOR * 10000 + MINOR * 100 + PATCH (e.g. 2.0.0 -> 20000, > 1.31.0's 1310) BASE_VERSION="${VERSION%%-*}" - CODE=$(echo "$BASE_VERSION" | tr -d '.' | sed 's/^0*//') - [ -z "$CODE" ] && CODE=0 + IFS='.' read -r MAJOR MINOR PATCH _ <<< "$BASE_VERSION" + MAJOR="${MAJOR:-0}" + MINOR="${MINOR:-0}" + PATCH="${PATCH:-0}" + CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) echo "code=$CODE" >> "$GITHUB_OUTPUT" if [[ "$VERSION" == *"-"* ]]; then PRERELEASE="true" diff --git a/AGENTS.md b/AGENTS.md index d87443ad..99c335c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,21 @@ explicitly disconnected on logout and reconnected after login: The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to call unconditionally. +### Typed Gateway RPC + +Every gateway method in the contract (`app/src/test/resources/gateway-contract/openrpc.json`) is called through a +typed descriptor: `HermesWsClient.call(RpcMethods.X, params)` (suspending), `requestTyped(...)`, or the typed +`send(RpcMethods.X, params, onSent)`. Params are `@Serializable` classes under `data/ws/contract/`; results are +`JsonElement` passthrough or typed. To add a method: add the params class, register it in `RpcMethods` (and +`RpcMethods.all`), and `GatewayContractTest` checks it against the contract. + +- The untyped `request(String, Map)` / `send(String, Map)` are `internal`. Use them only for `gateway.ping` and the + legacy `*.respond` fallbacks (`clarify`, `sudo`, `secret`, `vault.*`) that have no contract entry. +- Optional params are `T? = null`: the client Json has `encodeDefaults = false`, so unset fields are omitted and an + explicit `false`/`""` is still sent. Do not add a `profile` field unless callers pass an explicit override; + `WsProfileParams.decorate` injects it for profile-scoped methods. +- `RawRpcParamsRatchetTest.BASELINE` must stay empty. + ### Shared Components - **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot, @@ -197,8 +212,8 @@ for the current inset implementation. ### Theme -`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default, -Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light +`Theme.kt` uses a preset-based theme system with 7 built-in presets: Default, +Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, and Garnet. Each preset provides light and dark color schemes plus matching semantic status colors (AMOLED is dark-only — light mode falls back to the default theme). Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw @@ -259,7 +274,7 @@ gh pr create --base dev --title "fix(#N): description" --body "Closes #N" (`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local `val` at the composable scope first. - Don't add new screens without checking if an existing one already covers the - functionality (28+ screens exist). Extend rather than duplicate. + functionality (30+ screens exist). Extend rather than duplicate. - **⚠ Never scope a dependency to `debugImplementation` if its import is used in `main/` source code.** The CI `release-compile` job catches this, but save the cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's @@ -286,22 +301,25 @@ com.m57.hermescontrol/ │ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore │ ├── model/ 40+ data classes for API responses + requests │ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management +│ ├── repository/ KanbanRepository, KanbanSyncCoordinator, VoiceNoteRepository │ ├── session/ ActiveSessionHolder +│ ├── update/ AppUpdateChecker, AppUpdateCache, AppUpdateState │ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository ├── notification/ ChatNotificationService, NotificationReplyReceiver ├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors -│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord +│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet ├── ui/ │ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows -│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels, -│ chat, config, connect, cron, gateway, kanban, keys, landing, logs, mcp, -│ model, pairing, plugins, process, profiles, providers, sessions, settings, -│ skills, system, toolsets, webhooks) +│ └── 31 feature packages (achievements, analytics, authlogin, billing, bots, +│ channels, chat, config, connect, connectors, cron, files, gateway, kanban, +│ keys, landing, logs, mcp, memory, model, pairing, plugins, process, +│ profiles, providers, sessions, settings, skills, system, toolsets, +│ webhooks) ├── util/ CronExpressionFormatter, LocaleContextWrapper ├── HermesControlApp.kt Application class ├── Navigation.kt Drawer + NavDisplay + entry wiring ├── NavigationController.kt Central navigation guard (dedup) -├── NavigationKeys.kt @Serializable NavKey data objects (28 screens + 6 settings sub-pages) +├── NavigationKeys.kt @Serializable NavKey data objects (30 screens + 4 detail keys + 7 settings sub-pages) ├── ScreenRegistry.kt entry registrations for all NavKeys └── MainActivity.kt ``` @@ -311,6 +329,7 @@ com.m57.hermescontrol/ - [README.md](README.md) — human-facing overview, features, screenshots, tech stack - [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map - [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation +- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh` - [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style - [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth - [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0283d0e..0748ef83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ If an open PR already covers it, review or improve that one instead of opening a Every change must go through a pull request (PR) targeting the `dev` staging branch. Do not push directly to `main` or `dev`. +**Keep PRs focused: one feature or one fix per PR.** Unrelated changes belong in separate PRs so each one stays easy to review, test, and revert. + 1. **Pick or open an issue** to discuss the changes you want to make. 2. **Create a branch** off `dev` using the following naming convention: - Features: `feat/short-description` (optionally `feat/issue-N-description`) @@ -110,6 +112,15 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices. - Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component. - For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed. + +### Gateway contract snapshot + +`GatewayContractTest` checks `WsMethods`, handled server requests and literal param keys against a vendored copy of +hermes-agent's `gateway-contract.openrpc.json` (`app/src/test/resources/gateway-contract/`, provenance in `SOURCE`). +It is test-only. Refresh it with `scripts/sync-gateway-contract.sh [ref]`, then run +`./gradlew testDebugUnitTest --tests '*GatewayContract*'` and review the diff. Allowlist exceptions need a reason in +`GatewayContractAllowlist`. + --- ## Dependency & Build Hygiene @@ -124,6 +135,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices. Before submitting your PR, please verify: - [ ] I searched open/closed PRs + issues for duplicates (see *Before You Start*). +- [ ] PR is focused on one feature or one fix (no unrelated changes). - [ ] Branch is rebased onto current `dev` (`git rebase origin/dev`). - [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first — no hand edits). - [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/). diff --git a/DESIGN.md b/DESIGN.md index 86672bcc..376fb2ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use ` | Concern | Authoritative source | | --- | --- | -| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | +| Presets and mode selection | [ThemeRegistry.kt](app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt), [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | | Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) | | Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) | | Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) | @@ -41,7 +41,7 @@ The visual language follows Material 3 guidelines strictly adapted for developer - **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity. ## Colors -The app supports 6 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default. +The app supports 7 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`, `Garnet`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default. 1. **Surfaces**: - `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background. @@ -144,8 +144,11 @@ passed an accessibility audit. - **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions. [ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt) -currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode -invariants. Those tests are useful but do not establish contrast or accessibility +guards normal-text slot pairs, full-bleed prose/header contrast, complete Material +role mapping, registry coverage, fixed-role mode invariance, and theme-mode invariants. +[ThemeComponentContrastTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt) +and the instrumented component gallery also guard enabled Material control states. +Those tests are useful but do not establish contrast or accessibility compliance for every rendered component, alpha blend, or dynamic palette. Record device/emulator steps and observed results for changed UI; explicitly name any gate that could not be verified. diff --git a/README.md b/README.md index 76b4aefe..3d7d40cc 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,9 @@ ## Overview -**Hermes Mobile** is the native Android client for [Hermes Agent](https://hermes-agent.nousresearch.com). It connects to your Hermes gateway (REST API and WebSocket TUI Gateway), giving you pocket control over your AI assistant. Plain HTTP/WS connections are intended for trusted networks only; authentication does not encrypt the transport. +**Hermes Mobile** is a native Android app for controlling your [Hermes Agent](https://hermes-agent.nousresearch.com) from your phone. Chat with your agent, manage cron jobs, skills, models and more. ---- - -## Support the project - -If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7). +It connects to the Hermes dashboard (REST API and WebSocket gateway), so you need a running dashboard that your phone can reach. --- @@ -61,79 +57,41 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi ## Features -- **Real-Time Chat:** Message your agent with Room-backed local database history and inline reply notifications. -- **System Config:** Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections. +- **Chat:** Talk to your agent. History is stored locally in Room, and notifications support inline replies. +- **Configuration:** Manage profiles, skills, plugins, toolsets, and model/provider selections. - **Operations:** Stream and filter live logs, manage cron jobs, edit environment keys, test webhooks, and monitor processes. -- **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers. -- **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history. -- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management. -- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices. -- **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation. +- **Gateway status:** Check the WebSocket connection, MCP servers, messaging channels, and OAuth providers. +- **Productivity:** Manage tasks on Kanban boards, track agent milestones, and browse session history. +- **Analytics and billing:** See usage analytics and manage billing or subscriptions. +- **Theming:** Seven built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet), plus Material You dynamic colors on supported devices. +- **Material 3 design:** Pull-to-refresh, a scroll-aware top bar, and customizable bottom navigation. --- ## Quick Start -1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/) or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest). -2. Start your Hermes dashboard on a host reachable from your phone. -3. Follow [Authentication](#authentication) below to connect. Use a trusted network for plain HTTP/WS connections. - -## Build from source - -### Prerequisites - -- **JDK 21+** (required for Kotlin compilation and the Gradle toolchain) -- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), available through Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or configure `sdk.dir` in your local `local.properties`. - -### Build & Deploy - -1. **Clone the repository:** - ```bash - git clone https://github.com/Hy4ri/hermes-mobile.git - cd hermes-mobile - ``` -2. **Build the debug APK:** - ```bash - ./gradlew assembleDebug - ``` -3. **Install on your emulator/device:** - ```bash - adb install app/build/outputs/apk/debug/app-debug.apk - ``` - -_Note: For release builds, ensure keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) are configured, or let the GitHub Actions release workflow handle it on tag push (`v*`)._ - -### Nix emulator - -The Nix development shell enables physical keyboard input in an existing -`hermes_dev` AVD configuration. It respects `ANDROID_AVD_HOME` and -`ANDROID_USER_HOME`, defaulting to `~/.android/avd`. - -Close any running emulator, then cold boot once to apply the setting: - -```bash -nix develop --command emulator -avd hermes_dev -no-snapshot-load -``` - -Subsequent launches can omit `-no-snapshot-load`. Create the `hermes_dev` AVD -first if it does not exist; the shell does not create one. +1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/), or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest). +2. Start your Hermes dashboard on a host your phone can reach. +3. Open the app, tap **Sign in**, and follow [Authentication](#authentication). --- ## Authentication -Once the app is installed, you need to point it at your Hermes gateway. The app auto-detects which auth mode the dashboard is using — just fill in the fields it shows. +The app detects which auth mode your dashboard uses and shows only the fields you need. + +> **Security:** Use HTTPS for remote connections. Plain HTTP/WS is for trusted local networks only, because authentication does not encrypt the transport. ### 1. Start the dashboard -On your host machine, start the dashboard: +On the host machine: ```bash hermes dashboard # loopback (127.0.0.1:9119) — no auth needed hermes dashboard --host 0.0.0.0 # LAN — requires auth ``` -For LAN access, configure credentials in `~/.hermes/config.yaml`: +For LAN access, set credentials in `~/.hermes/config.yaml`: ```yaml dashboard: @@ -144,14 +102,17 @@ dashboard: ### 2. Connect the app -Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and reveals the fields you need: +Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and shows the fields it needs. -| Auth mode | When | What you fill | -| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page | -| **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically | +**Token only** (dashboard on the same machine, bound to loopback) -Use HTTPS for remote connections. Use HTTP only on a trusted local network. +- Enter the **Token**. You can find it in `~/.hermes/dashboard-token.txt` or in `~/.hermes/.env` as `HERMES_DASHBOARD_SESSION_TOKEN`. +- The app can also extract the token from the dashboard page for you. + +**Basic auth** (dashboard on the LAN behind a password) + +- Enter your **Username** and **Password** (default `admin` / `hermes`). +- The app logs in, stores a session cookie, and requests a WebSocket ticket automatically. ### Cloudflare Access and custom headers @@ -164,63 +125,99 @@ Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one must accept the service token. These headers authenticate with Cloudflare; you still need to complete Hermes authentication. -You can add other custom header names and values in the same editor. To edit or -remove saved headers, open **Custom headers** from login or the saved connection's -edit dialog in Settings. Values are masked and stored in encrypted preferences. -Saving an empty list removes the headers for that URL. +**Other headers.** You can add any custom header name and value in the same editor. +To edit or remove saved headers, open **Custom headers** from login, or from the saved +connection's edit dialog in Settings. Values are masked and stored in encrypted +preferences. Saving an empty list removes the headers for that URL. -Headers are shared by profiles with the same server URL. They apply to probes, -login, API and media requests, and WebSocket handshakes. Each URL's scheme, host, -port, and path prefix limit where its headers are sent. Redirects outside that -scope do not receive the credentials; WebSocket redirects are not followed. -The app manages `Authorization`, `Cookie`, and transport headers itself. +**Where headers are sent.** + +- Profiles with the same server URL share headers. +- They apply to probes, login, API and media requests, and WebSocket handshakes. +- Each URL's scheme, host, port, and path prefix limit where its headers go. +- Redirects outside that scope do not receive them, and WebSocket redirects are not followed. +- The app manages `Authorization`, `Cookie`, and transport headers itself. ### Connection profiles -Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token — just tap to swap. - ---- - -## Project Structure - -``` -app/src/main/java/com/m57/hermescontrol/ -├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models -├── notification/ # Foreground service + inline reply for chat notifications -├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography -├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews) -├── util/ # CronExpressionFormatter, LocaleContextWrapper -└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller -``` - ---- - -## Tech Stack - -- **Language:** Kotlin with KSP -- **UI & Layout:** Jetpack Compose & Material 3 / Material You -- **Navigation:** Navigation3 (Compose-first Routing) -- **Networking:** Retrofit, OkHttp, Kotlinx Serialization -- **Database:** Room with SQLCipher encryption -- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore -- **Theming:** Built-in presets + Material You dynamic colors; see [Themes](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) -- **Image Loading:** Coil -- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing -- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI) - -Dependency versions are maintained in [`gradle/libs.versions.toml`](gradle/libs.versions.toml); build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts). +Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token. --- ## Contributing -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist. +Contributions are welcome! Read [CONTRIBUTING.md](CONTRIBUTING.md) for the branch workflow, code style, and PR checklist. -### Translations +- **Translations:** Help translate the app on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/). +- **Conventions and architecture:** [AGENTS.md](AGENTS.md). +- **Visual and interaction requirements:** [DESIGN.md](DESIGN.md). +- **Theme implementation:** [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md). -Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)! +--- -For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS.md). [DESIGN.md](DESIGN.md) defines visual and interaction requirements; [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) explains theme implementation. +## Build from source + +### Prerequisites + +- **JDK 21+** (used for Kotlin compilation and the Gradle toolchain). +- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), from Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or `sdk.dir` in `local.properties`. + +### Build and install + +```bash +git clone https://github.com/Hy4ri/hermes-mobile.git +cd hermes-mobile +./gradlew assembleDebug +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +For release builds, set the keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`). Or let the GitHub Actions release workflow build on a `v*` tag push. + +### Nix emulator + +The Nix development shell turns on physical keyboard input in an existing `hermes_dev` AVD. It respects `ANDROID_AVD_HOME` and `ANDROID_USER_HOME`, defaulting to `~/.android/avd`. The shell does not create the AVD, so create `hermes_dev` first. + +Close any running emulator, then cold boot once to apply the setting: + +```bash +nix develop --command emulator -avd hermes_dev -no-snapshot-load +``` + +Later launches can omit `-no-snapshot-load`. + +--- + +## Project structure + +``` +app/src/main/java/com/m57/hermescontrol/ +├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models +├── notification/ # Foreground service + inline reply for chat notifications +├── theme/ # Preset-based design system (7 themes), status colors, spacing, typography +├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews) +├── util/ # CronExpressionFormatter, LocaleContextWrapper +└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller +``` + +## Tech stack + +- **Language:** Kotlin with KSP +- **UI:** Jetpack Compose, Material 3 / Material You +- **Navigation:** Navigation3 +- **Networking:** Retrofit, OkHttp, Kotlinx Serialization +- **Database:** Room with SQLCipher encryption +- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore +- **Images:** Coil +- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing +- **Formatting:** `ktlint` 1.8.0 (checked in CI) + +Dependency versions live in [`gradle/libs.versions.toml`](gradle/libs.versions.toml). Build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts). + +--- + +## Support the project + +If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7). --- @@ -228,4 +225,4 @@ For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS. Copyright © 2026 M57 (Hy4ri). -This project is licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details. +Licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df81b98c..a4e35c23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,18 +85,26 @@ android { buildTypes { debug { + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } + release { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) signingConfig = signingConfigs["release"] buildConfigField("boolean", "ALLOW_CLEARTEXT", "true") manifestPlaceholders["usesCleartextTraffic"] = "true" } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 @@ -309,6 +317,13 @@ tasks.named("check") { dependsOn("checkColorLiterals") } -tasks.withType { +val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent + +tasks.withType().configureEach { useJUnitPlatform() + maxParallelForks = 2 + // Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks + if (!includeBenchmarks) { + filter { excludeTestsMatching("*Benchmark") } + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt new file mode 100644 index 00000000..681267a0 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/data/ws/WsTransportDeviceTest.kt @@ -0,0 +1,296 @@ +package com.m57.hermescontrol.data.ws + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okio.ByteString +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 +import org.junit.runner.RunWith +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +@RunWith(AndroidJUnit4::class) +class WsTransportDeviceTest { + private class FakeWebSocket( + private val request: Request, + var accept: Boolean = true, + var queueSizeValue: Long = 0L, + ) : WebSocket { + val sentFrames = ConcurrentLinkedQueue() + val attempts = AtomicInteger(0) + val closedCode = AtomicInteger(-1) + val closedReason = + java.util.concurrent.atomic + .AtomicReference(null) + val canceled = AtomicBoolean(false) + + override fun request(): Request = request + + override fun queueSize(): Long = queueSizeValue + + override fun send(text: String): Boolean { + attempts.incrementAndGet() + if (!accept) return false + sentFrames.add(text) + return true + } + + override fun send(bytes: ByteString): Boolean { + attempts.incrementAndGet() + if (!accept) return false + sentFrames.add(bytes.utf8()) + return true + } + + override fun close( + code: Int, + reason: String?, + ): Boolean { + closedCode.set(code) + closedReason.set(reason) + return true + } + + override fun cancel() { + canceled.set(true) + } + } + + private class TestFixture { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val lock = Any() + val clock = AtomicLong(100L) + val dummyRequest: Request = Request.Builder().url("http://localhost/ws").build() + val openResponse: Response = + Response + .Builder() + .request(dummyRequest) + .protocol(Protocol.HTTP_1_1) + .code(101) + .message("Switching Protocols") + .build() + + val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + val recordedEvents = ConcurrentLinkedQueue() + lateinit var transport: WsTransport + lateinit var rpc: RpcChannel + lateinit var health: ConnectionHealth + lateinit var replay: ReplayTracker + + init { + health = + ConnectionHealth( + scope = scope, + isConnected = { transport.isConnected }, + pingRequest = { _ -> }, + cancelSocket = { transport.cancelSocket() }, + pendingReply = { transport.pendingReply }, + nowMs = { clock.get() }, + ) + + replay = + ReplayTracker( + scope = scope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { recordedEvents.add(it) }, + ) + + rpc = + RpcChannel( + scope = scope, + json = json, + outboundLock = lock, + sendRequest = { method, params, onSent -> + transport.send(rpc.createRequest(method, params), onSent) + }, + sendFrame = { transport.sendDirectFrame(it) }, + removeQueuedMessage = { transport.removeQueuedMessage(it) }, + onIdleCheck = { transport.disconnectIfIdleInBackground() }, + onResult = { transport.onRpcResult(it) }, + onError = { transport.onRpcError(it) }, + ) + + transport = + WsTransport( + scope = scope, + outboundLock = lock, + reconnectPolicy = ReconnectPolicy(), + connectionHealth = health, + replayTracker = replay, + rpcChannel = rpc, + onRawMessage = { raw -> + rpc.handleIncomingFrame(raw, replay) { recordedEvents.add(it) } + }, + ) + } + + fun close() { + try { + transport.disconnect(clearPendingMessages = true) + } finally { + scope.cancel() + } + } + } + + @Test + fun transportRpcRoundTripWithStaleListenerIsolation() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listenerGen1 = fixture.transport.createListenerForTest(1) + listenerGen1.onOpen(fakeWs, fixture.openResponse) + + assertTrue(fixture.transport.isConnected) + assertEquals(fakeWs, fixture.transport.webSocketForTest) + + val deferred = fixture.rpc.request("gateway.ping", emptyMap()) + val sentFrame = requireNotNull(fakeWs.sentFrames.poll()) + + val parsedSent = fixture.json.decodeFromString(sentFrame) + val reqId = parsedSent.id + + fixture.clock.set(200L) + val responseJson = """{"jsonrpc":"2.0","id":"$reqId","result":42.0}""" + listenerGen1.onMessage(fakeWs, responseJson) + + val result = withTimeout(2_000L) { deferred.await() } + assertEquals(JsonPrimitive(42.0), result) + + val event = requireNotNull(fixture.recordedEvents.poll()) + assertTrue(event is WsEvent.RpcResult) + assertEquals(reqId, (event as WsEvent.RpcResult).id) + assertEquals(200L, fixture.health.lastPongTimestamp) + + val staleListener = fixture.transport.createListenerForTest(0) + val staleWs = FakeWebSocket(fixture.dummyRequest, accept = true) + staleListener.onMessage(staleWs, """{"jsonrpc":"2.0","id":999,"result":99.0}""") + listenerGen1.onMessage(staleWs, """{"jsonrpc":"2.0","id":998,"result":88.0}""") + + assertTrue(fixture.transport.isConnected) + assertEquals(fakeWs, fixture.transport.webSocketForTest) + assertEquals(200L, fixture.health.lastPongTimestamp) + assertTrue(fixture.recordedEvents.isEmpty()) + } finally { + fixture.close() + } + } + + @Test + fun rejectingWsQueuesAndFlushesOnNextAcceptSocket() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val rejectingWs = FakeWebSocket(fixture.dummyRequest, accept = false, queueSizeValue = 1L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listener1 = fixture.transport.createListenerForTest(1) + listener1.onOpen(rejectingWs, fixture.openResponse) + assertTrue(fixture.transport.isConnected) + + var recordedId: String? = null + var callbackRanUnderLock = false + val req1 = fixture.rpc.createRequest("test.one", emptyMap()) + val returnedId = + fixture.transport.send( + request = req1, + onSent = { id -> + callbackRanUnderLock = Thread.holdsLock(fixture.lock) + recordedId = id + }, + ) + + assertEquals(returnedId, req1.id.toString()) + assertEquals(returnedId, recordedId) + assertTrue(callbackRanUnderLock) + + val req2 = fixture.rpc.createRequest("test.two", emptyMap()) + fixture.transport.messageQueueForTest.add(fixture.rpc.encodeRequest(req2)) + + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value) + assertNotNull(fixture.transport.outboundDrainJobForTest) + assertEquals(2, fixture.transport.messageQueueForTest.size) + + val acceptWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + val listenerReplacement = fixture.transport.createListenerForTest(1) + listenerReplacement.onOpen(acceptWs, fixture.openResponse) + + assertTrue(fixture.transport.isConnected) + assertEquals(ConnectionStatus.CONNECTED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.outboundDrainJobForTest) + + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + val frameList = acceptWs.sentFrames.toList() + assertEquals( + listOf(fixture.rpc.encodeRequest(req1), fixture.rpc.encodeRequest(req2)), + frameList, + ) + } finally { + fixture.close() + } + } + + @Test + fun authClosingDoesNotPrematurelyDropPendingRpcAndClosedHaltsReconnect() = + kotlinx.coroutines.runBlocking { + val fixture = TestFixture() + try { + val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.intentionalCloseForTest.set(false) + + val listener = fixture.transport.createListenerForTest(1) + listener.onOpen(fakeWs, fixture.openResponse) + assertTrue(fixture.transport.isConnected) + + val pendingDeferred = fixture.rpc.request("gateway.ping", emptyMap()) + assertTrue(fixture.rpc.hasPendingCalls()) + + listener.onClosing(fakeWs, 4401, "AUTH_EXPIRED") + assertTrue(fixture.transport.isConnected) + assertTrue(fixture.rpc.hasPendingCalls()) + assertFalse(pendingDeferred.isCompleted) + + listener.onClosed(fakeWs, 4401, "AUTH_EXPIRED") + assertFalse(fixture.transport.isConnected) + assertNull(fixture.transport.reconnectJobForTest) + assertTrue(fixture.rpc.hasPendingCalls()) + assertFalse(pendingDeferred.isCompleted) + + fixture.transport.disconnect(clearPendingMessages = true) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } finally { + fixture.close() + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt index 946125fc..29fe3dac 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatReadNotificationTest.kt @@ -251,7 +251,15 @@ class ChatReadNotificationTest { SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")), SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")), ) - val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context) + val messages = + mapServerMessages( + sessionId, + history, + 0, + true, + emptyList(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId) assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId) 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 52dd188f..564742ac 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 @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState import com.m57.hermescontrol.ui.common.ActionProgressController import io.mockk.every import io.mockk.mockk @@ -61,6 +62,10 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.transcriptState } returns + MutableStateFlow( + TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null), + ).asStateFlow() every { mockViewModel.connectionOperationState } returns MutableStateFlow(ConnectionOperationUiState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow — a relaxed @@ -110,6 +115,10 @@ class ChatScreenTest { every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() + every { mockViewModel.transcriptState } returns + MutableStateFlow( + TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null), + ).asStateFlow() every { mockViewModel.connectionOperationState } returns MutableStateFlow(ConnectionOperationUiState()).asStateFlow() every { mockViewModel.actionProgress } returns diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt new file mode 100644 index 00000000..bb7b1d1f --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt @@ -0,0 +1,49 @@ +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 com.m57.hermescontrol.ui.chat.components.ReasoningCard +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ChatSearchRenderingTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() { + composeTestRule.setContent { + MaterialTheme { + ReasoningCard( + reasoningText = "a hidden needle in the trace", + searchQuery = "needle", + isCurrentMatch = true, + ) + } + } + + composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed() + } + + @Test + fun toolMatchRemainsVisibleInCollapsedHeader() { + composeTestRule.setContent { + MaterialTheme { + ToolBubble( + message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"), + searchQuery = "terminal", + isCurrentMatch = true, + ) + } + } + + composeTestRule.onNodeWithText("terminal").assertIsDisplayed() + } +} 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 index ec36d0dc..cdad49b8 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -30,4 +32,50 @@ class MarkdownHeadingRenderingTest { composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed() composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist() } + + @Test + fun quoteAndListContinuationRenderNestedCodeBlocks() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = + """ + > # Quote heading + > ```kotlin + > val quoted = 1 + > ``` + + - Item + ```kotlin + val listed = 2 + ``` + """.trimIndent(), + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed() + composeTestRule.onNodeWithText("Item").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2) + composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed() + } + + @Test + fun streamingMarkdownRendersBlocksBeforeCompletion() { + composeTestRule.setContent { + MaterialTheme { + MarkdownText( + text = "## Live heading\n\n**ready**\n\n```kotlin\nval x = 1\n```", + textColor = MaterialTheme.colorScheme.onSurface, + isStreaming = true, + ) + } + } + + composeTestRule.onNodeWithText("Live heading").assertIsDisplayed() + composeTestRule.onNodeWithText("ready").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(1) + } } 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 index 559a5fc8..4272cbb5 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt @@ -1,11 +1,19 @@ package com.m57.hermescontrol.ui.chat +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -32,4 +40,26 @@ class MarkdownTableRenderingTest { composeTestRule.onNodeWithText("**Name**").assertDoesNotExist() composeTestRule.onNodeWithText("`code`").assertDoesNotExist() } + + @Test + fun columnsSizeToContentAndStayAlignedAcrossRows() { + composeTestRule.setContent { + MaterialTheme { + Box(Modifier.width(220.dp)) { + MarkdownText( + text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |", + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot() + val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot() + val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot() + val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot() + assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left) + assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left) + assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left) + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt index 49617fb4..f7eea804 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MediaViewerDeviceTest.kt @@ -210,6 +210,59 @@ class MediaViewerDeviceTest { assertEquals(expectedSize, actualSize) } + @Test + fun imageUsesImageSurfaceAndStreamsSaveToDownloads() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val name = "viewer-image-${System.nanoTime()}.png" + val file = File(context.cacheDir, name) + val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888) + file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + compose.setContent { + var visible by remember { mutableStateOf(true) } + HermesControlTheme { + if (visible) { + MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png") + } + } + } + awaitTag("media_image") + // Images never get the player surface. + assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty()) + compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick() + + val resolver = context.contentResolver + var savedUri: Uri? = null + compose.waitUntil(15_000) { + resolver + .query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(name), + null, + )?.use { c -> + if (c.moveToFirst()) { + val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + true + } else { + false + } + } ?: false + } + try { + assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() }) + } finally { + savedUri?.let { resolver.delete(it, null, null) } + } + compose.onNodeWithTag("media_close_button").performClick() + compose.waitUntil(15_000) { + compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty() + } + } + @Test fun invalidSourceShowsRetryAndCanClose() { compose.setContent { diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt new file mode 100644 index 00000000..2c8bc33a --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubbleTest.kt @@ -0,0 +1,162 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.AnnotatedString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi +import com.m57.hermescontrol.ui.chat.ClarifyUi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ClarifyBubbleTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() { + var respondedSingle: String? = null + var respondedBatch: Map? = null + var dismissed = false + + val clarifyRequest = + ClarifyUi( + text = "Which environment should be targeted?", + options = listOf("Staging", "Production"), + clarifyId = "req-1", + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = clarifyRequest, + onRespondSingle = { respondedSingle = it }, + onRespondBatch = { respondedBatch = it }, + onDismiss = { dismissed = true }, + ) + } + } + + compose.onNodeWithText("Staging").assertIsDisplayed().performClick() + + compose.runOnIdle { + assertEquals("Staging", respondedSingle) + assertNull(respondedBatch) + assertEquals(false, dismissed) + } + } + + @Test + fun clickingDismiss_invokesOnDismissWithoutResponse() { + var respondedSingle: String? = null + var respondedBatch: Map? = null + var dismissed = false + + val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss) + + val clarifyRequest = + ClarifyUi( + text = "Do you want to run database migrations?", + options = listOf("Yes", "No"), + clarifyId = "req-2", + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = clarifyRequest, + onRespondSingle = { respondedSingle = it }, + onRespondBatch = { respondedBatch = it }, + onDismiss = { dismissed = true }, + ) + } + } + + compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick() + + compose.runOnIdle { + assertEquals(true, dismissed) + assertNull(respondedSingle) + assertNull(respondedBatch) + } + } + + @Test + fun replacingRequestIdentity_resetsEnteredTextAndSelection() { + val sharedQuestion = + ClarifyQuestionUi( + qid = "q-common", + question = "Which build targets and extras should be enabled?", + choices = listOf("APK", "AAB", "Native Symbols"), + multiSelect = true, + ) + + var currentRequest by mutableStateOf( + ClarifyUi( + text = sharedQuestion.question, + clarifyId = "req-1", + questions = listOf(sharedQuestion), + ), + ) + + compose.setContent { + HermesControlTheme { + ClarifyBubble( + clarifyRequest = currentRequest, + onRespondSingle = {}, + onRespondBatch = {}, + onDismiss = {}, + ) + } + } + + compose + .onNodeWithText("APK") + .assertIsDisplayed() + .assertIsNotSelected() + .performClick() + compose.onNodeWithText("APK").assertIsSelected() + + val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed() + textInputNode.performTextInput("--dry-run") + textInputNode.assert(hasEditableText("--dry-run")) + + // Replace request identity while retaining identical qid, question, and options + compose.runOnIdle { + currentRequest = + ClarifyUi( + text = sharedQuestion.question, + clarifyId = "req-2", + questions = listOf(sharedQuestion), + ) + } + + // Selected choice and typed text must reset under new clarifyId + compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected() + compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText("")) + } + + // The field merges its placeholder label into Text, so assertTextEquals can + // never match; compare only the typed value. + private fun hasEditableText(value: String) = + SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value)) +} 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 fc075cf0..cfd5c863 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 @@ -50,8 +50,8 @@ class ComposerReadinessTest { 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. + // Ready + typing = an interruptible generation: Send keeps the primary + // action slot and Stop moves to the flat slot beside it. compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.runOnIdle { assertEquals(1, sends) } @@ -60,4 +60,28 @@ class ComposerReadinessTest { compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("chat_input").assertTextEquals("held draft") } + + @Test + fun emptyDraftWhileStreamingMakesStopThePrimaryAction() { + compose.setContent { + var input by remember { mutableStateOf(TextFieldValue("")) } + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = {}, + onMicTap = {}, + isListening = false, + isAgentTyping = true, + canInterrupt = true, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + ) + } + compose.onNodeWithTag("stop_button").assertExists() + compose.onNodeWithTag("send_button").assertDoesNotExist() + compose.onNodeWithTag("chat_input").performTextInput("next") + compose.onNodeWithTag("send_button").assertExists() + compose.onNodeWithTag("stop_button").assertExists() + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt new file mode 100644 index 00000000..43b45c67 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/CopyFeedbackTest.kt @@ -0,0 +1,165 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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 CopyFeedbackTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Composable + private fun CopyFeedbackHarness( + tagPrefix: String, + stateReceiver: (MutableState) -> Unit = {}, + ) { + val feedbackState = rememberCopyFeedback() + stateReceiver(feedbackState) + + Column { + Text( + text = if (feedbackState.value) "Copied" else "Idle", + modifier = Modifier.testTag("${tagPrefix}_status"), + ) + Text( + text = "Trigger", + modifier = + Modifier + .testTag("${tagPrefix}_trigger") + .clickable { feedbackState.value = true }, + ) + } + } + + @Test + fun initialStateIsFalse() { + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + composeTestRule + .onNodeWithTag("feedback_status") + .assertIsDisplayed() + .assertTextEquals("Idle") + } + + @Test + fun settingTrueShowsFeedbackAndResetsAfter1500Ms() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance 1400ms - should still be true (100ms before 1500ms timeout) + composeTestRule.mainClock.advanceTimeBy(1400L) + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance another 100ms to hit exactly 1500ms + composeTestRule.mainClock.advanceTimeBy(100L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + } + + @Test + fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + CopyFeedbackHarness(tagPrefix = "feedback") + } + + // Trigger at t = 0ms + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Advance to t = 1000ms + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // Re-trigger setting true at t = 1000ms while already true + composeTestRule.onNodeWithTag("feedback_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied") + + // If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click) + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle") + } + + @Test + fun separateComposedHookInstancesAreIndependent() { + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.setContent { + Column { + CopyFeedbackHarness(tagPrefix = "instance_a") + CopyFeedbackHarness(tagPrefix = "instance_b") + } + } + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + + // Trigger only instance A + composeTestRule.onNodeWithTag("instance_a_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + // Instance A should be active, instance B should remain idle + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + + // Advance 1000ms + composeTestRule.mainClock.advanceTimeBy(1000L) + + // Trigger instance B at t = 1000ms + composeTestRule.onNodeWithTag("instance_b_trigger").performClick() + composeTestRule.mainClock.advanceTimeByFrame() + + // Both should be Copied now + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied") + + // Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining + composeTestRule.mainClock.advanceTimeBy(500L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied") + + // Advance remaining 1000ms for Instance B + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.mainClock.advanceTimeByFrame() + + composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle") + composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle") + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt index 5c9a80c1..86f7f60b 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -219,6 +219,10 @@ class VoiceNoteGestureTest { composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } composeTestRule.mainClock.advanceTimeBy(600) + // The strip takes the field's decoration box mid-hold; the field must + // keep its focus so the keyboard never collapses when recording starts + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } composeTestRule.waitForIdle() diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt index dfbf9241..5a940cee 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatAnchorTest.kt @@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.StreamingState @@ -21,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController -import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch @@ -116,31 +116,58 @@ class FullBleedChatAnchorTest { val tailKeys = listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card") val listState = LazyListState(index, offset) - val viewModel = mockk(relaxed = true) lateinit var scope: CoroutineScope compose.setContent { scope = rememberCoroutineScope() val controller = rememberChatScrollController(listState, scope) Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) { FullBleedChatList( - messages = messages.value, - streamingState = streaming.value, - isAgentTyping = tailKey != null, + transcript = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = messages.value, + isAgentTyping = tailKey != null, + typingEffectEnabled = false, + typingEffectDelayMs = 30, + isLoadingOlder = loading.value, + currentSessionId = session.value, + clarifyRequest = + if (tailKey != + null + ) { + ClarifyUi("Choose an option", listOf("Yes")) + } else { + null + }, + vaultUnlockPrompt = + if (tailKey != null) { + VaultUnlockPromptUi( + null, + null, + ) + } else { + null + }, + vaultSaveLoginPrompt = + if (tailKey != + null + ) { + VaultSaveLoginPromptUi(null, null) + } else { + null + }, + vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null, + ), + timeline = ChatTimelineState(), + streaming = streaming.value, + savingAttachmentPath = null, + speakingMessageId = null, + ), + actions = testTranscriptActions(), searchState = ChatSearchState(), - typingEffectEnabled = false, - typingEffectDelayMs = 30, - isLoading = false, - isLoadingOlder = loading.value, listState = listState, scrollController = controller, - lastAnimatedMessageId = null, - onLastAnimatedMessageIdChange = {}, - viewModel = viewModel, - pagingSessionId = session.value, - clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null, - vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null, - vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null, - vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null, ) } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index 9a4ef7f1..e036eed9 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.chat.fullbleed import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals @@ -12,19 +15,23 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController -import io.mockk.mockk -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -72,44 +79,87 @@ class FullBleedChatListTest { showUserMessageTokens: Boolean = true, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, + searchState: ChatSearchState = ChatSearchState(), + onController: (ChatScrollController) -> Unit = {}, ) { composeTestRule.setContent { - val listState = LazyListState() + val listState = remember { LazyListState() } + // Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock. + val scrollController = rememberChatScrollController(listState, rememberCoroutineScope()) + SideEffect { onController(scrollController) } FullBleedChatList( - messages = messages, - streamingState = streamingState, - isAgentTyping = isAgentTyping, - searchState = ChatSearchState(), - typingEffectEnabled = false, - typingEffectDelayMs = 30, - messageStatsEnabled = messageStatsEnabled, - showUserMessageTokens = showUserMessageTokens, - showAssistantMessageTokens = showAssistantMessageTokens, - showTokensPerSecond = showTokensPerSecond, - isLoading = false, - isLoadingOlder = false, - listState = listState, - scrollController = - ChatScrollController( - listState = listState, - scope = CoroutineScope(Dispatchers.Main.immediate), + transcript = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = messages, + isAgentTyping = isAgentTyping, + typingEffectEnabled = false, + typingEffectDelayMs = 30, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, + clarifyRequest = + if (clarify) { + com.m57.hermescontrol.ui.chat.ClarifyUi( + text = "pick", + options = listOf("a"), + ) + } else { + null + }, + ), + timeline = ChatTimelineState(), + streaming = streamingState, + savingAttachmentPath = null, + speakingMessageId = null, ), - lastAnimatedMessageId = null, - onLastAnimatedMessageIdChange = {}, - viewModel = mockk(relaxed = true), - clarifyRequest = - if (clarify) { - com.m57.hermescontrol.ui.chat.ClarifyUi( - text = "pick", - options = listOf("a"), - ) - } else { - null - }, + actions = testTranscriptActions(), + searchState = searchState, + listState = listState, + scrollController = scrollController, ) } } + @Test + fun upwardUserGesture_pausesBottomFollow() { + lateinit var scrollController: ChatScrollController + render( + messages = List(40) { index -> msg("u$index", MessageRole.USER) }, + onController = { scrollController = it }, + ) + composeTestRule.runOnIdle { scrollController.jumpToBottom() } + composeTestRule.waitForIdle() + composeTestRule.onRoot().performTouchInput { swipeDown() } + composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) } + } + + @Test + fun searchNavigatesToToolRowBeyondInitialViewport() { + val search = + ChatSearchState().apply { + isActive = true + query = "terminal" + matchIndices = listOf(30) + matchOffsets = listOf(0) + matchTargets = listOf(SearchTarget.TOOL) + currentIndex = 0 + matchedIds = setOf("tool") + currentMatchId = "tool" + } + render( + messages = + List(30) { index -> msg("user-$index", MessageRole.USER) } + + ChatMessage("tool", MessageRole.TOOL, "opaque", toolName = "terminal"), + searchState = search, + ) + + composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed() + composeTestRule.onNodeWithText("terminal").assertIsDisplayed() + } + @Test fun userMessages_keepBubble_agentProse_isFullBleed() { render( diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt new file mode 100644 index 00000000..90f29583 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptTestActions.kt @@ -0,0 +1,21 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */ +internal fun testTranscriptActions() = + TranscriptActions( + onLoadOlder = {}, + onOpenAttachment = {}, + onSaveAttachment = {}, + onImageClick = {}, + onRespondApproval = {}, + onRespondClarify = {}, + onRespondClarifyBatch = {}, + onDismissClarify = {}, + onRespondVaultUnlock = {}, + onDismissVaultUnlock = {}, + onRespondVaultSaveLogin = { _, _ -> }, + onDismissVaultSaveLogin = {}, + onRespondVaultCode = {}, + onDismissVaultCode = {}, + onToggleSpeak = {}, + ) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt new file mode 100644 index 00000000..a115fa7a --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.resolveStatusColors +import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class StatusBadgeColorTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun latteBadgesRenderStatusFillsAndMatchingOnColors() { + val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL } + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) { + Column { + types.forEach { type -> + StatusBadge(type.name, type, Modifier.testTag("badge_$type")) + } + } + } + } + val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false) + types.forEach { type -> + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb()) + } + } + + @Test + fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() { + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) { + Column { + SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working") + SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting") + } + } + } + val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false) + assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb()) + assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb()) + } + + private fun assertRenderedPair( + tag: String, + background: Int, + foreground: Int, + ) { + // PR #1417: verify pixels as well as the palette mapping, so a renderer + // switching back to accent-on-container cannot leave the contrast test green. + val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap() + assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb()) + assertTrue( + "$tag must render the matching on-status text/icon", + (0 until pixels.height).any { y -> + (0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground } + }, + ) + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt new file mode 100644 index 00000000..091862e4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt @@ -0,0 +1,238 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.ThemeRegistry +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +@MediumTest +class ThemeComponentGalleryTest( + private val preset: ThemePreset, + private val dark: Boolean, +) { + @get:Rule + val composeTestRule = createComposeRule() + + private var samples = emptyList() + + @Test + fun enabledControlsRenderTheirAccessibleDefaultStateColors() { + composeTestRule.setContent { + HermesControlTheme( + themePreset = preset, + themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT, + ) { + Gallery() + } + } + composeTestRule.waitForIdle() + // Assert the public defaults from the installed Compose version, not a copied token table. + assertTrue("gallery must publish its state contracts", samples.isNotEmpty()) + samples.forEach { sample -> + val a = sample.foreground.luminance() + val b = sample.background.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum) + } + // Pixel evidence catches defaults that differ from the actual rendered state (including focus). + samples.groupBy { it.tag }.forEach { (tag, contracts) -> + val pixels = + composeTestRule + .onNodeWithTag(tag) + .performScrollTo() + .captureToImage() + .toPixelMap() + val rendered = + buildSet { + for (y in 0 until pixels.height) { + for (x in 0 until pixels.width) add(pixels[x, y].toArgb()) + } + } + contracts.forEach { sample -> + assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered) + if (sample.checkBackground) { + assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered) + } + } + } + } + + @Composable + private fun Gallery() { + val c = MaterialTheme.colorScheme + val switches = SwitchDefaults.colors() + val radios = RadioButtonDefaults.colors() + val checks = CheckboxDefaults.colors() + val sliders = SliderDefaults.colors() + val fields = OutlinedTextFieldDefaults.colors() + val segments = SegmentedButtonDefaults.colors() + val focus = remember { FocusRequester() } + SideEffect { + samples = + listOf( + Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true), + Sample("switch_on", switches.checkedTrackColor, c.surface), + Sample( + "switch_off", + switches.uncheckedThumbColor, + switches.uncheckedTrackColor, + checkBackground = true, + ), + Sample("switch_off", switches.uncheckedBorderColor, c.surface), + Sample("radio_on", radios.selectedColor, c.surface), + Sample("radio_off", radios.unselectedColor, c.surface), + Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true), + Sample("check_on", checks.checkedBorderColor, c.surface), + Sample("check_off", checks.uncheckedBorderColor, c.surface), + Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true), + Sample( + "field_normal", + fields.unfocusedIndicatorColor, + fields.unfocusedContainerColor.compositeOver(c.surface), + ), + Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f), + Sample( + "field_focus", + fields.focusedIndicatorColor, + fields.focusedContainerColor.compositeOver(c.surface), + ), + Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f), + Sample( + "field_error", + fields.errorIndicatorColor, + fields.errorContainerColor.compositeOver(c.surface), + ), + Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f), + Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true), + Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor), + Sample( + "segment_off", + segments.inactiveContentColor, + segments.inactiveContainerColor.compositeOver(c.surface), + 4.5f, + ), + Sample("segment_off", segments.inactiveBorderColor, c.surface), + Sample("primary_text", c.primary, c.surface, 4.5f), + Sample("error_text", c.error, c.surface, 4.5f), + ) + } + Surface(color = c.surface, modifier = Modifier.fillMaxSize()) { + Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) { + Text("$preset ${if (dark) "dark" else "light"}") + Row { + Switch(true, {}, Modifier.testTag("switch_on")) + Switch(false, {}, Modifier.testTag("switch_off")) + RadioButton(true, {}, Modifier.testTag("radio_on")) + RadioButton(false, {}, Modifier.testTag("radio_off")) + } + Row { + Checkbox(true, {}, Modifier.testTag("check_on")) + Checkbox(false, {}, Modifier.testTag("check_off")) + } + Slider(0.5f, {}, Modifier.testTag("slider")) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") }) + OutlinedTextField( + "Value", + { + }, + Modifier + .fillMaxWidth() + .focusRequester( + focus, + ).testTag("field_focus"), + readOnly = true, + label = { Text("Focused") }, + ) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = { + Text("Error") + }, supportingText = { Text("Error text") }) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + true, + {}, + SegmentedButtonDefaults.itemShape(0, 2), + Modifier.testTag("segment_on"), + ) { Text("Selected") } + SegmentedButton( + false, + { + }, + SegmentedButtonDefaults.itemShape( + 1, + 2, + ), + Modifier.testTag("segment_off"), + ) { Text("Unselected") } + } + Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary) + Text("Error foreground", Modifier.testTag("error_text"), color = c.error) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + } + + private data class Sample( + val tag: String, + val foreground: Color, + val background: Color, + val minimum: Float = 3f, + val checkBackground: Boolean = false, + ) + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0} dark={1}") + fun presets(): List> = + ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf(preset, it) } } + } +} diff --git a/app/src/debug/res/values-ar/strings.xml b/app/src/debug/res/values-ar/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-ar/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values-zh/strings.xml b/app/src/debug/res/values-zh/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values-zh/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 00000000..16d048ae --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Hermes Dev + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8da078e3..6311e266 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -71,6 +71,10 @@ android:name=".notification.NotificationReplyReceiver" android:exported="false" /> + + char in '0'..'9' } }?.toLongOrNull() diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index f9b2bf17..c45078c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -17,6 +17,9 @@ import kotlinx.serialization.Serializable * file that lives on the *gateway host* (agent-delivered `MEDIA:` directive). * Null for locally-picked attachments. * @param source Where the attachment originated. + * @param contentOffset For agent `MEDIA:` attachments, the char offset in the message's + * stripped text where the directive appeared, so the renderer can place it in-flow. + * Null means "append after the text". */ @Serializable data class Attachment( @@ -26,6 +29,7 @@ data class Attachment( val size: Long = 0, val gatewayUrl: String? = null, val source: AttachmentSource = AttachmentSource.LOCAL, + val contentOffset: Int? = null, ) { val mediaKind: MediaKind get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt new file mode 100644 index 00000000..ec4f9fb9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CredentialPoolAddRequest.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** Body for POST api/credentials/pool. `apiKey` serializes as `api_key`. */ +@Serializable +data class CredentialPoolAddRequest( + val provider: String, + val apiKey: String, + val label: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt index 14be3d2d..705c5212 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/DebugShareResponse.kt @@ -9,3 +9,8 @@ data class DebugShareResponse( val redacted: Boolean? = null, val auto_delete_seconds: Long? = null, ) + +@Serializable +data class DebugShareRequest( + val redact: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt index 5f4f4b31..46ed5b8c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/HookResponse.kt @@ -17,3 +17,19 @@ data class HookEntry( val approved_at: String? = null, val executable: Boolean? = null, ) + +/** Body for POST api/ops/hooks. The backend field is `approve` (writes the consent allowlist entry). */ +@Serializable +data class HookCreateRequest( + val event: String, + val command: String, + val matcher: String? = null, + val timeout: Int? = null, + val approve: Boolean? = null, +) + +@Serializable +data class HookDeleteRequest( + val event: String, + val command: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 9b671430..3ce29ce3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject @Serializable data class McpServersResponse( @@ -19,12 +22,21 @@ data class McpServer( val status: String? = null, val error: String? = null, val auth: String? = null, - val tools: List? = null, + val tools: JsonElement? = null, val source: String = "config", val plugin: String? = null, ) { // #1283: plugin-provided servers are managed by their owning plugin. val isPluginOwned: Boolean get() = source == "plugin" + + // Backend sends `tools` as null, a name list, or a `{include, exclude}` filter object. + val toolCount: Int? + get() = + when (val t = tools) { + is JsonArray -> t.size + is JsonObject -> (t["include"] as? JsonArray)?.size + else -> null + } } @Serializable @@ -71,13 +83,15 @@ data class McpCatalogEntry( val url: String? = null, val command: String? = null, val args: List? = null, - val env: List? = null, + @SerialName("required_env") val env: List? = null, + val installed: Boolean = false, + val enabled: Boolean = false, ) @Serializable data class McpCatalogEnvVar( - val key: String, - val label: String? = null, + @SerialName("name") val key: String, + @SerialName("prompt") val label: String? = null, val description: String? = null, val required: Boolean = false, ) @@ -96,3 +110,9 @@ data class McpOAuthFlowResponse( val authorizationUrl: String? = null, val error: String? = null, ) + +/** Body for PUT api/mcp/servers/{name}; only the fields being changed are sent. */ +@Serializable +data class McpServerUpdateRequest( + val env: Map? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt new file mode 100644 index 00000000..a95a6bb2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */ +data class MessageReaction( + val emoji: String, + val author: String, +) + +/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */ +internal fun parseMessageReactions(raw: Any?): List = + (raw as? List<*>).orEmpty().mapNotNull { item -> + val map = item as? Map<*, *> ?: return@mapNotNull null + val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MessageReaction(emoji = emoji, author = map["author"] as? String ?: "") + } + +/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */ +internal fun parseMessageReactions(displayMetadata: JsonElement?): List { + val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList() + return list.mapNotNull { item -> + val obj = item as? JsonObject ?: return@mapNotNull null + val emoji = + (obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty() + MessageReaction(emoji = emoji, author = author) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt index ec25c2af..3a51c9b4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ModelAssignment.kt @@ -25,14 +25,20 @@ data class MainModelAssignment( data class MoaModelSlot( val provider: String, val model: String, + val reasoning_effort: String? = null, + val enabled: Boolean = true, ) @Serializable data class MoaConfigPreset( val reference_models: List, val aggregator: MoaModelSlot, - val reference_temperature: Double = 0.7, - val aggregator_temperature: Double = 0.3, + // null = omitted from API calls (provider default); the backend sends null when unset. + val reference_temperature: Double? = null, + val aggregator_temperature: Double? = null, + val reference_timeout: Double? = null, + val degraded_reference_policy: String = "loud", + val fanout: String? = null, val max_tokens: Int = 4096, val enabled: Boolean = true, ) @@ -44,8 +50,12 @@ data class MoaConfigResponse( val presets: Map = emptyMap(), val reference_models: List = emptyList(), val aggregator: MoaModelSlot = MoaModelSlot("", ""), - val reference_temperature: Double = 0.7, - val aggregator_temperature: Double = 0.3, + // null = omitted from API calls (provider default); the backend sends null when unset. + val reference_temperature: Double? = null, + val aggregator_temperature: Double? = null, + val reference_timeout: Double? = null, + val degraded_reference_policy: String = "loud", + val fanout: String? = null, val max_tokens: Int = 4096, val enabled: Boolean = true, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt index f5525b2e..3315dd19 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt @@ -1,7 +1,17 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive /** * OAuth provider catalog entry returned by `GET /api/providers/oauth`. @@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable * id, name, flow ("pkce" | "device_code" | "external"), cli_command, * docs_url, disconnect_hint, disconnect_command, disconnectable, status. * - * `expires_at` in the status comes back as an ISO timestamp string. + * `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the + * credential source, so they decode through [LenientStringSerializer]. */ @Serializable data class OAuthProvider( @@ -31,9 +42,13 @@ data class OAuthProviderStatus( val source: String? = null, @SerialName("source_label") val sourceLabel: String? = null, @SerialName("token_preview") val tokenPreview: String? = null, - @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("expires_at") + @Serializable(with = LenientStringSerializer::class) + val expiresAt: String? = null, @SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null, - @SerialName("last_refresh") val lastRefresh: String? = null, + @SerialName("last_refresh") + @Serializable(with = LenientStringSerializer::class) + val lastRefresh: String? = null, val error: String? = null, ) @@ -98,3 +113,25 @@ data class OAuthCancelResponse( @SerialName("session_id") val sessionId: String? = null, val message: String? = null, ) + +/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */ +@OptIn(ExperimentalSerializationApi::class) +object LenientStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + return if (element is JsonPrimitive && element !is JsonNull) element.content else null + } + return decoder.decodeString() + } + + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 8b391ba0..4b299abc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -123,7 +123,10 @@ data class SessionRenameRequest( @Serializable data class BulkDeleteRequest( val ids: List, - val delete_all: Boolean = false, + // Destructive POSTs read the profile off the BODY (not the ?profile= + // query rewrite) — an unnamed bulk delete is refused with 400 on a + // multi-profile backend. Null stays valid for single-profile hosts. + val profile: String? = null, ) @Serializable @@ -134,5 +137,13 @@ data class BulkDeleteResponse( @Serializable data class PruneRequest( - val days: Int, + // Server contract: `older_than_days` (hermes_cli/web_models.py SessionPrune). + // The old `days` key was unknown to the backend, so the user's choice was + // silently dropped and the server pruned its own 90-day default. + @SerialName("older_than_days") val olderThanDays: Int, + // Destructive POSTs read the profile off the BODY (not the ?profile= query + // rewrite): on a multi-profile backend an unnamed prune is refused with + // 400 (web_routers/_common.py destructive_profile). Null stays valid for + // single-profile hosts. + val profile: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt index 3f201350..bf827456 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt @@ -30,6 +30,7 @@ data class ActiveSessionItem( val status: String? = null, @SerialName("last_active") val lastActive: Double? = null, + val title: String? = null, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index a5a5e1a1..1e0b6ca0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -75,6 +75,10 @@ data class SessionMessage( val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + /** Emoji reactions persisted on this row (`display_metadata.reactions`). */ + val reactions: List + get() = parseMessageReactions(display_metadata) + val tokenCount: Int? get() { val prim = token_count as? JsonPrimitive ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index a2a486c6..3e15cf62 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 @@ -20,10 +20,12 @@ import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareRequest import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DeliveryTargetsResponse @@ -38,6 +40,8 @@ import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.HealthStatus +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.KanbanBoardResponse @@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest @@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService { @PUT("api/mcp/servers/{name}") suspend fun updateMcpServer( @Path("name") name: String, - @Body body: Map, + @Body body: McpServerUpdateRequest, ): Response @POST("api/mcp/servers/{name}/auth") @@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService { @POST("api/mcp/catalog/install") suspend fun installMcpCatalogEntry( @Body body: McpCatalogInstallRequest, - ): Response> + ): Response @GET("api/webhooks") suspend fun getWebhooks(): Response @@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService { @PUT("api/curator/paused") suspend fun setCuratorPaused( @Body body: Map, - ): Response> + ): Response @POST("api/curator/run") suspend fun runCurator(): Response @@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService { @POST("api/credentials/pool") suspend fun addCredentialPoolEntry( - @Body body: Map, - ): Response> + @Body body: CredentialPoolAddRequest, + ): Response @DELETE("api/credentials/pool/{provider}/{index}") suspend fun removeCredentialPoolEntry( @Path("provider") provider: String, @Path("index") index: Int, - ): Response> + ): Response // ── Admin: Operations ───────────────────────────────────────────── @POST("api/ops/security-audit") @@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService { // ── Admin: Debug share ──────────────────────────────────────────── @POST("api/ops/debug-share") suspend fun runDebugShare( - @Body body: Map, + @Body body: DebugShareRequest, ): Response // ── Admin: Checkpoints ──────────────────────────────────────────── @@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService { @POST("api/ops/hooks") suspend fun createHook( - @Body body: Map, - ): Response> + @Body body: HookCreateRequest, + ): Response - @DELETE("api/ops/hooks") + @HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true) suspend fun deleteHook( - @Body body: Map, - ): Response> + @Body body: HookDeleteRequest, + ): Response // ── Admin: Action status (log viewer) ──────────────────────────── @GET("api/actions/{name}/status") 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 3c407a83..3202cadc 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 @@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor { // multi-profile host transcribes under the launch profile // (review, PR #1250). "/api/audio", + // Curator state is per profile (config-scoped reads) and the run + // trigger is privileged: `POST /api/curator/run` is refused with + // 400 on a multi-profile backend without ?profile=. + "/api/curator", "/api/config", "/api/credentials/pool", "/api/cron", diff --git a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt index fb261acd..c9133ca2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/update/AppUpdateChecker.kt @@ -222,7 +222,10 @@ open class AppUpdateChecker( val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL") val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL") - if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") { + val expectedReposPath = base.encodedPath.trimEnd('/') + "/releases" + val isReposPath = next.encodedPath == expectedReposPath + val isRepositoriesPath = REPOSITORIES_RELEASES_PATH.matches(next.encodedPath) + if (next.host != base.host || (!isReposPath && !isRepositoriesPath)) { throw IOException("GitHub returned an untrusted pagination URL") } return next.toString() @@ -340,6 +343,7 @@ open class AppUpdateChecker( const val RELEASE_LIST_PAGE_SIZE = 100 val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"") + val REPOSITORIES_RELEASES_PATH = Regex("^/repositories/[0-9]+/releases$") } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt index 9a5fee1d..67fd9d97 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/AccountConnectorRepository.kt @@ -7,6 +7,20 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsCatalogParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicyGetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsToolsParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -48,10 +62,9 @@ interface AccountConnectorRepository { expectedRevision: String, ): AccountConnectorResult - suspend fun operationRequest( - method: String, - params: Map, - ): Any? + suspend fun operationRespond(params: ConnectionRespondParams): Any? + + suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? suspend fun operationStatus( opId: String, @@ -61,25 +74,8 @@ interface AccountConnectorRepository { } class HermesAccountConnectorRepository( - private val rpc: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch ( - e: CancellationException, - ) { - deferred.cancel(e) - throw e - } finally { - if (!deferred.isCompleted) deferred.cancel() - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : AccountConnectorRepository { - private suspend fun request( - method: String, - params: Map = emptyMap(), - ) = rpc(method, params) - private suspend fun guarded(block: suspend () -> T): AccountConnectorResult = try { AccountConnectorResult.Success(block()) @@ -97,12 +93,6 @@ class HermesAccountConnectorRepository( AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway.")) } - private fun accountParams(params: Map = emptyMap()): Map = - buildMap { - putAll(params) - AuthManager.activeProfileId.value?.let { put("profile", it) } - } - private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty() @@ -116,10 +106,11 @@ class HermesAccountConnectorRepository( override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult = try { ConnectorParser.parseListResult( - rpc( - WsMethods.CONNECTORS_LIST, - accountParams( - mapOf("owner" to mapOf("type" to "account")), + caller.call( + RpcMethods.CONNECTORS_LIST, + ConnectorsListParams( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, ), ), ) @@ -146,27 +137,48 @@ class HermesAccountConnectorRepository( ) = guarded { require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug)) parseOperation( - request( - WsMethods.CONNECTORS_CONNECT, - accountParams( - mapOf( - "owner" to mapOf("type" to "account"), - "connectors" to slugs, - "reconnect" to reconnect, - ), + caller.call( + RpcMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = slugs, + reconnect = reconnect, + profile = AuthManager.activeProfileId.value, ), ), ) } - override suspend fun operationRequest( - method: String, - params: Map, - ): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account")))) + override suspend fun operationRespond(params: ConnectionRespondParams): Any? = + caller.call( + RpcMethods.CONNECTION_RESPOND, + params.copy( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, + ), + ) + + override suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? = + caller.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + params.copy( + owner = ConnectorOwner.account(), + profile = AuthManager.activeProfileId.value, + ), + ) override suspend fun operationStatus(opId: String) = guarded { - parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId))) + parseOperation( + caller.call( + RpcMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = opId, + profile = AuthManager.activeProfileId.value, + ), + ), + ) } @Suppress("UNCHECKED_CAST") @@ -180,7 +192,14 @@ class HermesAccountConnectorRepository( override suspend fun catalog() = guarded { - obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map { + val response = + caller.call( + RpcMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["connectors"]!!.jsonArray.map { val row = it.jsonObject ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category")) } @@ -188,7 +207,14 @@ class HermesAccountConnectorRepository( override suspend fun accounts() = guarded { - obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams( + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["accounts"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorAccount( r.str("connection_id"), @@ -209,10 +235,11 @@ class HermesAccountConnectorRepository( require(connectionId.isNotBlank()) val response = obj( - request( - WsMethods.CONNECTORS_ACCOUNTS_REMOVE, - accountParams( - mapOf("connection_id" to connectionId), + caller.call( + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams( + connectionId = connectionId, + profile = AuthManager.activeProfileId.value, ), ), ) @@ -225,12 +252,16 @@ class HermesAccountConnectorRepository( refresh: Boolean, ) = guarded { require(ConnectorRepository.isValidSlug(slug)) - val params = - buildMap { - put("slug", slug) - if (refresh) put("refresh", true) - } - obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e -> + val response = + caller.call( + RpcMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams( + slug = slug, + refresh = if (refresh) true else null, + profile = AuthManager.activeProfileId.value, + ), + ) + obj(response)["tools"]!!.jsonArray.map { e -> val r = e.jsonObject ConnectorTool( r.str("slug"), @@ -245,7 +276,15 @@ class HermesAccountConnectorRepository( override suspend fun policy() = guarded { - val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams())) + val root = + obj( + caller.call( + RpcMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams( + profile = AuthManager.activeProfileId.value, + ), + ), + ) val layers = root.array("layers").map { it.jsonObject } val member = layers.firstOrNull { it.str("kind") == "member" } parsePolicy(root["effective"]!!.jsonObject).copy( @@ -293,13 +332,12 @@ class HermesAccountConnectorRepository( check(scope == AuthManager.currentDataScope()) { "Account scope changed" } val result = obj( - request( - WsMethods.CONNECTORS_POLICY_SET, - accountParams( - mapOf( - "change" to change, - "expected_revision" to expectedRevision, - ), + caller.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = ConnectorParser.toJsonElement(change) as JsonObject, + expectedRevision = expectedRevision, + profile = AuthManager.activeProfileId.value, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt index 277162e6..8f6a602d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/BillingRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.SubscriptionStateResponse import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse import com.m57.hermescontrol.data.model.UsageBarsResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionPreviewParams +import com.m57.hermescontrol.data.ws.contract.SubscriptionUpgradeParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -90,50 +95,47 @@ object BillingRepository { } suspend fun getSubscriptionState(): SubscriptionStateResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams) return decode(result) } suspend fun getUsageBars(): UsageBarsResponse? { - val result = HermesWsClient.request(WsMethods.USAGE_BARS).await() + val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams) return decode(result) } suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_PREVIEW, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_CHANGE, - buildMap { - request.subscription_type_id?.let { put("subscription_type_id", it) } - request.cancel?.let { put("cancel", it) } - }, - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = request.subscription_type_id, + cancel = request.cancel, + ), + ) return decode(result) } suspend fun resumeSubscription(): SubscriptionResumeResponse? { - val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await() + val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams) return decode(result) } suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? { val result = - HermesWsClient - .request( - WsMethods.SUBSCRIPTION_UPGRADE, - mapOf("subscription_type_id" to subscriptionTypeId), - ).await() + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId), + ) return decode(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt new file mode 100644 index 00000000..89dcbbca --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionHealth.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.data.ws + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal class ConnectionHealth( + private val scope: CoroutineScope, + private val isConnected: () -> Boolean, + private val pingRequest: suspend (Long) -> Unit, + private val cancelSocket: () -> Unit, + private val pendingReply: () -> Boolean, + private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, + private val log: (String) -> Unit = {}, + private val debugLog: (String) -> Unit = {}, +) { + companion object { + internal const val HEARTBEAT_INTERVAL_MS = 15_000L + internal const val STALE_THRESHOLD_MS = 30_000L + internal const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L + } + + @Volatile + var lastPongTimestamp: Long = 0L + internal set + + private val _lastLatencyMs = MutableStateFlow(null) + val lastLatencyMs: StateFlow = _lastLatencyMs.asStateFlow() + + val isHealthy: Boolean + get() = isConnected() && (nowMs() - lastPongTimestamp < STALE_THRESHOLD_MS) + + private var healthJob: Job? = null + private var foregroundProbeJob: Job? = null + private var transportProbeJob: Job? = null + + fun start() { + healthJob?.cancel() + lastPongTimestamp = nowMs() + healthJob = + scope.launch { + while (isConnected()) { + delay(HEARTBEAT_INTERVAL_MS) + if (isConnected()) { + try { + ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS) + } catch (e: Exception) { + log("Health check ping failed: ${e.message}") + } + } + if (!runHealthCheckPass()) break + } + } + } + + fun stop() { + healthJob?.cancel() + healthJob = null + _lastLatencyMs.value = null + } + + fun onInboundFrame() { + lastPongTimestamp = nowMs() + } + + suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long { + val start = nowMs() + pingRequest(timeoutMs) + val latency = (nowMs() - start).coerceAtLeast(0L) + lastPongTimestamp = nowMs() + _lastLatencyMs.value = latency + return latency + } + + @VisibleForTesting + internal fun setLastLatencyForTest(latency: Long?) { + _lastLatencyMs.value = latency + } + + private fun runHealthCheckPass(): Boolean { + if (!isConnected()) return false + val staleMs = nowMs() - lastPongTimestamp + if (staleMs <= STALE_THRESHOLD_MS) return true + log("WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect") + cancelSocket() + return false + } + + @VisibleForTesting + internal fun forceHealthCheckForTest(staleMillis: Long) { + lastPongTimestamp = nowMs() - staleMillis + runHealthCheckPass() + } + + fun probeLivenessOnWake(deferredOnce: Boolean = false) { + if (!isConnected()) return + foregroundProbeJob?.cancel() + foregroundProbeJob = + scope.launch { + val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess + if (alive) return@launch + if (pendingReply() && !deferredOnce) { + delay(3_000L) + probeLivenessOnWake(deferredOnce = true) + return@launch + } + log("Foreground liveness probe failed — cancelling socket") + cancelSocket() + } + } + + fun cancelForegroundProbe() { + foregroundProbeJob?.cancel() + foregroundProbeJob = null + } + + fun probeLivenessOnTransportChange( + timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS, + onFailureAction: () -> Unit = cancelSocket, + ) { + if (!isConnected()) return + transportProbeJob?.cancel() + transportProbeJob = + scope.launch { + debugLog("Network transport changed — probing WebSocket liveness") + val alive = runCatching { ping(timeoutMs) }.isSuccess + if (alive) { + debugLog("WebSocket liveness probe succeeded on new transport") + return@launch + } + log("Transport change liveness probe failed — cancelling socket") + onFailureAction() + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt index a6535881..70816e73 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt @@ -3,6 +3,12 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ConnectorConnectResult import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.model.ConnectorListResult +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException /** @@ -42,19 +48,7 @@ interface ConnectorRepository { * Default implementation of [ConnectorRepository] backed by [HermesWsClient]. */ class HermesConnectorRepository( - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } finally { - if (!deferred.isCompleted) { - deferred.cancel() - } - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : ConnectorRepository { override suspend fun listConnectors(sessionId: String): ConnectorListResult { if (sessionId.isBlank()) { @@ -64,8 +58,8 @@ class HermesConnectorRepository( } return try { - val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId)) - val result = rpcRequest(WsMethods.CONNECTORS_LIST, params) + val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId)) + val result = caller.call(RpcMethods.CONNECTORS_LIST, params) ConnectorParser.parseListResult(result) } catch (e: CancellationException) { throw e @@ -107,12 +101,12 @@ class HermesConnectorRepository( return try { val params = - mapOf( - "owner" to ConnectorRepository.sessionOwner(sessionId), - "connectors" to connectors, - "reconnect" to reconnect, + ConnectorsConnectParams( + owner = ConnectorOwner.session(sessionId), + connectors = connectors, + reconnect = reconnect, ) - val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params) + val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params) ConnectorParser.parseConnectResult(result) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 99639aa4..f4d56a93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import android.util.Log +import com.m57.hermescontrol.data.model.parseMessageReactions import com.m57.hermescontrol.ui.chat.extractTodosFromMap import kotlinx.serialization.json.JsonObject @@ -333,6 +334,20 @@ object EventParser { WsEvent.ReactionEvent(kind) } + "message.reaction" -> { + val rowId = (payload?.get("row_id") as? Number)?.toLong() + if (rowId == null) { + WsEvent.Unknown(rawJson) + } else { + WsEvent.MessageReactionUpdated( + rowId = rowId, + reactions = parseMessageReactions(payload["reactions"]), + role = payload["role"] as? String ?: "", + sessionId = sessionId, + ) + } + } + "approval.request" -> { val command = payload?.get("command") as? String val description = payload?.get("description") as? String 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 1098e7be..634af15e 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 @@ -2,24 +2,17 @@ package com.m57.hermescontrol.data.ws import android.util.Log import androidx.annotation.VisibleForTesting -import com.m57.hermescontrol.BuildConfig -import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.remote.CookieManager -import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher -import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider -import com.m57.hermescontrol.data.remote.await import com.m57.hermescontrol.data.session.ActiveSessionHolder -import kotlinx.coroutines.CancellationException +import com.m57.hermescontrol.data.ws.contract.ClientCapabilitiesParams +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.RpcMethods import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -28,29 +21,14 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import okio.utf8Size -import java.util.Collections -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger -/** - * Connection status for the WebSocket client. - */ +/** Connection status for the WebSocket client. */ enum class ConnectionStatus { DISCONNECTED, CONNECTING, @@ -62,205 +40,16 @@ enum class ConnectionStatus { /** * WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface. - * - * Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with - * exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow - * as well as direct callbacks. + * Facade coordinating transport, RPC correlation, replay tracking, and connection health. */ object HermesWsClient { private const val TAG = "HermesWsClient" - // ── Monotonic time helper (immune to NTP and wall-clock jumps) ───────── private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L - // ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─ - - private var initialBackoffMs = 1_000L - private const val BACKOFF_BASE_MS = 300L - private const val BACKOFF_CAP_MS = 15_000L - private const val MAX_BACKOFF_MS = 30_000L - private const val BACKOFF_MULTIPLIER = 2.0 - private var testBackoffOverrideMs: Long? = null - - /** - * Test hook: the initial reconnect backoff, overridable so reconnect - * tests are deterministic instead of racing real time (CI runners - * routinely exceed fixed latch windows). Production keeps full-jitter backoff. - */ - @VisibleForTesting - internal fun setReconnectBackoffForTest(initialMillis: Long) { - initialBackoffMs = initialMillis - currentBackoff = initialMillis - testBackoffOverrideMs = initialMillis - } - - private fun nextBackoffDelayMs(): Long { - val override = testBackoffOverrideMs - if (override != null) return override - val ceiling = currentBackoff - if (ceiling <= 0L) return 0L - return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling) - } - - private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024 - private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L - - /** - * Liveness thresholds aligned with reference clients (desktop/web): - * Ping every 15s using gateway.ping; 30s inbound silence deadline (issue #1165) - * as mobile secondary safety net for faster half-open socket recovery. - */ - private const val HEARTBEAT_INTERVAL_MS = 15_000L - private const val STALE_THRESHOLD_MS = 30_000L - private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L - - // ── Internal state (all access through synchronized / atomic) ──────── - - private val requestId = AtomicInteger(0) - private val capabilityRequestIds = ConcurrentHashMap.newKeySet() - private val connectionGeneration = AtomicInteger(0) - private val connected = AtomicBoolean(false) - private val intentionalClose = AtomicBoolean(false) - private val acceptQueuedMessages = AtomicBoolean(true) - private val appInForeground = AtomicBoolean(true) - private val externalActivityConnectionLease = AtomicBoolean(false) - private val backgroundConnectionLease = AtomicBoolean(false) - private val messageQueue = ConcurrentLinkedQueue() - private val queuedMessagesById = ConcurrentHashMap() private val outboundLock = Any() - - @Volatile - private var webSocket: WebSocket? = null - - @Volatile - private var closingSocket: WebSocket? = null - - @Volatile - private var currentBackoff = initialBackoffMs - private val wsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /** - * Started lazily on the first [connect] instead of in [init]: the - * reconnect-on-network-change subscription only matters once a socket - * has actually been opened. init-time subscription also leaks a live - * real-IO collector into the unit-test JVM (class load), where - * NetworkMonitor emissions crashed it after unmockkAll. - */ - private val networkCollectorStarted = AtomicBoolean(false) - - @Volatile - private var reconnectJob: Job? = null - - @Volatile - private var outboundDrainJob: Job? = null - - // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── - private val lastSeenSeq = ConcurrentHashMap() - - @Volatile - private var replayEpoch: String? = null - - @Volatile - private var replayInFlight: Boolean = false - private val replayHold = ConcurrentHashMap>>() - - @Volatile - private var replayJob: Job? = null - - // ── Health and Ping/Pong tracking ──────────────────────────────────── - - @Volatile - var lastPongTimestamp: Long = 0L - private set - - val isHealthy: Boolean - get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS) - - // ── Observable latency tracking (issue #1017) ───────────────────────── - private val _lastLatencyMs = MutableStateFlow(null) - - /** Last measured round-trip latency in milliseconds via [ping], or null when disconnected / unmeasured. */ - val lastLatencyMs: StateFlow = _lastLatencyMs.asStateFlow() - - private var healthJob: Job? = null - - private fun startHealthTracking() { - healthJob?.cancel() - lastPongTimestamp = monotonicTimeMs() - healthJob = - wsScope.launch { - while (connected.get()) { - delay(HEARTBEAT_INTERVAL_MS) - if (connected.get()) { - try { - ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS) - } catch (e: Exception) { - Log.w(TAG, "Health check ping failed: ${e.message}") - } - } - if (!runHealthCheckPass()) break - } - } - } - - /** - * Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017). - * Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read - * loop (ws.py:350) and never queues behind worker dispatch pools. - * Updates [lastLatencyMs] and [lastPongTimestamp]. - */ - suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long { - val start = monotonicTimeMs() - request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await() - val latency = (monotonicTimeMs() - start).coerceAtLeast(0L) - lastPongTimestamp = monotonicTimeMs() - _lastLatencyMs.value = latency - return latency - } - - @VisibleForTesting - internal fun setLastLatencyForTest(latency: Long?) { - _lastLatencyMs.value = latency - } - - /** - * One liveness pass. Returns false when the tracking loop should stop: - * either the connection is already gone or the watchdog fired a cancel. - * - * Cancelling (not close()) forces an immediate onFailure, which rides the - * existing teardown path: generation bump, RECONNECTING, scheduleReconnect. - */ - private fun runHealthCheckPass(): Boolean { - if (!connected.get()) return false - val staleMs = monotonicTimeMs() - lastPongTimestamp - if (staleMs <= STALE_THRESHOLD_MS) return true - Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect") - synchronized(outboundLock) { - if (!connected.get()) return false - webSocket?.cancel() - } - return false - } - - /** - * Test hook: backdate liveness past the staleness threshold and force one - * synchronous watchdog pass, so reconnect tests are deterministic instead - * of racing the real 15s/45s cadence. - */ - @VisibleForTesting - internal fun forceHealthCheckForTest(staleMillis: Long) { - lastPongTimestamp = monotonicTimeMs() - staleMillis - runHealthCheckPass() - } - - private fun stopHealthTracking() { - healthJob?.cancel() - healthJob = null - _lastLatencyMs.value = null - } - - // ── Public observable stream ───────────────────────────────────────── + private val reconnectPolicy = ReconnectPolicy() private val parsedEvents = MutableSharedFlow( @@ -268,34 +57,79 @@ object HermesWsClient { onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - /** Collect this from ViewModels to receive all parsed [WsEvent]s. */ val events: SharedFlow = parsedEvents.asSharedFlow() - // ── Connection status flow ────────────────────────────────────────── - private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + private val connectionHealth = + ConnectionHealth( + scope = wsScope, + isConnected = { transport.isConnected }, + pingRequest = { timeoutMs -> + request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await() + Unit + }, + cancelSocket = { transport.cancelSocket() }, + pendingReply = { transport.pendingReply }, + nowMs = ::monotonicTimeMs, + log = { Log.w(TAG, it) }, + debugLog = { Log.d(TAG, it) }, + ) - /** Observable connection status */ - val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + private val replayTracker = + ReplayTracker( + scope = wsScope, + fetchEventsSince = { + call(RpcMethods.SESSION_EVENTS_SINCE, it, timeoutMs = 10_000L) + }, + emitEvent = { parsedEvents.tryEmit(it) }, + resolveStoredSessionId = { ActiveSessionHolder.resolveStoredSessionId(it) }, + onReplayFailure = { sid, e -> Log.w(TAG, "Replay failed for session $sid: ${e.message}") }, + ) - // ── Reply-pending tracking ───────────────────────────────────────── - // True while at least one agent turn is in flight. Drives the chat - // notification foreground service: it only runs while a reply is - // actually pending, instead of for the whole time the app is - // backgrounded (issue #794). - @Volatile - var pendingReply: Boolean = false + private val rpcChannel: RpcChannel = + RpcChannel( + scope = wsScope, + json = OkHttpProvider.json, + sendRequest = { method, params, onSent -> send(method, params, onSent) }, + sendFrame = { frameJson -> transport.sendDirectFrame(frameJson) }, + removeQueuedMessage = { transport.removeQueuedMessage(it) }, + onIdleCheck = { transport.disconnectIfIdleInBackground() }, + logRejected = { method, id -> Log.w(TAG, "Rejecting pending request on disconnect: $method (id=$id)") }, + outboundLock = outboundLock, + onResult = { id -> transport.onRpcResult(id) }, + onError = { id -> transport.onRpcError(id) }, + resolveStoredSessionId = { ActiveSessionHolder.resolveStoredSessionId(it) }, + logParseFailure = { e -> Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") }, + decorateParams = { method, params -> WsProfileParams.decorate(method, params) }, + ) + + private val transport: WsTransport = + WsTransport( + scope = wsScope, + outboundLock = outboundLock, + reconnectPolicy = reconnectPolicy, + connectionHealth = connectionHealth, + replayTracker = replayTracker, + rpcChannel = rpcChannel, + onRawMessage = { rawFrame -> + rpcChannel.handleIncomingFrame(rawFrame, replayTracker) { event -> parsedEvents.tryEmit(event) } + }, + ) + + val connectionStatus: StateFlow = transport.connectionStatus + + var pendingReply: Boolean by transport::pendingReply private set - private val pendingPromptSubmits = ConcurrentHashMap.newKeySet() + var lastPongTimestamp: Long by connectionHealth::lastPongTimestamp + private set + + val isHealthy: Boolean get() = connectionHealth.isHealthy + val lastLatencyMs: StateFlow = connectionHealth.lastLatencyMs + + suspend fun ping(timeoutMs: Long = ConnectionHealth.LIVENESS_PROBE_TIMEOUT_MS): Long = + connectionHealth.ping(timeoutMs) - // ── Credential warning (issue #534) ───────────────────────────────── - // Backend surfaces `credential_warning` in `gateway.ready` / `session.info` - // WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent - // at the auth layer, so we extract it here once, globally, and let any - // screen render a banner that deep-links to ProvidersScreen. private val _credentialWarning = MutableStateFlow(null) - - /** Non-null when the backend reports a credential warning to resolve. */ val credentialWarning: StateFlow = _credentialWarning.asStateFlow() fun clearCredentialWarning() { @@ -303,7 +137,9 @@ object HermesWsClient { } init { - // Extract credential_warning from gateway.ready / session.info payloads. + wsScope.launch { + parsedEvents.collect { transport.observeEvent(it) } + } wsScope.launch { events.collect { event -> val data: Map? = @@ -313,1263 +149,238 @@ object HermesWsClient { else -> null } val warning = data?.get("credential_warning") as? String - if (!warning.isNullOrBlank()) { - _credentialWarning.value = warning - } + if (!warning.isNullOrBlank()) _credentialWarning.value = warning } } - // Track whether a reply is actually in flight so the chat notification - // foreground service only runs while the user is waiting for one - // (issue #794), instead of for the whole time the app is backgrounded. - // Also covers turns started from other devices on the same session. wsScope.launch { - events.collect { event -> - when (event) { - is WsEvent.MessageStart, - is WsEvent.MessageToken, - is WsEvent.ThinkingDelta, - is WsEvent.ReasoningDelta, - is WsEvent.ToolStart, - -> { - pendingReply = true - } - - is WsEvent.MessageComplete -> { - pendingReply = false - disconnectIfIdleInBackground() - } - - else -> {} - } - } + events.filterIsInstance().collect { ChangeEventHub.emit(it) } } - // Forward parsed change events (issue #784) to the hub so screens can - // refresh on change without touching this singleton — its real static - // init must never run inside unit tests (see ChangeEventHub). - wsScope.launch { - events - .filterIsInstance() - .collect { ChangeEventHub.emit(it) } - } - // Track gateway replay_epoch across gateway.ready events (issue #1016) wsScope.launch { events.collect { event -> if (event is WsEvent.GatewayReady) { - // Advertise support for gateway server→client requests (issue #1197). - // Older gateways may reject this method; that is harmless. runCatching { send( - WsMethods.CLIENT_CAPABILITIES, - mapOf("server_requests" to true), - onSent = { id -> capabilityRequestIds.add(id) }, + RpcMethods.CLIENT_CAPABILITIES.name, + rpcChannel.encodeParams( + RpcMethods.CLIENT_CAPABILITIES, + ClientCapabilitiesParams(serverRequests = true), + ), + onSent = { id -> rpcChannel.registerCapabilityRequest(id) }, queueIfDisconnected = false, ) } val epoch = event.data?.get("replay_epoch") as? String - if (!epoch.isNullOrEmpty()) { - if (replayEpoch != null && replayEpoch != epoch) { - lastSeenSeq.clear() - } - replayEpoch = epoch - } + replayTracker.updateEpoch(epoch) } } } } - // ── Connection helpers ──────────────────────────────────────────────── - - @VisibleForTesting - val isConnected: Boolean get() = connected.get() - - private var foregroundProbeJob: Job? = null - private var transportProbeJob: Job? = null - - fun setAppForeground(foreground: Boolean) { - appInForeground.set(foreground) - if (!foreground) { - disconnectIfIdleInBackground() - return - } - probeLivenessOnWake() - } - - private fun probeLivenessOnWake(deferredOnce: Boolean = false) { - if (!connected.get()) return - foregroundProbeJob?.cancel() - foregroundProbeJob = - wsScope.launch { - val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess - if (alive) return@launch - if (pendingReply && !deferredOnce) { - delay(3_000L) - probeLivenessOnWake(deferredOnce = true) - return@launch - } - Log.w(TAG, "Foreground liveness probe failed — cancelling socket") - synchronized(outboundLock) { - if (connected.get()) webSocket?.cancel() - } - } - } - @VisibleForTesting - internal fun probeLivenessOnTransportChange( - onFailureAction: () -> Unit = { - synchronized(outboundLock) { - if (connected.get()) webSocket?.cancel() - } - }, - ) { - if (!connected.get()) return - transportProbeJob?.cancel() - transportProbeJob = - wsScope.launch { - Log.d(TAG, "Network transport changed — probing WebSocket liveness") - val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess - if (alive) { - Log.d(TAG, "WebSocket liveness probe succeeded on new transport") - return@launch - } - Log.w(TAG, "Transport change liveness probe failed — cancelling socket") - onFailureAction() - } - } + val isConnected: Boolean get() = transport.isConnected - fun acquireExternalActivityConnectionLease() { - externalActivityConnectionLease.set(true) - } + fun setAppForeground(foreground: Boolean) = transport.setAppForeground(foreground) - fun releaseExternalActivityConnectionLease() { - externalActivityConnectionLease.set(false) - disconnectIfIdleInBackground() - } + fun acquireExternalActivityConnectionLease() = transport.acquireExternalActivityConnectionLease() - fun acquireBackgroundConnectionLease() { - backgroundConnectionLease.set(true) - } + fun releaseExternalActivityConnectionLease() = transport.releaseExternalActivityConnectionLease() - fun releaseBackgroundConnectionLease() { - backgroundConnectionLease.set(false) - disconnectIfIdleInBackground() - } + fun acquireBackgroundConnectionLease() = transport.acquireBackgroundConnectionLease() + + fun releaseBackgroundConnectionLease() = transport.releaseBackgroundConnectionLease() @VisibleForTesting - internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get() + internal fun hasBackgroundConnectionLease(): Boolean = transport.hasBackgroundConnectionLease() - private fun disconnectIfIdleInBackground() { - synchronized(outboundLock) { - if (!appInForeground.get() && - !externalActivityConnectionLease.get() && - !backgroundConnectionLease.get() && - !pendingReply && - pendingCalls.isEmpty() && - messageQueue.isEmpty() - ) { - disconnect() - } - } - } + private fun openSocket() = transport.openSocket() - /** Open a WebSocket connection using settings from [AuthManager]. */ - fun connect() { - if (networkCollectorStarted.compareAndSet(false, true)) { - wsScope.launch { - NetworkMonitor.networkChanges.collect { networkAvailable -> - reconnectForNetworkChange(networkAvailable) - } - } - wsScope.launch { - NetworkMonitor.transportChanges.collect { - probeLivenessOnTransportChange() - } - } - } - var socketToCancel: WebSocket? = null - val shouldOpen = - synchronized(outboundLock) { - acceptQueuedMessages.set(true) - if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { - connectionGeneration.incrementAndGet() - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - socketToCancel = webSocket - webSocket = null - closingSocket = null - connected.set(false) - stopHealthTracking() - } - if (connected.get()) { - Log.d(TAG, "Already connected — skipping") - return@synchronized false - } - if (_connectionStatus.value == ConnectionStatus.CONNECTING || - _connectionStatus.value == ConnectionStatus.RECONNECTING - ) { - Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping") - return@synchronized false - } - // An explicit connect follows successful authentication and is - // therefore allowed to leave the terminal AUTH_EXPIRED state. - intentionalClose.set(false) - _connectionStatus.value = ConnectionStatus.CONNECTING - currentBackoff = initialBackoffMs - true - } - if (socketToCancel != null) { - rejectAllPending() - socketToCancel.cancel() - } - if (shouldOpen) openSocket() - } + fun connect() = transport.connect() - @VisibleForTesting - internal fun reconnectForNetworkChange( - networkAvailable: Boolean = NetworkMonitor.isConnected.value, + fun reconnectForNetworkChange( + networkAvailable: Boolean = com.m57.hermescontrol.data.remote.NetworkMonitor.isConnected.value, openReplacement: () -> Unit = ::openSocket, - ) { - val socketToCancel: WebSocket? - val shouldOpen: Boolean - synchronized(outboundLock) { - if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { - return - } - Log.d(TAG, "Default network changed — reconnecting WebSocket") - currentBackoff = initialBackoffMs - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - connectionGeneration.incrementAndGet() - socketToCancel = webSocket - webSocket = null - closingSocket = null - connected.set(false) - stopHealthTracking() - shouldOpen = networkAvailable && AuthManager.isAutoReconnect() - _connectionStatus.value = - when { - !networkAvailable -> ConnectionStatus.NO_NETWORK - shouldOpen -> ConnectionStatus.RECONNECTING - else -> ConnectionStatus.DISCONNECTED - } - rejectAllPending() - } - socketToCancel?.cancel() - if (shouldOpen) openReplacement() - } + ) = transport.reconnectForNetworkChange(networkAvailable, openReplacement) - /** - * If a session cookie is present (gated mode), mint a fresh WS ticket - * from the dashboard. The ticket is single-use and has a 30-second TTL, - * so we must mint a new one on every connect (first launch and reconnect). - * - * The session cookie is attached automatically by the shared CookieJar on - * OkHttpProvider.probe (issue #470), so we no longer inject it manually. - * - * Returns true if the token is ready (either because we are not in gated mode, - * or because ticket refresh succeeded). Returns false if we are in gated mode - * and ticket refresh failed or cannot be performed. - */ - internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean { - val isGated = - try { - AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" - } catch (_: IllegalStateException) { - // serverStore not initialized yet (transient early call); treat - // as loopback so a stale-token refresh still runs. Log so a real - // misconfiguration isn't silently swallowed. - Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated") - false - } - if (!isGated) { - // The loopback dashboard token is regenerated on every server - // restart. Refresh it before each WebSocket handshake so automatic - // reconnect does not get stuck in AUTH_EXPIRED with a stale token. - val token = - runCatching { - DashboardSessionTokenRefresher.refreshAsync() - }.getOrNull() - synchronized(outboundLock) { - if (isCurrentGeneration(generation) && token != null) { - runCatching { AuthManager.setToken(token) } - } - } - return true - } + internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean = + transport.refreshWsTicketIfNeeded(generation) - val ticketResult = requestWsTicket() - if (!ticketResult.ticket.isNullOrBlank()) { - synchronized(outboundLock) { - if (!isCurrentGeneration(generation)) return false - AuthManager.setToken(ticketResult.ticket) - } - if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") - return true - } - val status = - when { - ticketResult.exception -> ConnectionStatus.RECONNECTING - ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED - ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING - ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING - else -> ConnectionStatus.DISCONNECTED - } - return handleWsTicketRefreshFailure(status, generation) - } + internal suspend fun mintWsTicket(): String? = transport.mintWsTicket() - /** - * Mint a fresh WS ticket for a *secondary* consumer (e.g. the kanban - * events stream) WITHOUT touching the shared token slot. - * - * The shared slot is a single-use ticket that the chat WebSocket consumes - * at handshake. If two clients mint and stash into the same slot on their - * own schedules, each can grab the other's just-consumed ticket and the - * pair starves each other into a permanent reconnect loop. Secondary - * consumers must use their own ticket per connection. - * - * Gated mode: cookie-auth'd POST to /api/auth/ws-ticket, returns the - * ticket. Loopback mode: refresh the dashboard token and return it. - * Returns null when a ticket could not be obtained. - */ - internal suspend fun mintWsTicket(): String? { - val isGated = - try { - AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" - } catch (_: IllegalStateException) { - false - } - if (!isGated) { - DashboardSessionTokenRefresher.refreshAsync() - return AuthManager.getToken() - } - return requestWsTicket().ticket - } + fun disconnect(clearPendingMessages: Boolean = false) = transport.disconnect(clearPendingMessages) - private data class TicketRequestResult( - val ticket: String?, - val httpCode: Int?, - val exception: Boolean = false, - ) - - /** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */ - private suspend fun requestWsTicket(): TicketRequestResult { - try { - val client = OkHttpProvider.probe - val request = - Request - .Builder() - .url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString()) - .post("{}".toRequestBody()) - .build() - - val (code, body) = - withContext(Dispatchers.IO) { - if (CookieManager.isInitialized()) { - CookieManager.useStore(CookieManager.cookieJar.currentServer()) - } - client.newCall(request).await().use { resp -> - resp.code to resp.body.string() - } - } - - if (code in 200..299) { - // Real JSON decode — the ticket is base64url and may contain - // characters a naive [^"]+ regex cannot extract (see - // testGatedMode_parsesEscapedTicketFromRealJsonShape). - val parsed = - runCatching { - OkHttpProvider.json.decodeFromString(body) - }.getOrNull() - val ticket = parsed?.ticket - if (!ticket.isNullOrBlank()) { - return TicketRequestResult(ticket, null) - } - Log.w(TAG, "WS ticket mint failed: unparseable ticket response") - } else { - Log.w(TAG, "WS ticket mint failed: HTTP $code") - return TicketRequestResult(null, code) - } - } catch (e: Exception) { - // Include the stack: the exception class alone (e.g. - // NetworkOnMainThreadException) can't show WHICH caller on what - // thread tripped the mint (main-scope kanban events connect vs - // the WS reconnect path). - Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e) - return TicketRequestResult(null, null, exception = true) - } - return TicketRequestResult(null, null) - } - - private fun handleWsTicketRefreshFailure( - status: ConnectionStatus, - generation: Int, - ): Boolean { - synchronized(outboundLock) { - if (!isCurrentGeneration(generation)) return false - _connectionStatus.value = status - if (status == ConnectionStatus.RECONNECTING) scheduleReconnect() - } - return false - } - - /** Cleanly close the WebSocket and stop auto-reconnect. */ - fun disconnect(clearPendingMessages: Boolean = false) { - ActiveSessionHolder.clear() - synchronized(outboundLock) { - intentionalClose.set(true) - acceptQueuedMessages.set(!clearPendingMessages) - connectionGeneration.incrementAndGet() - reconnectJob?.cancel() - reconnectJob = null - outboundDrainJob?.cancel() - outboundDrainJob = null - foregroundProbeJob?.cancel() - foregroundProbeJob = null - stopHealthTracking() - webSocket?.close(1000, "Client closed") - webSocket = null - closingSocket = null - capabilityRequestIds.clear() - if (clearPendingMessages) { - backgroundConnectionLease.set(false) - messageQueue.clear() - queuedMessagesById.clear() - pendingPromptSubmits.clear() - pendingReply = false - lastSeenSeq.clear() - replayHold.clear() - replayEpoch = null - replayJob?.cancel() - replayJob = null - replayInFlight = false - } - connected.set(false) - _connectionStatus.value = ConnectionStatus.DISCONNECTED - } - } - - // ── Awaited RPC request layer (issue #526) ───────────────────────── - // Mirrors desktop apps/shared JsonRpcGatewayClient.request(): an in-flight - // map with a per-call timeout and rejectAllPending on socket close, so a - // dropped RPC response can't leave a caller awaiting forever. - - /** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */ class HermesRpcException( message: String, val code: Int = 0, val data: JsonElement? = null, ) : Exception(message) - /** - * Default per-request timeout. Matches the desktop - * `apps/shared` `JsonRpcGatewayClient.DEFAULT_REQUEST_TIMEOUT_MS` (120s), - * so legitimately long agent turns are not pruned early. - */ const val REQUEST_TIMEOUT_MS: Long = 120_000L - /** A single in-flight [request] awaiting its RPC result/error. */ - private data class PendingCall( - val method: String, - val deferred: CompletableDeferred, - var timeoutJob: Job? = null, - val suppressErrorEvent: Boolean = false, - ) - - /** Tracks in-flight [request] calls by their JSON-RPC id. */ - private val pendingCalls = ConcurrentHashMap() - - /** - * Send a JSON-RPC request that expects a result and returns a - * [CompletableDeferred] for it — mirroring the desktop - * `JsonRpcGatewayClient.request()`. Fire-and-forget notifications should - * keep using [send]. - * - * The deferred is completed on the matching [WsEvent.RpcResult] / - * [WsEvent.RpcError], rejected after [timeoutMs] (no response), or - * rejected by [rejectAllPending] when the socket closes. - */ - fun request( + internal fun request( 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, suppressErrorEvent = suppressErrorEvent) - } - deferred.invokeOnCompletion { cause -> - if (cause is CancellationException) { - pendingCalls.remove(id)?.let { call -> - call.timeoutJob?.cancel() - removeQueuedMessage(id) - disconnectIfIdleInBackground() - } - } - } - // Arm the per-request timeout (fires if the server never answers). - pendingCalls[id]?.timeoutJob = - wsScope.launch { - delay(timeoutMs) - resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method")) - } - return deferred - } + ): CompletableDeferred = rpcChannel.request(method, params, timeoutMs, suppressErrorEvent) - /** Complete (or fail) a single pending call and cancel its timer. */ - private fun resolvePending( - id: String, - result: Any?, - error: JsonRpcError?, - ) { - val call = pendingCalls.remove(id) ?: return - removeQueuedMessage(id) - call.timeoutJob?.cancel() - if (error != null) { - call.deferred.completeExceptionally( - HermesRpcException( - message = error.message, - code = error.code, - data = error.data, - ), - ) - } else { - call.deferred.complete(result) - } - disconnectIfIdleInBackground() - } + fun requestTyped( + method: RpcMethod, + params: P, + timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred = + request(method.name, rpcChannel.encodeParams(method, params), timeoutMs, suppressErrorEvent) + + suspend fun call( + method: RpcMethod, + params: P, + timeoutMs: Long = REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): R = rpcChannel.awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent)) - /** - * Fail and clear every in-flight [request]. Called on disconnect / - * reconnect so callers awaiting a result don't hang across a socket - * close — mirrors desktop `JsonRpcGatewayClient.rejectAllPending(error)` - * invoked on socket close. - */ fun rejectAllPending( error: HermesRpcException = HermesRpcException("Connection lost — request cancelled"), - ) { - if (pendingCalls.isEmpty()) return - val snapshot = pendingCalls.toList() - pendingCalls.clear() - for ((id, call) in snapshot) { - removeQueuedMessage(id) - Log.w(TAG, "Rejecting pending request on disconnect: ${call.method} (id=$id)") - call.timeoutJob?.cancel() - call.deferred.completeExceptionally(error) - } - } + ) = rpcChannel.rejectAllPending(error) - /** - * Answer a gateway-originated JSON-RPC request. This deliberately bypasses - * [send]: server requests already have an id allocated by the gateway and - * responses must not contain a method or receive a new client id. - */ fun respondToServerRequest( id: String, result: JsonElement, - ): Boolean { - if (id.isBlank()) return false - val json = - buildJsonObject { - put("jsonrpc", "2.0") - put("id", id) - put("result", result) - }.toString() - synchronized(outboundLock) { - val ws = webSocket - if (ws == null || !connected.get()) return false - return ws.send(json) - } - } + ): Boolean = rpcChannel.respondToServerRequest(id, result) - /** Answer a gateway-originated request with a standard JSON-RPC error. */ fun respondToServerRequestError( id: String, code: Int, message: String, - ): Boolean { - if (id.isBlank()) return false - val json = - buildJsonObject { - put("jsonrpc", "2.0") - put("id", id) - put( - "error", - buildJsonObject { - put("code", code) - put("message", message) - }, - ) - }.toString() - synchronized(outboundLock) { - val ws = webSocket - if (ws == null || !connected.get()) return false - return ws.send(json) - } - } + ): Boolean = rpcChannel.respondToServerRequestError(id, code, message) - // ── Send helpers ───────────────────────────────────────────────────── - - /** - * Send a JSON-RPC request with the given [method] and optional [params]. - * @return the request id used (can be matched against [WsEvent.RpcResult]). - */ - fun send( + internal fun send( method: String, params: Map = emptyMap(), onSent: ((String) -> Unit)? = null, - ): String = send(method, params, onSent, true) + ): String = send(method, params, onSent, queueIfDisconnected = true) + + fun send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)? = null, + ): String = send(method.name, rpcChannel.encodeParams(method, params), onSent) private fun send( method: String, params: Map, onSent: ((String) -> Unit)?, queueIfDisconnected: Boolean, - ): String { - val id = requestId.incrementAndGet().toString() - val decoratedParams = WsProfileParams.decorate(method, params) - val request = - JsonRpcRequest( - id = id, - method = method, - params = decoratedParams.mapValues { it.value.toJsonElement() }, - ) - val json = OkHttpProvider.json.encodeToString(request) - if (BuildConfig.DEBUG) { - Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) - } - var reconnect = false - synchronized(outboundLock) { - if (method == WsMethods.PROMPT_SUBMIT) { - pendingPromptSubmits.add(id) - pendingReply = true - } - val ws = webSocket - if (ws != null && connected.get()) { - // Never replay send(true): the server may have executed it even if its response is lost. - if (ws.send(json)) { - onSent?.invoke(id) - } else { - if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized - if (queueIfDisconnected && isRetryableMessage(json)) { - onSent?.invoke(id) - Log.w(TAG, "WS rejected outgoing message — queuing for reconnect") - queueMessage(id, json) - recoverRejectedSocket(ws) - } else { - Log.w(TAG, "WS rejected oversized outgoing message — not retrying") - } - } - } else if (acceptQueuedMessages.get() && queueIfDisconnected) { - if (isRetryableMessage(json)) { - onSent?.invoke(id) - Log.d(TAG, "WS disconnected — queuing message") - queueMessage(id, json) - reconnect = true - } else { - Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing") - } - } - } - if (reconnect) connect() - return id - } + ): String = + transport.send( + rpcChannel.createRequest(method, params), + onSent = onSent, + queueIfDisconnected = queueIfDisconnected, + ) - private fun isRetryableMessage(json: String): Boolean { - if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false - if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true - return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong() - } - - private fun queueMessage( - id: String, - json: String, - ) { - if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json) - } - - private fun removeQueuedMessage(id: String) { - synchronized(outboundLock) { - queuedMessagesById.remove(id)?.let(messageQueue::remove) - } - } - - private fun markQueuedMessageSent(json: String) { - queuedMessagesById.entries.removeIf { it.value == json } - } - - private fun recoverRejectedSocket(ws: WebSocket) { - connected.set(false) - if (closingSocket === ws) return - _connectionStatus.value = ConnectionStatus.RECONNECTING - if (ws.queueSize() == 0L) { - ws.cancel() - scheduleReconnect() - return - } - if (intentionalClose.get()) return - outboundDrainJob?.cancel() - outboundDrainJob = - wsScope.launch { - delay(OUTBOUND_DRAIN_TIMEOUT_MS) - synchronized(outboundLock) { - if (!connected.get() && !intentionalClose.get() && webSocket === ws) { - ws.cancel() - outboundDrainJob = null - scheduleReconnect() - } - } - } - } - - /** Convenience: submit a user prompt to an existing session. */ fun sendMessage( sessionId: String, text: String, onSent: ((String) -> Unit)? = null, queued: Boolean = false, - ): String { - val params = - buildMap { - put("session_id", sessionId) - put("text", text) - // Explicit queue semantics: the gateway's busy-input policy - // forces "run after, never interrupt" when prompt.submit - // carries queued=true (hermes-agent methods_prompt.py - // _handle_busy_submit) — used by /queue. - if (queued) put("queued", true) - } - return send( - method = WsMethods.PROMPT_SUBMIT, - params = params, - onSent = onSent, - ) - } + ): String = transport.sendMessage(sessionId, text, onSent, queued) - /** - * Convenience: redirect the active model turn while it is still generating - * (backend `session.redirect`). Fire-and-forget — the backend either rewrites - * the live turn, queues the correction as the next turn, or rejects it (the - * caller's ViewModel handles the `4010` fall-back to [sendMessage]). - */ fun sendRedirect( sessionId: String, text: String, onSent: ((String) -> Unit)? = null, - ): String = - send( - method = WsMethods.SESSION_REDIRECT, - params = mapOf("session_id" to sessionId, "text" to text), - onSent = onSent, - ) - - // ── Sequence tracking & replay on reconnect (issue #1016) ───────────── - - private fun triggerReplay() { - // Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a - // live frame racing the replay coroutine is parked instead of advancing - // lastSeenSeq past events the replay would then skip-drop (G2). - if (lastSeenSeq.isEmpty() || replayInFlight) return - replayInFlight = true - val sessionsToReplay = lastSeenSeq.keys().toList() - for (sid in sessionsToReplay) { - replayHold[sid] = Collections.synchronizedList(mutableListOf()) - } - replayJob?.cancel() - replayJob = - wsScope.launch { - fetchReplay(sessionsToReplay) - } - } - - private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) { - try { - for (sid in sessionsToReplay) { - val lastSeen = lastSeenSeq[sid] ?: continue - try { - val deferred = - request( - method = WsMethods.SESSION_EVENTS_SINCE, - params = mapOf("session_id" to sid, "last_seen" to lastSeen), - timeoutMs = 10_000L, - ) - val result = deferred.await() - - @Suppress("UNCHECKED_CAST") - val resultMap = - when (result) { - is JsonElement -> result.toAny() as? Map - is Map<*, *> -> result as? Map - else -> null - } - if (resultMap != null) { - val epoch = resultMap["epoch"] as? String - val epochChanged = - !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch - val truncated = resultMap["truncated"] == true - val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt() - - if (epochChanged) replayEpoch = epoch - - if (epochChanged || truncated) { - // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). - // Partial replay would silently hole the transcript — fast-forward watermark - // and request full transcript resync. - if (epochChanged) lastSeenSeq.clear() - if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - continue - } - val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance>() - if (eventsList != null) { - for (eventMap in eventsList) { - val eventSeq = (eventMap["seq"] as? Number)?.toInt() - val eventSid = (eventMap["session_id"] as? String) ?: sid - if (eventSeq != null && eventSid.isNotEmpty()) { - val prev = lastSeenSeq[eventSid] ?: 0 - if (eventSeq <= prev) continue - lastSeenSeq[eventSid] = eventSeq - } - val parsedEvent = EventParser.parseParams(eventMap) - val finalEvent = - if (parsedEvent is WsEvent.MessageComplete) { - parsedEvent.copy( - storedSessionId = - ActiveSessionHolder.resolveStoredSessionId( - parsedEvent.sessionId, - ), - ) - } else { - parsedEvent - } - parsedEvents.tryEmit(finalEvent) - } - } - } - } catch (e: Exception) { - Log.w(TAG, "Replay failed for session $sid: ${e.message}") - parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid)) - } - } - } finally { - withContext(NonCancellable) { - for (sid in sessionsToReplay) { - val held = replayHold.remove(sid) - if (held != null) { - synchronized(held) { - for ((heldSeq, heldEvent) in held) { - if (heldSeq != null) { - val prev = lastSeenSeq[sid] ?: 0 - if (heldSeq <= prev) continue - lastSeenSeq[sid] = heldSeq - } - parsedEvents.tryEmit(heldEvent) - } - } - } - } - replayInFlight = false - } - } - } + ): String = transport.sendRedirect(sessionId, text, onSent) @VisibleForTesting - internal fun getSeqWatermarks(): Map = HashMap(lastSeenSeq) + internal fun getSeqWatermarks(): Map = replayTracker.getSeqWatermarks() @VisibleForTesting internal fun setSeqWatermark( sessionId: String, seq: Int, - ) { - lastSeenSeq[sessionId] = seq - } + ) = replayTracker.setSeqWatermark(sessionId, seq) @VisibleForTesting - internal fun clearSeqWatermarks() { - lastSeenSeq.clear() - replayHold.clear() - replayEpoch = null - replayInFlight = false - } + internal fun clearSeqWatermarks() = replayTracker.clearSeqWatermarks() @VisibleForTesting - internal fun isReplayInFlight(): Boolean = replayInFlight + internal fun isReplayInFlight(): Boolean = replayTracker.isReplayInFlight() @VisibleForTesting - internal fun setReplayEpochForTest(epoch: String?) { - replayEpoch = epoch - } + internal fun setReplayEpochForTest(epoch: String?) = replayTracker.setReplayEpochForTest(epoch) @VisibleForTesting - internal suspend fun fetchReplayForTest() { - fetchReplay() - } - - // ── Internal ───────────────────────────────────────────────────────── - - private fun openSocket() { - val generation = - synchronized(outboundLock) { - if (intentionalClose.get()) return - connectionGeneration.incrementAndGet() - } - wsScope.launch(Dispatchers.IO) { - if (!refreshWsTicketIfNeeded(generation)) { - Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") - return@launch - } - if (!isCurrentGeneration(generation)) return@launch - val url = AuthManager.wsUrl() - val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") - if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") - - val request = Request.Builder().url(url).build() - val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) - synchronized(outboundLock) { - if (connectionGeneration.get() == generation && !intentionalClose.get()) { - webSocket = newSocket - } else { - newSocket.cancel() - } - } - } - } - - private fun isCurrentGeneration(generation: Int): Boolean = - connectionGeneration.get() == generation && !intentionalClose.get() - - private fun scheduleReconnect() { - synchronized(outboundLock) { - if (intentionalClose.get() || - _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED || - reconnectJob?.isActive == true - ) { - return - } - if (!AuthManager.isAutoReconnect()) { - if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") - _connectionStatus.value = ConnectionStatus.DISCONNECTED - return - } - if (!NetworkMonitor.isConnected.value) { - Log.d(TAG, "No network available — delaying reconnect scheduling") - _connectionStatus.value = ConnectionStatus.NO_NETWORK - return - } - val reconnectDelay = nextBackoffDelayMs() - currentBackoff = - (currentBackoff * BACKOFF_MULTIPLIER) - .toLong() - .coerceAtMost(MAX_BACKOFF_MS) - if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …") - - reconnectJob = - wsScope.launch { - delay(reconnectDelay) - val owner = currentCoroutineContext()[Job] - val shouldOpen = - synchronized(outboundLock) { - if (reconnectJob !== owner) { - false - } else { - reconnectJob = null - !intentionalClose.get() && - !connected.get() && - _connectionStatus.value != ConnectionStatus.AUTH_EXPIRED - } - } - if (shouldOpen) openSocket() - } - } - } - - private fun isTerminalAuthClose( - code: Int, - reason: String, - ): Boolean = - code == 4401 || code == 4403 || - reason.contains("unauthorized", ignoreCase = true) || - reason.startsWith("auth:", ignoreCase = true) - - // ── Listener ───────────────────────────────────────────────────────── - - private class WsListenerImpl( - private val generation: Int, - ) : WebSocketListener() { - private fun isCurrent(): Boolean = isCurrentGeneration(generation) - - private fun consumeCapabilityResponse(event: WsEvent): Boolean { - val id = - when (event) { - is WsEvent.RpcResult -> event.id - is WsEvent.RpcError -> event.id - else -> return false - } - // send() registers capability request IDs via onSent while holding - // outboundLock. Take the same lock here so a very fast gateway - // response cannot be consumed before its ID has been registered. - val isCapabilityResponse = - synchronized(outboundLock) { - capabilityRequestIds.remove(id) - } - if (!isCapabilityResponse) return false - - removeQueuedMessage(id) - return true - } - - override fun onOpen( - webSocket: WebSocket, - response: Response, - ) { - synchronized(outboundLock) { - if (!isCurrent()) { - webSocket.close(1000, "Superseded") - return - } - HermesWsClient.webSocket = webSocket - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - Log.i(TAG, "WebSocket opened") - connected.set(true) - _connectionStatus.value = ConnectionStatus.CONNECTED - currentBackoff = initialBackoffMs - startHealthTracking() - - while (true) { - val msg = messageQueue.peek() ?: break - if (!isRetryableMessage(msg)) { - Log.w(TAG, "Dropping oversized queued message") - messageQueue.poll() - markQueuedMessageSent(msg) - continue - } - if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) - if (!webSocket.send(msg)) { - recoverRejectedSocket(webSocket) - break - } - messageQueue.poll() - markQueuedMessageSent(msg) - } - triggerReplay() - } - disconnectIfIdleInBackground() - } - - override fun onMessage( - webSocket: WebSocket, - text: String, - ) { - if (!isCurrent() || HermesWsClient.webSocket !== webSocket) return - if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) - lastPongTimestamp = monotonicTimeMs() - // Resolve any in-flight `request()` awaiting this RPC result/error - // (issue #526) before fanning the parsed event out to collectors. - val event = - try { - val rpc = OkHttpProvider.json.decodeFromString(text) - // Resolve pending request() calls with the RAW JsonElement from - // rpc.result, BEFORE EventParser.parse() converts it via toAny(). - // This preserves integer/float type fidelity needed by callers - // that re-deserialize the result into typed data classes. - val rpcId = rpc.id - if (rpcId != null && rpc.error == null && rpc.result != null) { - synchronized(outboundLock) { pendingPromptSubmits.remove(rpcId) } - removeQueuedMessage(rpcId) - resolvePending(rpcId, rpc.result, null) - } - - // Resume/replay responses carry still-open server requests - // separately from the event ring. Re-deliver them through the - // same generic path before exposing the RPC result. - @Suppress("UNCHECKED_CAST") - val openRequests = - (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> - openRequests?.forEach { openRequest -> - val openId = openRequest["id"] as? String ?: return@forEach - val method = openRequest["method"] as? String ?: return@forEach - - @Suppress("UNCHECKED_CAST") - val params = openRequest["params"] as? Map ?: emptyMap() - parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true)) - } - - if (rpc.id == null) { - // Issue #1163: inspect scalars, not a throwaway copy of the whole params tree. - val sid = rpc.params?.eventSessionId() - val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt() - - if (!sid.isNullOrBlank() && seq != null) { - val prev = lastSeenSeq[sid] ?: 0 - if (seq <= prev) { - // Frame is already at or behind watermark — drop duplicate - return - } - val parsed = EventParser.parse(rpc, text) - val finalEvent = - if (parsed is WsEvent.MessageComplete) { - parsed.copy( - storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId), - ) - } else { - parsed - } - if (replayInFlight && replayHold.containsKey(sid)) { - replayHold[sid]?.add(seq to finalEvent) - return - } - lastSeenSeq[sid] = seq - parsedEvents.tryEmit(finalEvent) - return - } - } - - val parsed = EventParser.parse(rpc, text) - if (parsed is WsEvent.MessageComplete) { - parsed.copy( - storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId), - ) - } else { - parsed - } - } catch (e: Exception) { - Log.e(TAG, "Failed to parse message: ${e.javaClass.simpleName}") - WsEvent.Unknown(text) - } - if (consumeCapabilityResponse(event)) return - when (event) { - is WsEvent.RpcResult -> { - synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) } - removeQueuedMessage(event.id) - resolvePending(event.id, event.result, null) - } - - is WsEvent.RpcError -> { - synchronized(outboundLock) { - if (pendingPromptSubmits.remove(event.id) && - pendingPromptSubmits.isEmpty() - ) { - pendingReply = false - 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) - } - - else -> {} - } - // tryEmit on a DROP_OLDEST flow only returns false when the - // buffer is full AND no subscriber is draining; with extraBuffer=2048 - // and always-on init collectors this is unreachable in practice. - parsedEvents.tryEmit(event) - } - - override fun onClosing( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - synchronized(outboundLock) { - if (!isCurrent()) { - webSocket.close(code, reason) - return - } - closingSocket = webSocket - // Do NOT log [reason] — it may carry server-side context. - Log.d(TAG, "WebSocket closing: $code") - if (isTerminalAuthClose(code, reason)) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } - webSocket.close(code, reason) - } - } - - override fun onClosed( - webSocket: WebSocket, - code: Int, - reason: String, - ) { - synchronized(outboundLock) { - if (!isCurrent()) return - connectionGeneration.incrementAndGet() - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null - // Do NOT log [reason] — it may carry server-side context. The - // reason is still inspected internally to detect auth failures. - Log.i(TAG, "WebSocket closed: $code") - connected.set(false) - ActiveSessionHolder.clear() - stopHealthTracking() - capabilityRequestIds.clear() - if (isTerminalAuthClose(code, reason)) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { - _connectionStatus.value = ConnectionStatus.RECONNECTING - scheduleReconnect() - } - } - } - - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response?, - ) { - synchronized(outboundLock) { - if (!isCurrent()) return - connectionGeneration.incrementAndGet() - closingSocket = null - outboundDrainJob?.cancel() - outboundDrainJob = null - if (HermesWsClient.webSocket === webSocket) HermesWsClient.webSocket = null - // Log the exception class only — [Throwable.message] can leak URLs - // or headers. The message is still inspected internally for auth - // detection. - Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) - connected.set(false) - ActiveSessionHolder.clear() - stopHealthTracking() - capabilityRequestIds.clear() - val code = response?.code ?: 0 - if (code == 401 || code == 4401 || code == 4403 || - t.message?.contains("401") == true || - t.message?.contains("4401") == true || - t.message?.contains("4403") == true || - t.message?.contains("unauthorized", ignoreCase = true) == true - ) { - _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED - } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { - _connectionStatus.value = ConnectionStatus.RECONNECTING - scheduleReconnect() - } - } - } - } + internal suspend fun fetchReplayForTest() = replayTracker.fetchReplayForTest() @VisibleForTesting - internal fun setConnectionStatusForTest(status: ConnectionStatus) { - _connectionStatus.value = status - } + internal fun probeLivenessOnTransportChange( + timeoutMs: Long = ConnectionHealth.LIVENESS_PROBE_TIMEOUT_MS, + onFailureAction: () -> Unit = { transport.cancelSocket() }, + ) = connectionHealth.probeLivenessOnTransportChange(timeoutMs, onFailureAction) + + @VisibleForTesting + internal fun setLastLatencyForTest(latency: Long?) = connectionHealth.setLastLatencyForTest(latency) + + @VisibleForTesting + internal fun forceHealthCheckForTest(staleMillis: Long) = connectionHealth.forceHealthCheckForTest(staleMillis) + + @VisibleForTesting + internal fun setReconnectBackoffForTest(initialMillis: Long) = transport.setReconnectBackoffForTest(initialMillis) + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) = transport.setConnectionStatusForTest(status) + + @VisibleForTesting + internal val connectedForTest: AtomicBoolean + get() = transport.connectedForTest + + @VisibleForTesting + internal val intentionalCloseForTest: AtomicBoolean + get() = transport.intentionalCloseForTest + + @VisibleForTesting + internal val connectionGenerationForTest: AtomicInteger + get() = transport.connectionGenerationForTest + + @VisibleForTesting + internal val messageQueueForTest: ConcurrentLinkedQueue + get() = transport.messageQueueForTest + + @VisibleForTesting + internal val outboundLockForTest: Any + get() = transport.outboundLockForTest + + @VisibleForTesting + internal val acceptQueuedMessagesForTest: AtomicBoolean + get() = transport.acceptQueuedMessagesForTest + + @VisibleForTesting + internal val connectionStatusForTest: MutableStateFlow + get() = transport.connectionStatusForTest + + @VisibleForTesting + internal var webSocketForTest: WebSocket? by transport::webSocketForTest + + @VisibleForTesting + internal var currentBackoffForTest: Long by transport::currentBackoffForTest + + @VisibleForTesting + internal var reconnectJobForTest: Job? by transport::reconnectJobForTest + + @VisibleForTesting + internal var outboundDrainJobForTest: Job? by transport::outboundDrainJobForTest + + @VisibleForTesting + internal fun scheduleReconnectForTest() = transport.scheduleReconnectForTest() + + @VisibleForTesting + internal fun createListenerForTest(generation: Int): WebSocketListener = transport.createListenerForTest(generation) + + @VisibleForTesting + internal fun pendingCallIdsForTest(): Set = rpcChannel.pendingCallIdsForTest() + + @VisibleForTesting + internal fun pendingCallTimeoutForTest(id: String): Job? = rpcChannel.pendingCallTimeoutForTest(id) @VisibleForTesting internal fun formatSafeOutgoingFrameLog( @@ -1578,47 +389,11 @@ object HermesWsClient { paramsKeys: Collection?, byteLength: Int, queued: Boolean = false, - ): String { - val prefix = if (queued) "→ (queued)" else "→" - val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" - return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" - } + ): String = transport.formatSafeOutgoingFrameLog(id, method, paramsKeys, byteLength, queued) @VisibleForTesting - internal fun formatSafeQueuedFrameLog(msg: String): String = - try { - val req = OkHttpProvider.json.decodeFromString(msg) - formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) - } catch (_: Throwable) { - "→ (queued) frame bytes=${msg.length}" - } + internal fun formatSafeQueuedFrameLog(msg: String): String = transport.formatSafeQueuedFrameLog(msg) @VisibleForTesting - internal fun formatSafeIncomingFrameLog(text: String): String = - try { - val element = OkHttpProvider.json.parseToJsonElement(text) - if (element is JsonObject) { - val id = (element["id"] as? JsonPrimitive)?.content - val method = (element["method"] as? JsonPrimitive)?.content - val hasResult = element.containsKey("result") - val resultKeys = - (element["result"] as? JsonObject)?.keys?.joinToString( - ",", - prefix = "[", - postfix = "]", - ) - val errorObj = element["error"] as? JsonObject - val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content - val sb = StringBuilder("← ") - if (id != null) sb.append("id=").append(id).append(" ") - if (method != null) sb.append("method=").append(method).append(" ") - if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") - if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") - sb.append("bytes=").append(text.length).toString() - } else { - "← non-object frame bytes=${text.length}" - } - } catch (_: Throwable) { - "← frame bytes=${text.length}" - } + internal fun formatSafeIncomingFrameLog(text: String): String = transport.formatSafeIncomingFrameLog(text) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt index 90cd65b8..6509b24f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ModelOptionsRepository.kt @@ -5,6 +5,8 @@ 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.ws.contract.ModelOptionsParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -16,13 +18,8 @@ class ModelOptionsRepository( private val connected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, - private val request: suspend (Map) -> Any? = { params -> - val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params) - try { - deferred.await() - } finally { - if (!deferred.isCompleted) deferred.cancel() - } + private val request: suspend (ModelOptionsParams) -> JsonElement = { params -> + HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params) }, private val rest: suspend (Boolean) -> NetworkResult = { refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) } @@ -32,9 +29,7 @@ class ModelOptionsRepository( currentCoroutineContext().ensureActive() if (connected()) { try { - val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false)) - // request() returns raw JsonElement, unlike the pushed-event Map representation. - val json = result as? JsonElement ?: error("Invalid model.options result") + val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false)) return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement(json)) } catch (e: CancellationException) { // A disconnected socket may cancel the independent RPC deferred. Only fall back diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt index 460e5bcf..0707cd14 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginManageRepository.kt @@ -1,8 +1,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.CancellationException -import kotlinx.serialization.json.Json +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement @@ -11,23 +13,14 @@ import kotlinx.serialization.json.decodeFromJsonElement * Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC. */ class PluginManageRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" }) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun listPlugins(profile: String? = null): List { - val params = mutableMapOf("action" to "list") - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - val element = result.toJsonElement() + val element = + caller.call( + RpcMethods.PLUGINS_MANAGE, + PluginsManageParams(action = "list", profile = profile.orNullIfBlank()), + ) val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList() return OkHttpProvider.json.decodeFromJsonElement>(pluginsElement) } @@ -38,15 +31,15 @@ class PluginManageRepository( profile: String? = null, ): PluginsManageSettingsResult { val params = - mutableMapOf( - "action" to "settings", - "key" to key, - "values" to JsonObject(values), + PluginsManageParams( + action = "settings", + key = key, + values = JsonObject(values), + profile = profile.orNullIfBlank(), ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params) - return OkHttpProvider.json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, params) + return OkHttpProvider.json.decodeFromJsonElement(result) } + + private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt index 92766afe..a2cea993 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.data.ws -import kotlinx.coroutines.CancellationException +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.PluginsManageParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement @@ -14,18 +17,10 @@ data class PluginRemovalResult( /** Removes a user-installed plugin through the gateway's canonical plugin manager. */ class PluginRemovalRepository( - private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> - val deferred = HermesWsClient.request(method, params) - try { - deferred.await() - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) { suspend fun remove(name: String): PluginRemovalResult { - val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) - return Json.decodeFromJsonElement(result.toJsonElement()) + val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name)) + return Json.decodeFromJsonElement(result) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt index f1984bee..7d790234 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ProjectInfo import com.m57.hermescontrol.data.model.ProjectsListResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.ProjectsListParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonObject @@ -17,14 +21,12 @@ class HermesProjectsSource( // Only ask a live socket: an offline request would be queued and force a reconnect on // every refresh. The view model refetches when the connection comes back. private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - HermesWsClient.request(method, params).await() - }, + private val caller: TypedRpcCaller = HermesRpcCaller, ) : ProjectsSource { override suspend fun fetchProjects(): List? { if (!isConnected()) return null return try { - val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap())) + val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams) if (element is JsonObject && element.containsKey("projects")) { OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects } else { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt new file mode 100644 index 00000000..ae7fa4e4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ReconnectPolicy.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.data.ws + +internal class ReconnectPolicy( + private val random: () -> Double = { Math.random() }, +) { + @Volatile + var currentBackoff: Long = INITIAL_BACKOFF_MS + private var initialBackoffMs: Long = INITIAL_BACKOFF_MS + private var testBackoffOverrideMs: Long? = null + + fun nextDelayMs(): Long { + val override = testBackoffOverrideMs + if (override != null) return override + val ceiling = currentBackoff + if (ceiling <= 0L) return 0L + return (random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling) + } + + fun advance() { + currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS) + } + + fun reset() { + currentBackoff = initialBackoffMs + } + + fun setBackoffForTest(initialMillis: Long) { + initialBackoffMs = initialMillis + currentBackoff = initialMillis + testBackoffOverrideMs = initialMillis + } + + companion object { + private const val INITIAL_BACKOFF_MS = 1_000L + private const val BACKOFF_BASE_MS = 300L + private const val MAX_BACKOFF_MS = 30_000L + private const val BACKOFF_MULTIPLIER = 2.0 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt new file mode 100644 index 00000000..7cede6ac --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ReplayTracker.kt @@ -0,0 +1,245 @@ +package com.m57.hermescontrol.data.ws + +import androidx.annotation.VisibleForTesting +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks session event sequence watermarks and orchestrates event replay on reconnect. + * + * Extracted from [HermesWsClient] (issue #1016, issue #1376). + */ +internal class ReplayTracker( + private val scope: CoroutineScope, + private val fetchEventsSince: suspend (SessionEventsSinceParams) -> SessionEventsSinceResult, + private val emitEvent: (WsEvent) -> Unit, + private val resolveStoredSessionId: (String?) -> String? = { it }, + private val onReplayFailure: (String, Exception) -> Unit = { _, _ -> }, +) { + private val lastSeenSeq = ConcurrentHashMap() + + @Volatile + private var replayEpoch: String? = null + + @Volatile + private var replayInFlight: Boolean = false + + private val replayHold = ConcurrentHashMap>>() + + @Volatile + private var replayJob: Job? = null + + /** + * Synchronously arms the hold buffers for all known sessions and launches replay in [scope]. + * Arming synchronously ensures racing live frames are held instead of advancing watermarks. + */ + fun triggerReplay() { + if (lastSeenSeq.isEmpty() || replayInFlight) return + replayInFlight = true + val sessionsToReplay = lastSeenSeq.keys().toList() + for (sid in sessionsToReplay) { + replayHold[sid] = Collections.synchronizedList(mutableListOf()) + } + replayJob?.cancel() + replayJob = + scope.launch { + fetchReplay(sessionsToReplay) + } + } + + /** + * Updates replay epoch from gateway.ready. + * When [epoch] is non-empty and changes from a previous non-null value, resets sequence watermarks. + */ + fun updateEpoch(epoch: String?) { + if (!epoch.isNullOrEmpty()) { + if (replayEpoch != null && replayEpoch != epoch) { + lastSeenSeq.clear() + } + replayEpoch = epoch + } + } + + /** + * Checks if an incoming event is a duplicate against the current watermark for [sessionId]. + * Returns true if [seq] <= watermark. + */ + fun isDuplicate( + sessionId: String, + seq: Int, + ): Boolean { + val prev = lastSeenSeq[sessionId] ?: 0 + return seq <= prev + } + + /** + * Processes an incoming live event with sequence tracking. + * Evaluates [eventFactory] only after checking for duplicates. + * If duplicate, drops it without calling [eventFactory]. + * If replay is in flight for [sessionId], buffers it in replayHold. + * Otherwise advances watermark and emits the event. + */ + fun acceptLiveEvent( + sessionId: String, + seq: Int, + eventFactory: () -> WsEvent, + ) { + if (isDuplicate(sessionId, seq)) { + return + } + val finalEvent = decorateEvent(eventFactory()) + if (replayInFlight && replayHold.containsKey(sessionId)) { + replayHold[sessionId]?.add(seq to finalEvent) + return + } + lastSeenSeq[sessionId] = seq + emitEvent(finalEvent) + } + + /** + * Processes an incoming live event with sequence tracking. + * If duplicate, drops it. + * If replay is in flight for [sessionId], buffers it in replayHold. + * Otherwise advances watermark and emits the event. + */ + fun acceptLiveEvent( + sessionId: String, + seq: Int, + event: WsEvent, + ) { + acceptLiveEvent(sessionId, seq) { event } + } + + private fun decorateEvent(event: WsEvent): WsEvent = + if (event is WsEvent.MessageComplete) { + event.copy( + storedSessionId = resolveStoredSessionId(event.sessionId), + ) + } else { + event + } + + private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) { + try { + for (sid in sessionsToReplay) { + val lastSeen = lastSeenSeq[sid] ?: continue + try { + val res = + fetchEventsSince( + SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen), + ) + + val epoch = res.epoch + val epochChanged = + !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch + val truncated = res.truncated == true + val latestSeq = res.latestSeq + + if (epochChanged) replayEpoch = epoch + + if (epochChanged || truncated) { + // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted). + // Partial replay would silently hole the transcript — fast-forward watermark + // and request full transcript resync. + if (epochChanged) lastSeenSeq.clear() + if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq + emitEvent(WsEvent.TranscriptResyncRequired(sid)) + continue + } + val eventsList = res.events + if (eventsList != null) { + for (element in eventsList) { + @Suppress("UNCHECKED_CAST") + val eventMap = (element as? JsonObject)?.toAny() as? Map ?: continue + val eventSeq = (eventMap["seq"] as? Number)?.toInt() + val eventSid = (eventMap["session_id"] as? String) ?: sid + if (eventSeq != null && eventSid.isNotEmpty()) { + val prev = lastSeenSeq[eventSid] ?: 0 + if (eventSeq <= prev) continue + lastSeenSeq[eventSid] = eventSeq + } + val parsedEvent = EventParser.parseParams(eventMap) + val finalEvent = decorateEvent(parsedEvent) + emitEvent(finalEvent) + } + } + } catch (e: Exception) { + onReplayFailure(sid, e) + emitEvent(WsEvent.TranscriptResyncRequired(sid)) + } + } + } finally { + withContext(NonCancellable) { + for (sid in sessionsToReplay) { + val held = replayHold.remove(sid) + if (held != null) { + synchronized(held) { + for ((heldSeq, heldEvent) in held) { + if (heldSeq != null) { + val prev = lastSeenSeq[sid] ?: 0 + if (heldSeq <= prev) continue + lastSeenSeq[sid] = heldSeq + } + emitEvent(heldEvent) + } + } + } + } + replayInFlight = false + } + } + } + + /** + * Complete teardown matching disconnect (issue #1016). + * Cancels any active replay job and clears all holds, watermarks, and epoch. + */ + fun clear() { + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayJob?.cancel() + replayJob = null + replayInFlight = false + } + + @VisibleForTesting + internal fun getSeqWatermarks(): Map = HashMap(lastSeenSeq) + + @VisibleForTesting + internal fun setSeqWatermark( + sessionId: String, + seq: Int, + ) { + lastSeenSeq[sessionId] = seq + } + + @VisibleForTesting + internal fun clearSeqWatermarks() { + lastSeenSeq.clear() + replayHold.clear() + replayEpoch = null + replayInFlight = false + } + + @VisibleForTesting + internal fun isReplayInFlight(): Boolean = replayInFlight + + @VisibleForTesting + internal fun setReplayEpochForTest(epoch: String?) { + replayEpoch = epoch + } + + @VisibleForTesting + internal suspend fun fetchReplayForTest() { + fetchReplay() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt new file mode 100644 index 00000000..6f8cfa5d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt @@ -0,0 +1,353 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Socket-independent JSON-RPC 2.0 request correlation and response dispatcher. + * Manages request IDs, in-flight pending calls, timeouts, cancellation cleanup, + * and server response frame formatting. + */ +internal class RpcChannel( + private val scope: CoroutineScope, + private val json: Json, + private val sendRequest: (method: String, params: Map, onSent: ((String) -> Unit)?) -> String, + private val sendFrame: (String) -> Boolean, + private val removeQueuedMessage: (String) -> Unit, + private val onIdleCheck: () -> Unit, + private val logRejected: (method: String, id: String) -> Unit = { _, _ -> }, + private val outboundLock: Any = Any(), + private val onResult: (String) -> Unit = {}, + private val onError: (String) -> Unit = {}, + private val resolveStoredSessionId: (String?) -> String? = { it }, + private val logParseFailure: (Exception) -> Unit = {}, + private val decorateParams: (String, Map) -> Map = { _, p -> p }, +) { + /** A single in-flight [request] awaiting its RPC result/error. */ + private data class PendingCall( + val method: String, + val deferred: CompletableDeferred, + var timeoutJob: Job? = null, + val suppressErrorEvent: Boolean = false, + ) + + private val requestId = AtomicInteger(0) + private val pendingCalls = ConcurrentHashMap() + private val capabilityRequestIds = ConcurrentHashMap.newKeySet() + + fun nextId(): String = requestId.incrementAndGet().toString() + + fun hasPendingCalls(): Boolean = pendingCalls.isNotEmpty() + + fun pendingCallIdsForTest(): Set = pendingCalls.keys.toSet() + + fun pendingCallTimeoutForTest(id: String): Job? = pendingCalls[id]?.timeoutJob + + fun isErrorEventSuppressed(id: String): Boolean = pendingCalls[id]?.suppressErrorEvent == true + + fun registerCapabilityRequest(id: String) { + capabilityRequestIds.add(id) + } + + fun clearCapabilityRequests() { + capabilityRequestIds.clear() + } + + fun consumeCapabilityResponse(event: WsEvent): Boolean { + val id = + when (event) { + is WsEvent.RpcResult -> event.id + is WsEvent.RpcError -> event.id + else -> return false + } + val isCapabilityResponse = + synchronized(outboundLock) { + capabilityRequestIds.remove(id) + } + if (!isCapabilityResponse) return false + + removeQueuedMessage(id) + return true + } + + fun createRequest( + method: String, + params: Map, + ): JsonRpcRequest { + val id = nextId() + val decorated = decorateParams(method, params) + return JsonRpcRequest( + id = id, + method = method, + params = decorated.mapValues { it.value.toJsonElement() }, + ) + } + + fun encodeRequest(request: JsonRpcRequest): String = json.encodeToString(request) + + /** + * Send a JSON-RPC request that expects a result and returns a [CompletableDeferred] for it. + */ + fun request( + method: String, + params: Map = emptyMap(), + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred { + val deferred = CompletableDeferred() + val id = + sendRequest(method, params) { reqId -> + pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent) + } + deferred.invokeOnCompletion { cause -> + if (cause is CancellationException) { + pendingCalls.remove(id)?.let { call -> + call.timeoutJob?.cancel() + removeQueuedMessage(id) + onIdleCheck() + } + } + } + // Arm the per-request timeout (fires if the server never answers). + pendingCalls[id]?.timeoutJob = + scope.launch { + delay(timeoutMs) + resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method")) + } + return deferred + } + + /** + * Non-suspending typed twin of [request] for callers that must enqueue inside a + * non-suspending owner check and await the raw result themselves. + */ + fun requestTyped( + method: RpcMethod, + params: P, + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): CompletableDeferred = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent) + + /** + * Send a typed JSON-RPC request and await its deserialized result. + */ + suspend fun call( + method: RpcMethod, + params: P, + timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS, + suppressErrorEvent: Boolean = false, + ): R = awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent)) + + suspend fun awaitResult( + method: RpcMethod, + deferred: CompletableDeferred, + ): R { + val result = + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + val element = result.toJsonElement() + return json.decodeFromJsonElement(method.result, element) + } + + fun encodeParams( + method: RpcMethod, + params: P, + ): JsonObject { + val encoded = json.encodeToJsonElement(method.params, params) + require(encoded is JsonObject) { + "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}" + } + return encoded + } + + /** Complete (or fail) a single pending call and cancel its timer. */ + fun resolvePending( + id: String, + result: Any?, + error: JsonRpcError?, + ) { + val call = pendingCalls.remove(id) ?: return + removeQueuedMessage(id) + call.timeoutJob?.cancel() + if (error != null) { + call.deferred.completeExceptionally( + HermesWsClient.HermesRpcException( + message = error.message, + code = error.code, + data = error.data, + ), + ) + } else { + call.deferred.complete(result) + } + onIdleCheck() + } + + /** + * Fail and clear every in-flight [request]. + */ + fun rejectAllPending( + error: HermesWsClient.HermesRpcException = + HermesWsClient.HermesRpcException("Connection lost — request cancelled"), + ) { + if (pendingCalls.isEmpty()) return + val snapshot = pendingCalls.toList() + pendingCalls.clear() + for ((id, call) in snapshot) { + removeQueuedMessage(id) + logRejected(call.method, id) + call.timeoutJob?.cancel() + call.deferred.completeExceptionally(error) + } + } + + /** + * Answer a gateway-originated JSON-RPC request. + */ + fun respondToServerRequest( + id: String, + result: JsonElement, + ): Boolean { + if (id.isBlank()) return false + val frameJson = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + }.toString() + return sendFrame(frameJson) + } + + /** Answer a gateway-originated request with a standard JSON-RPC error. */ + fun respondToServerRequestError( + id: String, + code: Int, + message: String, + ): Boolean { + if (id.isBlank()) return false + val frameJson = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put( + "error", + buildJsonObject { + put("code", code) + put("message", message) + }, + ) + }.toString() + return sendFrame(frameJson) + } + + /** + * Handles an incoming WebSocket text frame: JSON decoding, request correlation, + * server request dispatch, sequence deduplication via [replayTracker], and event emission. + */ + fun handleIncomingFrame( + text: String, + replayTracker: ReplayTracker, + emitEvent: (WsEvent) -> Unit, + ) { + val event = + try { + val rpc = json.decodeFromString(text) + // Resolve pending request() calls with the RAW JsonElement from + // rpc.result, BEFORE EventParser.parse() converts it via toAny(). + // This preserves integer/float type fidelity needed by callers + // that re-deserialize the result into typed data classes. + val rpcId = rpc.id + if (rpcId != null && rpc.error == null && rpc.result != null) { + onResult(rpcId) + removeQueuedMessage(rpcId) + resolvePending(rpcId, rpc.result, null) + } + + // Resume/replay responses carry still-open server requests + // separately from the event ring. Re-deliver them through the + // same generic path before exposing the RPC result. + @Suppress("UNCHECKED_CAST") + val openRequests = + (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> + openRequests?.forEach { openRequest -> + val openId = openRequest["id"] as? String ?: return@forEach + val method = openRequest["method"] as? String ?: return@forEach + + @Suppress("UNCHECKED_CAST") + val params = openRequest["params"] as? Map ?: emptyMap() + emitEvent(WsEvent.ServerRequest(openId, method, params, replayed = true)) + } + + if (rpc.id == null) { + // Issue #1163: inspect scalars, not a throwaway copy of the whole params tree. + val sid = rpc.params?.eventSessionId() + val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt() + + if (!sid.isNullOrBlank() && seq != null) { + replayTracker.acceptLiveEvent(sid, seq) { + EventParser.parse(rpc, text) + } + return + } + } + + val parsed = EventParser.parse(rpc, text) + if (parsed is WsEvent.MessageComplete) { + parsed.copy( + storedSessionId = resolveStoredSessionId(parsed.sessionId), + ) + } else { + parsed + } + } catch (e: Exception) { + logParseFailure(e) + WsEvent.Unknown(text) + } + if (consumeCapabilityResponse(event)) return + when (event) { + is WsEvent.RpcResult -> { + onResult(event.id) + removeQueuedMessage(event.id) + resolvePending(event.id, event.result, null) + } + + is WsEvent.RpcError -> { + onError(event.id) + 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) + } + + else -> {} + } + emitEvent(event) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt index 31cc88b4..9d8964c7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSource.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ActiveSessionsResponse import com.m57.hermescontrol.data.model.LiveSessionSnapshot import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -148,8 +150,8 @@ object SessionLiveStatusDecoder { * Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient]. */ class HermesSessionLiveStatusSource( - private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> - HermesWsClient.request(method, params).await() + private val rpcRequest: suspend () -> Any? = { + HermesWsClient.call(RpcMethods.SESSION_ACTIVE_LIST, SessionActiveListParams()) }, eventsProvider: () -> Flow = { HermesWsClient.events }, connectionStatusProvider: () -> StateFlow = { HermesWsClient.connectionStatus }, @@ -159,7 +161,7 @@ class HermesSessionLiveStatusSource( override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? = try { - val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap()) + val raw = rpcRequest() SessionLiveStatusDecoder.decodeSnapshot(raw) } catch (e: CancellationException) { throw e 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 1cda13ec..2c6ecb7f 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 @@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws import android.util.Log import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentTailResponse +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SubagentTailParams import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -33,12 +36,11 @@ object SubagentRepository { if (sessionId.isBlank()) return null return try { val result = - HermesWsClient - .request( - WsMethods.SUBAGENT_LIST, - mapOf("session_id" to sessionId), - suppressErrorEvent = true, - ).await() + HermesWsClient.call( + RpcMethods.SUBAGENT_LIST, + SessionIdParams(sessionId), + suppressErrorEvent = true, + ) decode(result) } catch (e: Exception) { Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}") @@ -55,24 +57,15 @@ object SubagentRepository { suspend fun tailSubagent( sessionId: String, subagentId: String, - maxBytes: Int = 16384, ): SubagentTailResponse? { - if (subagentId.isBlank()) return null - val params = - mutableMapOf( - "subagent_id" to subagentId, - "max_bytes" to maxBytes, - ) - if (sessionId.isNotBlank()) { - params["session_id"] = sessionId - } + // Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379). + if (subagentId.isBlank() || sessionId.isBlank()) return null val result = - HermesWsClient - .request( - WsMethods.SUBAGENT_TAIL, - params, - suppressErrorEvent = true, - ).await() + HermesWsClient.call( + RpcMethods.SUBAGENT_TAIL, + SubagentTailParams(sessionId, subagentId), + suppressErrorEvent = true, + ) return decode(result) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt index 56d7a6cc..234fece1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt @@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.VaultSourceSetResponse import com.m57.hermescontrol.data.model.VaultSourcesResponse import com.m57.hermescontrol.data.model.VaultUnlockResponse import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultSourceSetParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -48,7 +53,7 @@ object VaultRepository { } suspend fun getSources(): List { - val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await() + val result = HermesWsClient.call(RpcMethods.VAULT_SOURCES, EmptyParams) return decode(result)?.sources ?: emptyList() } @@ -57,11 +62,10 @@ object VaultRepository { enabled: Boolean, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_SOURCE_SET, - mapOf("name" to name, "enabled" to enabled), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_SOURCE_SET, + VaultSourceSetParams(name = name, enabled = enabled), + ) return decode(result)?.enabled == enabled } @@ -70,22 +74,21 @@ object VaultRepository { password: String, ): Boolean { val result = - HermesWsClient - .request( - WsMethods.VAULT_UNLOCK, - mapOf("name" to name, "password" to password), - ).await() + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams(name = name, password = password), + ) return decode(result)?.unlocked == true } suspend fun lockSource(name: String? = null): Boolean { - val params = if (name != null) mapOf("name" to name) else emptyMap() - val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await() + val params = VaultLockParams(name = name) + val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params) return decode(result)?.locked == true } suspend fun listItems(): List { - val result = HermesWsClient.request(WsMethods.VAULT_LIST).await() + val result = HermesWsClient.call(RpcMethods.VAULT_LIST, EmptyParams) return decode(result)?.items ?: emptyList() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index e5cf6c26..0f519032 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -445,6 +445,18 @@ sealed class WsEvent { val kind: String = "", ) : WsEvent() + /** + * `message.reaction`: the agent (or another client) set/cleared a tapback on a stored + * message. [reactions] is the row's full list after the write; [rowId] is the gateway + * SQLite row id, matched against `ChatMessage.serverRowId`. + */ + data class MessageReactionUpdated( + val rowId: Long, + val reactions: List, + val role: String, + val sessionId: String?, + ) : WsEvent() + // ── Replay resync (internal) ────────────────────────────────────────── /** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */ 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 f2e07a5a..0957f86a 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 @@ -94,6 +94,9 @@ object WsMethods { /** Kill a single background process (scoped to the active session). */ const val PROCESS_KILL = "process.kill" + /** Kill every background process in the gateway registry (`/stop`, global). */ + const val PROCESS_STOP = "process.stop" + // ── Subagents (issue #1089) ─────────────────────────────────────────── /** List active subagents and delegations for a session. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt new file mode 100644 index 00000000..b425ca4d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt @@ -0,0 +1,912 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.await +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.utf8Size +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * Socket lifecycle, authentication, outbound queueing, and network transport management. + * + * Extracted from [HermesWsClient] (issue #1376). + */ +internal class WsTransport( + private val scope: CoroutineScope, + private val outboundLock: Any, + private val reconnectPolicy: ReconnectPolicy, + private val connectionHealth: ConnectionHealth, + private val replayTracker: ReplayTracker, + private val rpcChannel: RpcChannel, + private val onRawMessage: (String) -> Unit, +) { + companion object { + private const val TAG = "HermesWsClient" + private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024 + private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L + } + + private val connectionGeneration = AtomicInteger(0) + private val connected = AtomicBoolean(false) + private val intentionalClose = AtomicBoolean(false) + private val acceptQueuedMessages = AtomicBoolean(true) + private val appInForeground = AtomicBoolean(true) + private val externalActivityConnectionLease = AtomicBoolean(false) + private val backgroundConnectionLease = AtomicBoolean(false) + private val messageQueue = ConcurrentLinkedQueue() + private val queuedMessagesById = ConcurrentHashMap() + + @Volatile + private var webSocket: WebSocket? = null + + @Volatile + private var closingSocket: WebSocket? = null + + @Volatile + private var reconnectJob: Job? = null + + @Volatile + private var outboundDrainJob: Job? = null + + private val networkCollectorStarted = AtomicBoolean(false) + + private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + + @Volatile + var pendingReply: Boolean = false + internal set + + private val pendingPromptSubmits = ConcurrentHashMap.newKeySet() + + val isConnected: Boolean get() = connected.get() + + fun setAppForeground(foreground: Boolean) { + appInForeground.set(foreground) + if (!foreground) { + disconnectIfIdleInBackground() + return + } + connectionHealth.probeLivenessOnWake() + } + + fun acquireExternalActivityConnectionLease() { + externalActivityConnectionLease.set(true) + } + + fun releaseExternalActivityConnectionLease() { + externalActivityConnectionLease.set(false) + disconnectIfIdleInBackground() + } + + fun acquireBackgroundConnectionLease() { + backgroundConnectionLease.set(true) + } + + fun releaseBackgroundConnectionLease() { + backgroundConnectionLease.set(false) + disconnectIfIdleInBackground() + } + + internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get() + + fun disconnectIfIdleInBackground() { + synchronized(outboundLock) { + if (!appInForeground.get() && + !externalActivityConnectionLease.get() && + !backgroundConnectionLease.get() && + !pendingReply && + !rpcChannel.hasPendingCalls() && + messageQueue.isEmpty() + ) { + disconnect() + } + } + } + + fun connect() { + if (networkCollectorStarted.compareAndSet(false, true)) { + scope.launch { + NetworkMonitor.networkChanges.collect { networkAvailable -> + reconnectForNetworkChange(networkAvailable) + } + } + scope.launch { + NetworkMonitor.transportChanges.collect { + connectionHealth.probeLivenessOnTransportChange( + onFailureAction = { cancelSocket() }, + ) + } + } + } + var socketToCancel: WebSocket? = null + val shouldOpen = + synchronized(outboundLock) { + acceptQueuedMessages.set(true) + if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { + connectionGeneration.incrementAndGet() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + socketToCancel = webSocket + webSocket = null + closingSocket = null + connected.set(false) + connectionHealth.stop() + } + if (connected.get()) { + Log.d(TAG, "Already connected — skipping") + return@synchronized false + } + if (_connectionStatus.value == ConnectionStatus.CONNECTING || + _connectionStatus.value == ConnectionStatus.RECONNECTING + ) { + Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping") + return@synchronized false + } + // An explicit connect follows successful authentication and is + // therefore allowed to leave the terminal AUTH_EXPIRED state. + intentionalClose.set(false) + _connectionStatus.value = ConnectionStatus.CONNECTING + reconnectPolicy.reset() + true + } + if (socketToCancel != null) { + rpcChannel.rejectAllPending() + socketToCancel.cancel() + } + if (shouldOpen) openSocket() + } + + fun reconnectForNetworkChange( + networkAvailable: Boolean = NetworkMonitor.isConnected.value, + openReplacement: () -> Unit = ::openSocket, + ) { + val socketToCancel: WebSocket? + val shouldOpen: Boolean + synchronized(outboundLock) { + if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) { + return + } + Log.d(TAG, "Default network changed — reconnecting WebSocket") + reconnectPolicy.reset() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + connectionGeneration.incrementAndGet() + socketToCancel = webSocket + webSocket = null + closingSocket = null + connected.set(false) + connectionHealth.stop() + shouldOpen = networkAvailable && AuthManager.isAutoReconnect() + _connectionStatus.value = + when { + !networkAvailable -> ConnectionStatus.NO_NETWORK + shouldOpen -> ConnectionStatus.RECONNECTING + else -> ConnectionStatus.DISCONNECTED + } + rpcChannel.rejectAllPending() + } + socketToCancel?.cancel() + if (shouldOpen) openReplacement() + } + + suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean { + val isGated = + try { + AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + } catch (_: IllegalStateException) { + // serverStore not initialized yet (transient early call); treat + // as loopback so a stale-token refresh still runs. Log so a real + // misconfiguration isn't silently swallowed. + Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated") + false + } + if (!isGated) { + // The loopback dashboard token is regenerated on every server + // restart. Refresh it before each WebSocket handshake so automatic + // reconnect does not get stuck in AUTH_EXPIRED with a stale token. + val token = + runCatching { + DashboardSessionTokenRefresher.refreshAsync() + }.getOrNull() + synchronized(outboundLock) { + if (isCurrentGeneration(generation) && token != null) { + runCatching { AuthManager.setToken(token) } + } + } + return true + } + + val ticketResult = requestWsTicket() + if (!ticketResult.ticket.isNullOrBlank()) { + synchronized(outboundLock) { + if (!isCurrentGeneration(generation)) return false + AuthManager.setToken(ticketResult.ticket) + } + if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") + return true + } + val status = + when { + ticketResult.exception -> ConnectionStatus.RECONNECTING + ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED + ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING + ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING + else -> ConnectionStatus.DISCONNECTED + } + return handleWsTicketRefreshFailure(status, generation) + } + + suspend fun mintWsTicket(): String? { + val isGated = + try { + AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + } catch (_: IllegalStateException) { + false + } + if (!isGated) { + DashboardSessionTokenRefresher.refreshAsync() + return AuthManager.getToken() + } + return requestWsTicket().ticket + } + + private data class TicketRequestResult( + val ticket: String?, + val httpCode: Int?, + val exception: Boolean = false, + ) + + private suspend fun requestWsTicket(): TicketRequestResult { + try { + val client = OkHttpProvider.probe + val request = + Request + .Builder() + .url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString()) + .post("{}".toRequestBody()) + .build() + + val (code, body) = + withContext(Dispatchers.IO) { + if (CookieManager.isInitialized()) { + CookieManager.useStore(CookieManager.cookieJar.currentServer()) + } + client.newCall(request).await().use { resp -> + resp.code to resp.body.string() + } + } + + if (code in 200..299) { + // Real JSON decode — the ticket is base64url and may contain + // characters a naive [^"]+ regex cannot extract (see + // testGatedMode_parsesEscapedTicketFromRealJsonShape). + val parsed = + runCatching { + OkHttpProvider.json.decodeFromString(body) + }.getOrNull() + val ticket = parsed?.ticket + if (!ticket.isNullOrBlank()) { + return TicketRequestResult(ticket, null) + } + Log.w(TAG, "WS ticket mint failed: unparseable ticket response") + } else { + Log.w(TAG, "WS ticket mint failed: HTTP $code") + return TicketRequestResult(null, code) + } + } catch (e: Exception) { + // Include the stack: the exception class alone (e.g. + // NetworkOnMainThreadException) can't show WHICH caller on what + // thread tripped the mint (main-scope kanban events connect vs + // the WS reconnect path). + Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e) + return TicketRequestResult(null, null, exception = true) + } + return TicketRequestResult(null, null) + } + + private fun handleWsTicketRefreshFailure( + status: ConnectionStatus, + generation: Int, + ): Boolean { + synchronized(outboundLock) { + if (!isCurrentGeneration(generation)) return false + _connectionStatus.value = status + if (status == ConnectionStatus.RECONNECTING) scheduleReconnect() + } + return false + } + + fun disconnect(clearPendingMessages: Boolean = false) { + ActiveSessionHolder.clear() + synchronized(outboundLock) { + intentionalClose.set(true) + acceptQueuedMessages.set(!clearPendingMessages) + connectionGeneration.incrementAndGet() + reconnectJob?.cancel() + reconnectJob = null + outboundDrainJob?.cancel() + outboundDrainJob = null + connectionHealth.cancelForegroundProbe() + connectionHealth.stop() + webSocket?.close(1000, "Client closed") + webSocket = null + closingSocket = null + rpcChannel.clearCapabilityRequests() + if (clearPendingMessages) { + backgroundConnectionLease.set(false) + messageQueue.clear() + queuedMessagesById.clear() + pendingPromptSubmits.clear() + pendingReply = false + replayTracker.clear() + } + connected.set(false) + _connectionStatus.value = ConnectionStatus.DISCONNECTED + } + } + + fun cancelSocket() { + synchronized(outboundLock) { + if (connected.get()) webSocket?.cancel() + } + } + + fun sendDirectFrame(frameJson: String): Boolean = + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) false else ws.send(frameJson) + } + + fun send( + request: JsonRpcRequest, + onSent: ((String) -> Unit)? = null, + queueIfDisconnected: Boolean = true, + ): String { + val id = request.id + val method = request.method + val json = rpcChannel.encodeRequest(request) + if (BuildConfig.DEBUG) { + Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false)) + } + var reconnect = false + synchronized(outboundLock) { + if (method == WsMethods.PROMPT_SUBMIT) { + pendingPromptSubmits.add(id) + pendingReply = true + } + val ws = webSocket + if (ws != null && connected.get()) { + // Never replay send(true): the server may have executed it even if its response is lost. + if (ws.send(json)) { + onSent?.invoke(id) + } else { + if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized + if (queueIfDisconnected && isRetryableMessage(json)) { + onSent?.invoke(id) + Log.w(TAG, "WS rejected outgoing message — queuing for reconnect") + queueMessage(id, json) + recoverRejectedSocket(ws) + } else { + Log.w(TAG, "WS rejected oversized outgoing message — not retrying") + } + } + } else if (acceptQueuedMessages.get() && queueIfDisconnected) { + if (isRetryableMessage(json)) { + onSent?.invoke(id) + Log.d(TAG, "WS disconnected — queuing message") + queueMessage(id, json) + reconnect = true + } else { + Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing") + } + } + } + if (reconnect) connect() + return id + } + + fun removeQueuedMessage(id: String) { + synchronized(outboundLock) { + queuedMessagesById.remove(id)?.let(messageQueue::remove) + } + } + + fun onRpcResult(id: String) { + synchronized(outboundLock) { + pendingPromptSubmits.remove(id) + } + } + + fun onRpcError(id: String) { + synchronized(outboundLock) { + if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) { + pendingReply = false + disconnectIfIdleInBackground() + } + } + } + + fun observeEvent(event: WsEvent) { + when (event) { + is WsEvent.MessageStart, + is WsEvent.MessageToken, + is WsEvent.ThinkingDelta, + is WsEvent.ReasoningDelta, + is WsEvent.ToolStart, + -> { + pendingReply = true + } + + is WsEvent.MessageComplete -> { + pendingReply = false + disconnectIfIdleInBackground() + } + + else -> {} + } + } + + private fun isRetryableMessage(json: String): Boolean { + if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false + if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true + return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong() + } + + private fun queueMessage( + id: String, + json: String, + ) { + if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json) + } + + private fun markQueuedMessageSent(json: String) { + queuedMessagesById.entries.removeIf { it.value == json } + } + + private fun recoverRejectedSocket(ws: WebSocket) { + connected.set(false) + if (closingSocket === ws) return + _connectionStatus.value = ConnectionStatus.RECONNECTING + if (ws.queueSize() == 0L) { + ws.cancel() + scheduleReconnect() + return + } + if (intentionalClose.get()) return + outboundDrainJob?.cancel() + outboundDrainJob = + scope.launch { + delay(OUTBOUND_DRAIN_TIMEOUT_MS) + synchronized(outboundLock) { + if (!connected.get() && !intentionalClose.get() && webSocket === ws) { + ws.cancel() + outboundDrainJob = null + scheduleReconnect() + } + } + } + } + + fun sendMessage( + sessionId: String, + text: String, + onSent: ((String) -> Unit)? = null, + queued: Boolean = false, + ): String = + send( + rpcChannel.createRequest( + method = RpcMethods.PROMPT_SUBMIT.name, + params = + PromptSubmitParams( + sessionId = sessionId, + text = text, + queued = queued.takeIf { it }, + ).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) }, + ), + onSent = onSent, + ) + + fun sendRedirect( + sessionId: String, + text: String, + onSent: ((String) -> Unit)? = null, + ): String = + send( + rpcChannel.createRequest( + method = RpcMethods.SESSION_REDIRECT.name, + params = + SessionCorrectionParams( + sessionId = sessionId, + text = text, + ).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) }, + ), + onSent = onSent, + ) + + internal fun openSocket() { + val generation = + synchronized(outboundLock) { + if (intentionalClose.get()) return + connectionGeneration.incrementAndGet() + } + scope.launch(Dispatchers.IO) { + if (!refreshWsTicketIfNeeded(generation)) { + Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") + return@launch + } + if (!isCurrentGeneration(generation)) return@launch + val url = AuthManager.wsUrl() + val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") + if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") + + val request = Request.Builder().url(url).build() + val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) + synchronized(outboundLock) { + if (connectionGeneration.get() == generation && !intentionalClose.get()) { + webSocket = newSocket + } else { + newSocket.cancel() + } + } + } + } + + private fun isCurrentGeneration(generation: Int): Boolean = + connectionGeneration.get() == generation && !intentionalClose.get() + + private fun scheduleReconnect() { + synchronized(outboundLock) { + if (intentionalClose.get() || + _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED || + reconnectJob?.isActive == true + ) { + return + } + if (!AuthManager.isAutoReconnect()) { + if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") + _connectionStatus.value = ConnectionStatus.DISCONNECTED + return + } + if (!NetworkMonitor.isConnected.value) { + Log.d(TAG, "No network available — delaying reconnect scheduling") + _connectionStatus.value = ConnectionStatus.NO_NETWORK + return + } + val reconnectDelay = reconnectPolicy.nextDelayMs() + reconnectPolicy.advance() + if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …") + + reconnectJob = + scope.launch { + delay(reconnectDelay) + val owner = currentCoroutineContext()[Job] + val shouldOpen = + synchronized(outboundLock) { + if (reconnectJob !== owner) { + false + } else { + reconnectJob = null + !intentionalClose.get() && + !connected.get() && + _connectionStatus.value != ConnectionStatus.AUTH_EXPIRED + } + } + if (shouldOpen) openSocket() + } + } + } + + private fun isTerminalAuthClose( + code: Int, + reason: String, + ): Boolean = + code == 4401 || code == 4403 || + reason.contains("unauthorized", ignoreCase = true) || + reason.startsWith("auth:", ignoreCase = true) + + private inner class WsListenerImpl( + private val generation: Int, + ) : WebSocketListener() { + private fun isCurrent(): Boolean = isCurrentGeneration(generation) + + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + synchronized(outboundLock) { + if (!isCurrent()) { + webSocket.close(1000, "Superseded") + return + } + this@WsTransport.webSocket = webSocket + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + Log.i(TAG, "WebSocket opened") + connected.set(true) + _connectionStatus.value = ConnectionStatus.CONNECTED + reconnectPolicy.reset() + connectionHealth.start() + + while (true) { + val msg = messageQueue.peek() ?: break + if (!isRetryableMessage(msg)) { + Log.w(TAG, "Dropping oversized queued message") + messageQueue.poll() + markQueuedMessageSent(msg) + continue + } + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg)) + if (!webSocket.send(msg)) { + recoverRejectedSocket(webSocket) + break + } + messageQueue.poll() + markQueuedMessageSent(msg) + } + replayTracker.triggerReplay() + } + disconnectIfIdleInBackground() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return + if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text)) + connectionHealth.onInboundFrame() + onRawMessage(text) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + synchronized(outboundLock) { + if (!isCurrent()) { + webSocket.close(code, reason) + return + } + closingSocket = webSocket + // Do NOT log [reason] — it may carry server-side context. + Log.d(TAG, "WebSocket closing: $code") + if (isTerminalAuthClose(code, reason)) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } + webSocket.close(code, reason) + } + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + synchronized(outboundLock) { + if (!isCurrent()) return + connectionGeneration.incrementAndGet() + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null + // Do NOT log [reason] — it may carry server-side context. The + // reason is still inspected internally to detect auth failures. + Log.i(TAG, "WebSocket closed: $code") + connected.set(false) + ActiveSessionHolder.clear() + connectionHealth.stop() + rpcChannel.clearCapabilityRequests() + if (isTerminalAuthClose(code, reason)) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { + _connectionStatus.value = ConnectionStatus.RECONNECTING + scheduleReconnect() + } + } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + synchronized(outboundLock) { + if (!isCurrent()) return + connectionGeneration.incrementAndGet() + closingSocket = null + outboundDrainJob?.cancel() + outboundDrainJob = null + if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null + // Log the exception class only — [Throwable.message] can leak URLs + // or headers. The message is still inspected internally for auth + // detection. + Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) + connected.set(false) + ActiveSessionHolder.clear() + connectionHealth.stop() + rpcChannel.clearCapabilityRequests() + val code = response?.code ?: 0 + if (code == 401 || code == 4401 || code == 4403 || + t.message?.contains("401") == true || + t.message?.contains("4401") == true || + t.message?.contains("4403") == true || + t.message?.contains("unauthorized", ignoreCase = true) == true + ) { + _connectionStatus.value = ConnectionStatus.AUTH_EXPIRED + } else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) { + _connectionStatus.value = ConnectionStatus.RECONNECTING + scheduleReconnect() + } + } + } + } + + // ── Test seams (issue #1376 extraction support) ────────────────────── + + @VisibleForTesting + internal val connectedForTest: AtomicBoolean + get() = connected + + @VisibleForTesting + internal val intentionalCloseForTest: AtomicBoolean + get() = intentionalClose + + @VisibleForTesting + internal val connectionGenerationForTest: AtomicInteger + get() = connectionGeneration + + @VisibleForTesting + internal val messageQueueForTest: ConcurrentLinkedQueue + get() = messageQueue + + @VisibleForTesting + internal val outboundLockForTest: Any + get() = outboundLock + + @VisibleForTesting + internal val acceptQueuedMessagesForTest: AtomicBoolean + get() = acceptQueuedMessages + + @VisibleForTesting + internal val connectionStatusForTest: MutableStateFlow + get() = _connectionStatus + + @VisibleForTesting + internal var webSocketForTest: WebSocket? + get() = webSocket + set(value) { + webSocket = value + } + + @VisibleForTesting + internal var currentBackoffForTest: Long + get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff } + set(value) { + synchronized(outboundLock) { + reconnectPolicy.currentBackoff = value + } + } + + @VisibleForTesting + internal var reconnectJobForTest: Job? + get() = reconnectJob + set(value) { + reconnectJob = value + } + + @VisibleForTesting + internal var outboundDrainJobForTest: Job? + get() = outboundDrainJob + set(value) { + outboundDrainJob = value + } + + @VisibleForTesting + internal fun setReconnectBackoffForTest(initialMillis: Long) { + synchronized(outboundLock) { + reconnectPolicy.setBackoffForTest(initialMillis) + } + } + + @VisibleForTesting + internal fun scheduleReconnectForTest() { + scheduleReconnect() + } + + @VisibleForTesting + internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation) + + @VisibleForTesting + internal fun setConnectionStatusForTest(status: ConnectionStatus) { + _connectionStatus.value = status + } + + @VisibleForTesting + internal fun formatSafeOutgoingFrameLog( + id: String, + method: String, + paramsKeys: Collection?, + byteLength: Int, + queued: Boolean = false, + ): String { + val prefix = if (queued) "→ (queued)" else "→" + val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]" + return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength" + } + + @VisibleForTesting + internal fun formatSafeQueuedFrameLog(msg: String): String = + try { + val req = OkHttpProvider.json.decodeFromString(msg) + formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true) + } catch (_: Throwable) { + "→ (queued) frame bytes=${msg.length}" + } + + @VisibleForTesting + internal fun formatSafeIncomingFrameLog(text: String): String = + try { + val element = OkHttpProvider.json.parseToJsonElement(text) + if (element is JsonObject) { + val id = (element["id"] as? JsonPrimitive)?.content + val method = (element["method"] as? JsonPrimitive)?.content + val hasResult = element.containsKey("result") + val resultKeys = + (element["result"] as? JsonObject)?.keys?.joinToString( + ",", + prefix = "[", + postfix = "]", + ) + val errorObj = element["error"] as? JsonObject + val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content + val sb = StringBuilder("← ") + if (id != null) sb.append("id=").append(id).append(" ") + if (method != null) sb.append("method=").append(method).append(" ") + if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ") + if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ") + sb.append("bytes=").append(text.length).toString() + } else { + "← non-object frame bytes=${text.length}" + } + } catch (_: Throwable) { + "← frame bytes=${text.length}" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt new file mode 100644 index 00000000..d66920eb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Approvals.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ApprovalPendingParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class ApprovalReceivedParams( + @SerialName("session_id") val sessionId: String, + @SerialName("request_id") val requestId: String, +) + +@Serializable +data class ApprovalRespondParams( + @SerialName("session_id") val sessionId: String, + @SerialName("choice") val choice: String? = null, + @SerialName("all") val all: Boolean? = null, + @SerialName("request_id") val requestId: String? = null, +) + +@Serializable +data class SessionActiveListParams( + @SerialName("current_session_id") val currentSessionId: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt new file mode 100644 index 00000000..28ae5871 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/BillingVault.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object EmptyParams + +@Serializable +data class SubscriptionPreviewParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class SubscriptionChangeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, + @SerialName("cancel") val cancel: Boolean? = null, +) + +@Serializable +data class SubscriptionUpgradeParams( + @SerialName("subscription_type_id") val subscriptionTypeId: String? = null, +) + +@Serializable +data class VaultSourceSetParams( + @SerialName("name") val name: String? = null, + @SerialName("enabled") val enabled: Boolean? = null, +) + +@Serializable +data class VaultUnlockParams( + @SerialName("name") val name: String? = null, + @SerialName("password") val password: String? = null, +) + +@Serializable +data class VaultLockParams( + @SerialName("name") val name: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt new file mode 100644 index 00000000..c3f32fda --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ChatCommands.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** `commands.catalog` is sent without explicit params; the active profile is injected by WsProfileParams. */ +@Serializable +data object CommandsCatalogParams + +/** `arg` is sent whenever non-null, including an empty string, exactly like the old map payload. */ +@Serializable +data class CommandDispatchParams( + @SerialName("name") val name: String, + @SerialName("arg") val arg: String? = null, + @SerialName("session_id") val sessionId: String? = null, +) + +@Serializable +data class SlashExecParams( + @SerialName("session_id") val sessionId: String, + @SerialName("command") val command: String, +) + +/** `data_url` carries the bytes for an uploaded file; `path` is for gateway-visible files. */ +@Serializable +data class FileAttachParams( + @SerialName("session_id") val sessionId: String, + @SerialName("path") val path: String? = null, + @SerialName("data_url") val dataUrl: String? = null, + @SerialName("name") val name: String? = null, +) + +/** The mobile client always sends the payload as `content_base64`; the `data` alias is not modelled. */ +@Serializable +data class ImageAttachBytesParams( + @SerialName("session_id") val sessionId: String, + @SerialName("content_base64") val contentBase64: String? = null, + @SerialName("filename") val filename: String? = null, + @SerialName("ext") val ext: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt new file mode 100644 index 00000000..c9721c89 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConfigModel.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted + * when null. `profile` is injected by WsProfileParams, so it is not a field here. + */ +@Serializable +data class ConfigSetParams( + @SerialName("key") val key: String, + @SerialName("value") val value: String, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("scope") val scope: String? = null, + @SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null, +) + +@Serializable +data class ConfigGetParams( + @SerialName("key") val key: String, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("cwd") val cwd: String? = null, +) + +/** + * `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an + * unset flag is omitted (the client Json has `encodeDefaults = false`). + */ +@Serializable +data class ModelOptionsParams( + @SerialName("session_id") val sessionId: String? = null, + @SerialName("explicit_only") val explicitOnly: Boolean? = null, + @SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null, + @SerialName("refresh") val refresh: Boolean? = null, +) + +@Serializable +data class ClientCapabilitiesParams( + @SerialName("server_requests") val serverRequests: Boolean, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt new file mode 100644 index 00000000..496d9451 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperation.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectionAnswerTarget( + @SerialName("name") val name: String, + @SerialName("status") val status: String, + @SerialName("detail") val detail: String? = null, + @SerialName("env") val env: Map? = null, +) + +@Serializable +data class ConnectionAnswer( + @SerialName("targets") val targets: List? = null, + @SerialName("settled_by") val settledBy: String? = null, +) + +@Serializable +data class ConnectionRespondParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("op_id") val opId: String, + @SerialName("result") val result: ConnectionAnswer, + @SerialName("profile") val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt new file mode 100644 index 00000000..b00a2c5c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/Connectors.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class ConnectorOwner( + @SerialName("type") val type: String, + @SerialName("session_id") val sessionId: String? = null, +) { + companion object { + fun account() = ConnectorOwner("account") + + fun session(sessionId: String) = ConnectorOwner("session", sessionId) + } +} + +@Serializable +data class ConnectorsListParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsConnectParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("connectors") val connectors: List, + @SerialName("reconnect") val reconnect: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsOperationStatusParams( + @SerialName("owner") val owner: ConnectorOwner, + @SerialName("op_id") val opId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsCatalogParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsParams( + @SerialName("connector") val connector: String? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsAccountsRemoveParams( + @SerialName("connection_id") val connectionId: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicyGetParams( + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsPolicySetParams( + @SerialName("change") val change: JsonObject, + @SerialName("expected_revision") val expectedRevision: String, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class ConnectorsToolsParams( + @SerialName("slug") val slug: String, + @SerialName("refresh") val refresh: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt new file mode 100644 index 00000000..e970a9bd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/PluginsManageParams.kt @@ -0,0 +1,25 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `plugins.manage` params. [action] has no default so it is always encoded; every other field is + * omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it. + */ +@Serializable +data class PluginsManageParams( + @SerialName("action") val action: String, + @SerialName("profile") val profile: String? = null, + @SerialName("key") val key: String? = null, + @SerialName("name") val name: String? = null, + @SerialName("enable") val enable: Boolean? = null, + @SerialName("identifier") val identifier: String? = null, + @SerialName("repo") val repo: String? = null, + @SerialName("catalog_name") val catalogName: String? = null, + @SerialName("force") val force: Boolean? = null, + @SerialName("ref") val ref: String? = null, + @SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null, + @SerialName("values") val values: JsonObject? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt new file mode 100644 index 00000000..0d4412df --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessKillParams.kt @@ -0,0 +1,10 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProcessKillParams( + @SerialName("session_id") val sessionId: String, + @SerialName("process_id") val processId: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt new file mode 100644 index 00000000..6e0a2f8f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProcessStop.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** + * `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop + * sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent. + */ +@Serializable +data object ProcessStopParams + +@Serializable +data class ProcessStopResult( + val killed: Int? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt new file mode 100644 index 00000000..88807989 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesConfigureParams.kt @@ -0,0 +1,16 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`. + * `profile` is not a field: WsProfileParams injects it when the method is profile scoped. + */ +@Serializable +data class ProfilesConfigureParams( + @SerialName("name") val name: String? = null, + @SerialName("ui_meta") val uiMeta: JsonObject? = null, + @SerialName("soul") val soul: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt new file mode 100644 index 00000000..0b5f7dda --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProfilesListParams.kt @@ -0,0 +1,7 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */ +@Serializable +data object ProfilesListParams diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt new file mode 100644 index 00000000..8b8937cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/ProjectsListParams.kt @@ -0,0 +1,7 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.Serializable + +/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */ +@Serializable +data object ProjectsListParams diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt new file mode 100644 index 00000000..be4bb3ba --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/RpcMethod.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement + +class RpcMethod( + val name: String, + val params: KSerializer

, + val result: KSerializer, +) + +interface TypedRpcSender { + fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)? = null, + ): String +} + +interface TypedRpcCaller { + suspend fun call( + method: RpcMethod, + params: P, + ): R +} + +object HermesRpcCaller : TypedRpcCaller { + override suspend fun call( + method: RpcMethod, + params: P, + ): R = HermesWsClient.call(method, params) +} + +object RpcMethods { + val SESSION_EVENTS_SINCE: RpcMethod = + RpcMethod( + WsMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams.serializer(), + SessionEventsSinceResult.serializer(), + ) + + val SESSION_CREATE: RpcMethod = + RpcMethod( + WsMethods.SESSION_CREATE, + SessionCreateParams.serializer(), + SessionCreateResult.serializer(), + ) + + val SESSION_RESUME: RpcMethod = + RpcMethod( + WsMethods.SESSION_RESUME, + SessionResumeParams.serializer(), + SessionResumeResult.serializer(), + ) + + val PROMPT_SUBMIT: RpcMethod = + RpcMethod( + WsMethods.PROMPT_SUBMIT, + PromptSubmitParams.serializer(), + PromptSubmitResult.serializer(), + ) + + val SESSION_INTERRUPT: RpcMethod = + RpcMethod( + WsMethods.SESSION_INTERRUPT, + SessionInterruptParams.serializer(), + SessionInterruptResult.serializer(), + ) + + val SESSION_STEER: RpcMethod = + RpcMethod( + WsMethods.SESSION_STEER, + SessionCorrectionParams.serializer(), + SessionCorrectionResult.serializer(), + ) + + val SESSION_REDIRECT: RpcMethod = + RpcMethod( + WsMethods.SESSION_REDIRECT, + SessionCorrectionParams.serializer(), + SessionCorrectionResult.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SESSION_LIST: RpcMethod = + RpcMethod( + WsMethods.SESSION_LIST, + SessionListParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH, + SessionBranchParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_BRANCH_WHOLE: RpcMethod = + RpcMethod( + WsMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_COMPRESS: RpcMethod = + RpcMethod( + WsMethods.SESSION_COMPRESS, + SessionCompressParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_CONTEXT_BREAKDOWN: RpcMethod = + RpcMethod( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val SESSION_USAGE: RpcMethod = + RpcMethod( + WsMethods.SESSION_USAGE, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + val PROMPT_BTW: RpcMethod = + RpcMethod( + WsMethods.PROMPT_BTW, + PromptBtwParams.serializer(), + PromptBtwResult.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_PENDING: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_PENDING, + ApprovalPendingParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_RECEIVED: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_RECEIVED, + ApprovalReceivedParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val APPROVAL_RESPOND: RpcMethod = + RpcMethod( + WsMethods.APPROVAL_RESPOND, + ApprovalRespondParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SESSION_ACTIVE_LIST: RpcMethod = + RpcMethod( + WsMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val SUBSCRIPTION_STATE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_STATE, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_PREVIEW: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_PREVIEW, + SubscriptionPreviewParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_CHANGE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_UPGRADE: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_UPGRADE, + SubscriptionUpgradeParams.serializer(), + JsonElement.serializer(), + ) + + val SUBSCRIPTION_RESUME: RpcMethod = + RpcMethod( + WsMethods.SUBSCRIPTION_RESUME, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val USAGE_BARS: RpcMethod = + RpcMethod( + WsMethods.USAGE_BARS, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LIST: RpcMethod = + RpcMethod( + WsMethods.VAULT_LIST, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCES: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCES, + EmptyParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_SOURCE_SET: RpcMethod = + RpcMethod( + WsMethods.VAULT_SOURCE_SET, + VaultSourceSetParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_UNLOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_UNLOCK, + VaultUnlockParams.serializer(), + JsonElement.serializer(), + ) + + val VAULT_LOCK: RpcMethod = + RpcMethod( + WsMethods.VAULT_LOCK, + VaultLockParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_LIST: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_LIST, + ConnectorsListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CONNECT: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_STATUS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_STATUS, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_CATALOG: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_CATALOG, + ConnectorsCatalogParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS, + ConnectorsAccountsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_ACCOUNTS_REMOVE, + ConnectorsAccountsRemoveParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_GET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_GET, + ConnectorsPolicyGetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_POLICY_SET: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_TOOLS: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_TOOLS, + ConnectorsToolsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTION_RESPOND: RpcMethod = + RpcMethod( + WsMethods.CONNECTION_RESPOND, + ConnectionRespondParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy parsers. + val CONNECTORS_OPERATION_WAKE: RpcMethod = + RpcMethod( + WsMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SUBAGENT_LIST: RpcMethod = + RpcMethod( + WsMethods.SUBAGENT_LIST, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SUBAGENT_TAIL: RpcMethod = + RpcMethod( + WsMethods.SUBAGENT_TAIL, + SubagentTailParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROCESS_LIST, + SessionIdParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROCESS_KILL: RpcMethod = + RpcMethod( + WsMethods.PROCESS_KILL, + ProcessKillParams.serializer(), + JsonElement.serializer(), + ) + + val PROCESS_STOP: RpcMethod = + RpcMethod( + WsMethods.PROCESS_STOP, + ProcessStopParams.serializer(), + ProcessStopResult.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PLUGINS_MANAGE: RpcMethod = + RpcMethod( + WsMethods.PLUGINS_MANAGE, + PluginsManageParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROJECTS_LIST: RpcMethod = + RpcMethod( + WsMethods.PROJECTS_LIST, + ProjectsListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_LIST: RpcMethod = + RpcMethod( + WsMethods.PROFILES_LIST, + ProfilesListParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val PROFILES_CONFIGURE: RpcMethod = + RpcMethod( + WsMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CONFIG_SET: RpcMethod = + RpcMethod( + WsMethods.CONFIG_SET, + ConfigSetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CONFIG_GET: RpcMethod = + RpcMethod( + WsMethods.CONFIG_GET, + ConfigGetParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val MODEL_OPTIONS: RpcMethod = + RpcMethod( + WsMethods.MODEL_OPTIONS, + ModelOptionsParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val CLIENT_CAPABILITIES: RpcMethod = + RpcMethod( + WsMethods.CLIENT_CAPABILITIES, + ClientCapabilitiesParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val COMMANDS_CATALOG: RpcMethod = + RpcMethod( + WsMethods.COMMANDS_CATALOG, + CommandsCatalogParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val COMMAND_DISPATCH: RpcMethod = + RpcMethod( + WsMethods.COMMAND_DISPATCH, + CommandDispatchParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val SLASH_EXEC: RpcMethod = + RpcMethod( + WsMethods.SLASH_EXEC, + SlashExecParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val FILE_ATTACH: RpcMethod = + RpcMethod( + WsMethods.FILE_ATTACH, + FileAttachParams.serializer(), + JsonElement.serializer(), + ) + + // Passthrough JsonElement results: callers keep their existing legacy decoders. + val IMAGE_ATTACH_BYTES: RpcMethod = + RpcMethod( + WsMethods.IMAGE_ATTACH_BYTES, + ImageAttachBytesParams.serializer(), + JsonElement.serializer(), + ) + + /** Every typed method. GatewayContractTest iterates this; future migrations append here. */ + val all: List> = + listOf( + SESSION_EVENTS_SINCE, + SESSION_CREATE, + SESSION_RESUME, + PROMPT_SUBMIT, + SESSION_INTERRUPT, + SESSION_STEER, + SESSION_REDIRECT, + SESSION_LIST, + SESSION_BRANCH, + SESSION_BRANCH_WHOLE, + SESSION_COMPRESS, + SESSION_CONTEXT_BREAKDOWN, + SESSION_USAGE, + PROMPT_BTW, + APPROVAL_PENDING, + APPROVAL_RECEIVED, + APPROVAL_RESPOND, + SESSION_ACTIVE_LIST, + SUBSCRIPTION_STATE, + SUBSCRIPTION_PREVIEW, + SUBSCRIPTION_CHANGE, + SUBSCRIPTION_UPGRADE, + SUBSCRIPTION_RESUME, + USAGE_BARS, + VAULT_LIST, + VAULT_SOURCES, + VAULT_SOURCE_SET, + VAULT_UNLOCK, + VAULT_LOCK, + CONNECTORS_LIST, + CONNECTORS_CONNECT, + CONNECTORS_OPERATION_STATUS, + CONNECTORS_CATALOG, + CONNECTORS_ACCOUNTS, + CONNECTORS_ACCOUNTS_REMOVE, + CONNECTORS_POLICY_GET, + CONNECTORS_POLICY_SET, + CONNECTORS_TOOLS, + CONNECTION_RESPOND, + CONNECTORS_OPERATION_WAKE, + SUBAGENT_LIST, + SUBAGENT_TAIL, + PROCESS_LIST, + PROCESS_KILL, + PROCESS_STOP, + PLUGINS_MANAGE, + PROJECTS_LIST, + PROFILES_LIST, + PROFILES_CONFIGURE, + CONFIG_SET, + CONFIG_GET, + MODEL_OPTIONS, + CLIENT_CAPABILITIES, + COMMANDS_CATALOG, + COMMAND_DISPATCH, + SLASH_EXEC, + FILE_ATTACH, + IMAGE_ATTACH_BYTES, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt new file mode 100644 index 00000000..6091fc1e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionControl.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SessionInterruptParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class SessionCorrectionParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, +) + +@Serializable +data class SessionInterruptResult( + val status: String? = null, +) + +@Serializable +data class SessionCorrectionResult( + val status: String? = null, + val text: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt new file mode 100644 index 00000000..57cebe0b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSince.kt @@ -0,0 +1,20 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SessionEventsSinceParams( + @SerialName("session_id") val sessionId: String, + @SerialName("last_seen") val lastSeen: Int? = null, +) // NO profile field: profile is injected by WsProfileParams only. + +// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated. +@Serializable +data class SessionEventsSinceResult( + val events: List? = null, + @SerialName("latest_seq") val latestSeq: Int? = null, + val truncated: Boolean? = null, + val epoch: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt new file mode 100644 index 00000000..54b7e59d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionMisc.kt @@ -0,0 +1,41 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data object SessionListParams + +@Serializable +data class SessionIdParams( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class SessionBranchParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionBranchWholeParams( + @SerialName("session_id") val sessionId: String, + @SerialName("name") val name: String? = null, +) + +@Serializable +data class SessionCompressParams( + @SerialName("session_id") val sessionId: String, + @SerialName("focus_topic") val focusTopic: String? = null, +) + +@Serializable +data class PromptBtwParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, +) + +@Serializable +data class PromptBtwResult( + @SerialName("task_id") val taskId: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt new file mode 100644 index 00000000..42dcbd03 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SessionPrompt.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SessionCreateParams( + @SerialName("source") val source: String? = null, + @SerialName("profile") val profile: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("hidden") val hidden: Boolean? = null, +) + +@Serializable +data class SessionResumeParams( + @SerialName("session_id") val sessionId: String, + @SerialName("omit_messages") val omitMessages: Boolean? = null, + @SerialName("profile") val profile: String? = null, +) + +@Serializable +data class PromptSubmitParams( + @SerialName("session_id") val sessionId: String, + @SerialName("text") val text: String, + @SerialName("queued") val queued: Boolean? = null, +) + +// tolerant readers: all nullable with null defaults, only fields callers read +@Serializable +data class SessionCreateResult( + @SerialName("session_id") val sessionId: String? = null, + @SerialName("stored_session_id") val storedSessionId: String? = null, +) + +@Serializable +data class SessionResumeResult( + @SerialName("session_id") val sessionId: String? = null, +) + +@Serializable +data class PromptSubmitResult( + val status: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt new file mode 100644 index 00000000..8ecebb25 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/contract/SubagentTailParams.kt @@ -0,0 +1,10 @@ +package com.m57.hermescontrol.data.ws.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SubagentTailParams( + @SerialName("session_id") val sessionId: String, + @SerialName("subagent_id") val subagentId: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt index 01ba66a4..eec6e688 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionController.kt @@ -51,6 +51,7 @@ class BackgroundConnectionController( status = status, isAutoReconnect = AuthManager.isAutoReconnect(), hasActiveNetwork = NetworkMonitor.isConnected.value, + notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(), ) } } @@ -98,6 +99,17 @@ class BackgroundConnectionController( } } + /** The persistent opt-in was turned off while backgrounded (e.g. from the notification action). */ + fun onKeepConnectedDisabled() { + val snapshot = snapshotProvider(false) + if (!snapshot.appInForeground) { + releaseLease() + } + // Force a re-post if the service stays up (pending reply) so the action button disappears. + currentNotificationState = BackgroundNotificationState.None + reconcileState() + } + fun reconcileState() { val snapshot = snapshotProvider(false) val decision = BackgroundConnectionPolicy.evaluate(snapshot) diff --git a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt index 693108ad..216ca3da 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicy.kt @@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot( val status: ConnectionStatus, val isAutoReconnect: Boolean = true, val hasActiveNetwork: Boolean = true, + /** + * User opted into "notify when a session completes" — completions arrive + * only over a live connection, so this implies the keep-connected lease. + */ + val notifyCompletionsOptIn: Boolean = false, ) { companion object { fun ineligible( @@ -77,8 +82,12 @@ object BackgroundConnectionPolicy { ) } - // Demand check: need either a pending reply or persistent opt-in - val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn + // Demand check: need a pending reply or a persistent opt-in. The + // completions notify opt-in implies the keep-connected lease — a + // session can only be observed finishing over a live connection. + val notifyCompletionsDemand = snapshot.notifyCompletionsOptIn + val keepConnectedLease = snapshot.keepConnectedOptIn || notifyCompletionsDemand + val hasDemand = snapshot.pendingReply || keepConnectedLease if (!hasDemand) { return BackgroundConnectionDecision( shouldHoldService = false, @@ -134,7 +143,7 @@ object BackgroundConnectionPolicy { return BackgroundConnectionDecision( shouldHoldService = true, - shouldHoldPersistentLease = snapshot.keepConnectedOptIn, + shouldHoldPersistentLease = keepConnectedLease, notificationState = notificationState, ) } 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 48342326..0f3ac200 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -9,15 +9,21 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.ActiveSessionsResponse 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.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams import com.m57.hermescontrol.ui.chat.replyFailureFromPayload +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -26,6 +32,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.serializer import java.util.concurrent.atomic.AtomicBoolean internal data class MessageCompleteNotificationPlan( @@ -37,6 +46,87 @@ internal data class MessageCompleteNotificationPlan( val allowInlineReply: Boolean, ) +internal data class CompletedSessionRef( + val storedId: String, + val title: String?, +) + +private const val TAG = "ChatNotificationService" + +/** Short cap so a slow `session.active_list` cannot hold a completion alert hostage. */ +private const val ACTIVE_LIST_TIMEOUT_MS = 5_000L + +/** Where a background `message.complete` goes. */ +internal sealed interface MessageCompleteRoute { + /** The reply flow: reply notification, then the foreground service is retired. */ + data object Reply : MessageCompleteRoute + + /** A session the phone did not submit finished; the opt-in alert fires. */ + data object ForeignSession : MessageCompleteRoute +} + +/** + * Route a completion the way the service did before the opt-in existed: + * toggle OFF must keep the legacy behavior exactly — every completion uses + * the reply path, even when `ActiveSessionHolder` was cleared by a background + * reconnect (dropping the completion would both hide the reply notification + * and strand the foreground service). + */ +internal fun messageCompleteRoute( + holderSessionId: String?, + toggleOn: Boolean, + eventSessionId: String?, +): MessageCompleteRoute = + if (!toggleOn || (!eventSessionId.isNullOrBlank() && eventSessionId == holderSessionId)) { + MessageCompleteRoute.Reply + } else { + MessageCompleteRoute.ForeignSession + } + +/** + * Map a WS event's runtime session id to the STORED session row the app can + * open. The gateway emits `message.complete` with its ephemeral transport id + * (`uuid4().hex[:8]`), while History/`switchSession` use the persisted + * `session_key` — a runtime id taps into a blank chat. `session.active_list` + * lists every live session (idle included) with both ids plus the title. + * + * Accepts the typed RPC result (`JsonElement`) and its pre-JSON `Map` shape, + * mirroring `SessionLiveStatusDecoder.decodeResponse`. + */ +internal fun parseActiveSessionLookup( + raw: Any?, + runtimeId: String, +): CompletedSessionRef? { + if (runtimeId.isBlank()) return null + val element = + when (raw) { + is JsonObject -> { + raw + } + + is String -> { + runCatching { lookupJson.parseToJsonElement(raw) }.getOrNull() ?: return null + } + + else -> { + return null + } + } + val response = + runCatching { + lookupJson.decodeFromJsonElement(serializer(), element) + }.getOrNull() ?: return null + for (item in response.sessions) { + if (item.id?.trim() != runtimeId) continue + val storedId = item.sessionKey?.trim().orEmpty() + if (storedId.isBlank()) return null + return CompletedSessionRef(storedId, item.title?.trim()?.takeIf { it.isNotEmpty() }) + } + return null +} + +private val lookupJson = Json { ignoreUnknownKeys = true } + internal fun messageCompleteNotificationPlan( event: WsEvent.MessageComplete, targetSessionId: String?, @@ -106,6 +196,9 @@ class ChatNotificationService : Service() { internal const val NOTIFICATION_ID = 1 internal const val PENDING_NOTIFICATION_ID = 2 + /** Session-complete notifications: one fixed id, tagged per stored session. */ + private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000 + private val isAppInForeground = AtomicBoolean(false) internal val lifecycle = ForegroundServiceLifecycle() @@ -152,43 +245,99 @@ class ChatNotificationService : Service() { if (!isAppInForeground.get()) { when (event) { is WsEvent.MessageComplete -> { - 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 = 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 - // normal, safe outcome: the notification is then - // never auto-dismissed from REST hydration, which - // is strictly better than dismissing the wrong - // duplicate reply. - serverMessageId = - plan.correlationText?.let { - coalesceTurnRow(plan.sessionId, it) - }, - ) - // The wait is over — retire the foreground - // service. The reply notification above - // replaces the persistent "waiting" one, - // and the pendingReply flag is cleared by - // HermesWsClient's own collector, so the - // service is not restarted on the next - // ON_STOP (issue #794). - // A delayed completion must not retire a newer turn/start. - BackgroundConnectionController.default.onReplyCompleted(generation) + when ( + val route = + messageCompleteRoute( + holderSessionId = ActiveSessionHolder.activeSessionId.value, + toggleOn = AuthManager.isNotifySessionCompletions(), + eventSessionId = event.sessionId, + ) + ) { + MessageCompleteRoute.Reply -> { + 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 = 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 + // normal, safe outcome: the notification is then + // never auto-dismissed from REST hydration. + serverMessageId = + plan.correlationText?.let { + coalesceTurnRow(plan.sessionId, it) + }, + ) + // The wait is over: retire the foreground service + // (issue #794). A delayed completion must not + // retire a newer turn/start. + BackgroundConnectionController.default.onReplyCompleted(generation) + } + + MessageCompleteRoute.ForeignSession -> { + // Another session finished. Resolve its runtime + // id to the STORED session row (the gateway's + // transport id means nothing to History/chat); + // without the stored id the tap would open a + // blank chat, so skip instead. The notification + // is plain — no inline reply, no reply-tracker + // registration — and the service keeps listening + // for the next completion (the opt-in demand + // holds it). + val ref = + try { + parseActiveSessionLookup( + HermesWsClient.call( + RpcMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams(), + timeoutMs = ACTIVE_LIST_TIMEOUT_MS, + ), + event.sessionId.orEmpty(), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (ref != null) { + // A failed turn must not read as success. + val failure = + replyFailureFromPayload( + event.rawPayload, + fallback = "Unknown gateway error", + ) + val snippet = + (failure?.details ?: event.text) + .take(100) + .replace("\n", " ") + .ifBlank { getString(R.string.notif_new_message) } + val body = + if (failure != null) { + getString(R.string.notif_session_failed, snippet) + } else { + getString(R.string.notif_session_complete, snippet) + } + showSessionCompleteNotification(ref, body) + } else { + Log.w( + TAG, + "Session-complete notification skipped: no stored id for runtime ${event.sessionId}", + ) + } + } + } } is WsEvent.ClarifyRequest -> { @@ -364,6 +513,35 @@ class ChatNotificationService : Service() { } } + /** + * A completion from a session the phone did not submit (cron, another + * client). Fires only when "notify on session completion" is on, with the + * session's STORED id (tap opens the real transcript) and its title when + * the gateway reported one. Plain alert, auto-cancel; the notification + * uses the tag overload — per-session tag with one shared id — so tags + * never collide and no session's alert can clobber a live reply + * notification or tombstone the tracker. + */ + private fun showSessionCompleteNotification( + ref: CompletedSessionRef, + body: String, + ) { + val notification = + NotificationCompat + .Builder(this, CHAT_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(ref.title ?: getString(R.string.notif_title)) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true) + .setContentIntent(buildContentIntent(ref.storedId)) + .build() + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + manager.notify(ref.storedId, SESSION_COMPLETE_NOTIFICATION_ID, notification) + } + private fun buildContentIntent(sessionId: String?): PendingIntent { val intent = Intent(this, MainActivity::class.java).apply { @@ -383,15 +561,48 @@ class ChatNotificationService : Service() { ) } - private fun buildForegroundNotification(text: String): Notification = - NotificationCompat - .Builder(this, SERVICE_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.notif_title)) - .setContentText(text) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setOngoing(true) - .build() + private fun buildForegroundNotification(text: String): Notification { + val openIntent = + Intent(this, MainActivity::class.java).apply { + setPackage(packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val builder = + NotificationCompat + .Builder(this, SERVICE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.notif_title)) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + openIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + + if (AuthManager.isKeepConnectedInBackground()) { + val stopIntent = + Intent(this, StopKeepConnectedReceiver::class.java).apply { + action = StopKeepConnectedReceiver.ACTION_STOP_KEEP_CONNECTED + setPackage(packageName) + } + builder.addAction( + R.drawable.ic_notification, + getString(R.string.notif_action_turn_off), + PendingIntent.getBroadcast( + this, + 0, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + } + return builder.build() + } private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index b776b055..c72a073e 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -11,7 +11,9 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -25,6 +27,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() { const val EXTRA_SESSION_ID = "extra_session_id" private const val REPLY_TIMEOUT_MS = 5_000L + /** Overall deadline for persisting and submitting one notification reply. */ + private const val PERSISTENCE_TIMEOUT_MS = 5_000L + /** * Turn-boundary read budget inside the reply deadline. Deliberately tight: * the probe sits on the reply path, so a slow or unreachable gateway must @@ -44,6 +49,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() { */ internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync() + /** Overridable so tests can prove the timeout path without waiting 5s. */ + internal open val persistenceTimeoutMs: Long get() = PERSISTENCE_TIMEOUT_MS + /** * Test-friendly wrapper for notification creation. Override in tests to * avoid [NotificationCompat.Builder.build()] calling Android framework @@ -75,7 +83,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() { val pendingResult = goAsyncCompat() replyScope.launch { try { - withTimeout(5000L) { + withTimeout(persistenceTimeoutMs) { withContext(Dispatchers.IO) { val db = com.m57.hermescontrol.data.local.HermesDatabase @@ -103,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() { val runtimeSessionId = ActiveSessionHolder.resolveRuntimeSessionId(sessionId) ?: resumeSession(sessionId) - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeSessionId, "text" to replyText), - timeoutMs = REPLY_TIMEOUT_MS, - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText), + timeoutMs = REPLY_TIMEOUT_MS, + ) val entity = com.m57.hermescontrol.data.local.ChatMessageEntity( @@ -146,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() { } private suspend fun resumeSession(storedSessionId: String): String { - val profile = AuthManager.activeProfileId.value + val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() } val params = - mutableMapOf( - "session_id" to storedSessionId, - "omit_messages" to true, + SessionResumeParams( + sessionId = storedSessionId, + omitMessages = true, + profile = profile, ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } val result = - HermesWsClient - .request( - WsMethods.SESSION_RESUME, - params, - timeoutMs = REPLY_TIMEOUT_MS, - ).await() as? Map<*, *> + HermesWsClient.call( + method = RpcMethods.SESSION_RESUME, + params = params, + timeoutMs = REPLY_TIMEOUT_MS, + ) val runtimeSessionId = - (result?.get("session_id") as? String)?.takeIf { it.isNotBlank() } + result.sessionId?.takeIf { it.isNotBlank() } ?: error("Resume returned no runtime session id") ActiveSessionHolder.set(runtimeSessionId, storedSessionId) return runtimeSessionId diff --git a/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt new file mode 100644 index 00000000..e1d0e787 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/notification/StopKeepConnectedReceiver.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.m57.hermescontrol.data.local.AuthManager + +/** + * Handles the "Turn off" action on the ongoing background notification: + * disables "Stay connected in background" and lets the controller retire the + * lease/service. + */ +class StopKeepConnectedReceiver : BroadcastReceiver() { + companion object { + const val ACTION_STOP_KEEP_CONNECTED = "com.m57.hermescontrol.ACTION_STOP_KEEP_CONNECTED" + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action != ACTION_STOP_KEEP_CONNECTED) return + AuthManager.setKeepConnectedInBackground(false) + BackgroundConnectionController.default.onKeepConnectedDisabled() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 3cb10951..89c05360 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -91,3 +91,6 @@ fun parseProjectColor(raw: String?): Color? { if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null return Color.hsl(h % 360f, s, l) } + +/** Pure white glyph for filled accent controls (composer action button) when it is legible on the preset primary. */ +val GlyphWhite = Color(0xFFFFFFFF) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt index 8dee742b..a569b4a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance * Material You changes the *primary* / *surface* palette based on the user's * wallpaper, but success / warning / error / info colours must stay * semantically correct (green = good, red = bad) regardless of what the - * wallpaper-derived palette would choose. This struct is provided via - * [LocalHermesStatusColors] and should be used for all status indicators, + * wallpaper-derived palette would choose. + * + * `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching + * status fills, not on their container colors. Reusable status badges use + * these fill/on pairs; error-container content uses `onErrorContainer`. + * + * Use [LocalHermesStatusColors] for all status indicators, * badges, and semantic feedback colours. */ data class HermesStatusColors( diff --git a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt index 7d145e94..e7034bf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt @@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY } * * The Material error slots are derived from [status] — the theme author * defines semantic colors once and the scheme inherits them. + * [primary] and the status error are also used directly for text on surfaces. + * [outline] must contrast with [surface] and [surfaceContainerHighest], which + * Material uses as the unchecked switch track. */ data class PaletteColors( val primary: Color, @@ -48,6 +51,20 @@ data class PaletteColors( val onSurface: Color, val surfaceVariant: Color, val onSurfaceVariant: Color, + val surfaceDim: Color, + val surfaceBright: Color, + val primaryFixed: Color, + val primaryFixedDim: Color, + val onPrimaryFixed: Color, + val onPrimaryFixedVariant: Color, + val secondaryFixed: Color, + val secondaryFixedDim: Color, + val onSecondaryFixed: Color, + val onSecondaryFixedVariant: Color, + val tertiaryFixed: Color, + val tertiaryFixedDim: Color, + val onTertiaryFixed: Color, + val onTertiaryFixedVariant: Color, val surfaceContainerLowest: Color, val surfaceContainerLow: Color, val surfaceContainer: Color, @@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, @@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index 01f7dd11..dec68b80 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -12,7 +12,8 @@ All theme code is under: ``` app/src/main/java/com/m57/hermescontrol/theme/ -├── Theme.kt # dispatcher — preset lookup + mode fallback +├── Theme.kt # dispatcher — mode fallback + dynamic colors +├── ThemeRegistry.kt # single preset/palette registry ├── PaletteTemplate.kt # the template — the ONE shape every theme follows ├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …) ├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*) @@ -24,7 +25,8 @@ app/src/main/java/com/m57/hermescontrol/theme/ ├── GruvboxScheme.kt ├── CatppuccinScheme.kt ├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY) - └── NordScheme.kt + ├── NordScheme.kt + └── GarnetScheme.kt ``` ## The template — one shape for every theme @@ -55,7 +57,14 @@ val MyTheme = The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`) are **derived** from the theme's status set — the theme author defines semantic -colors once. +colors once. `surfaceTint` comes from `primary`. Every other Material color role +is required explicitly, including bright/dim surfaces and all fixed accent roles. +No role may inherit Material baseline colors. Fixed accent and foreground roles +stay identical across a preset’s shipped modes; Fixed/Dim may share an official +swatch when the source palette has no accessible tonal ladder. + +Prefer upstream swatches. Document every derived color beside its declaration +with the official pairing’s measured contrast and why a derived role is needed. ## Preset conventions @@ -69,8 +78,41 @@ colors once. light text would fail contrast. - **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/ error/info by lightness only — consumers must pair with icons or labels. -- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by - `ThemePaletteTest` — a preset that breaks this fails the unit-test gate. +- Normal-text foreground/background pairs, including accent containers, fixed + accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast** + by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1. + +Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared +`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`) +with their matching `on*` text/icons. `onSuccess` is not a foreground token for +`successContainer`. Palette tests exercise this exact renderer mapping in every +resolved mode, including fallback modes; neutral badges use Material tokens. + +Enabled Material components also have a component-state contract: +- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and + checkbox states, slider active/inactive tracks, text-field borders, and segmented borders. +- Primary/error text and field labels on surface/background target >= 4.5:1. +- Segmented content uses the corresponding container foreground at >= 4.5:1. + +`ThemeComponentContrastTest` checks the pinned Material role combinations in every +resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose +public color defaults and verifies rendered pixels for all fourteen preset/mode +combinations, including focused/error fields. Disabled controls are deliberately +excluded from the enabled contrast contract; Material uses opacity to mute them. +Settings switches use Material defaults (`primary` track / `onPrimary` thumb), +not the reversed `primaryContainer` track / `primary` thumb combination. + +Outlines must contrast against both surface and the unchecked switch track +(`surfaceContainerHighest`). Named palettes may need documented derived primary +or error tones for direct text; fixed roles still preserve their upstream accents. + +## Garnet + +Garnet preserves Default's slate surface ladder, typography, and semantic status +colors. Its seed is `#990000`, used as the light primary and dark primary +container. Dark primary/text/icons use a lighter `#FFB4AB` for contrast; rose +secondary and copper tertiary roles, their containers, fixed accents, inverse +primary, and surface tint follow the new accent family. Both modes are shipped. ## Theme modes @@ -87,37 +129,33 @@ doesn't ship — never to a sibling preset. ## The dispatcher -`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and -a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme / -status are read off it (with the mode fallback above): +`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution, +UI selection, and palette tests use this registry. An exact enum/registry coverage +test rejects missing, duplicate, or reordered entries. Android string resources +stay in the UI’s exhaustive label mapping, outside the palette model. -```kotlin -private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) { - ThemePreset.DEFAULT -> DefaultTheme - ThemePreset.MONOCHROME -> MonochromeTheme - // …one line per preset… -} -``` +`Theme.kt` reads `preset.palette()` and applies the mode fallback above. Dynamic (Material You) color on API 31+ can optionally override the preset scheme when `useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the -active preset via `LocalHermesStatusColors`. +active preset via `LocalHermesStatusColors`. The preset selector stays enabled +and explains that it controls status colors while Material You supplies app colors. ## Adding a new theme 1. **Create** `presets/Scheme.kt` as a template fill (see above) — `buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly` for single-mode themes. Every status color must be bespoke — no aliasing. -2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val. +2. **Register** the new palette in `ThemeRegistry.kt`. 3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`. -4. **Wire the UI**: add the label + selection in +4. **Wire the UI**: add the exhaustive label mapping in `ui/settings/components/AppearanceSection.kt` (and any string resource). 5. **Verify**: ```bash ./gradlew ktlintCheck testDebugUnitTest ``` - `ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error - slot pairs and the ThemeMode invariants — it must stay green. + `ThemePaletteTest` checks text contrast, complete Material slot mapping, + mode-independent fixed roles, registry coverage, and mode/fallback invariants. ## Conventions diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index f9ce67ff..1bef1384 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -10,38 +10,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.platform.LocalContext -import com.m57.hermescontrol.theme.presets.AmoledTheme -import com.m57.hermescontrol.theme.presets.CatppuccinTheme import com.m57.hermescontrol.theme.presets.DefaultTheme -import com.m57.hermescontrol.theme.presets.GruvboxTheme -import com.m57.hermescontrol.theme.presets.MonochromeTheme -import com.m57.hermescontrol.theme.presets.NordTheme import kotlinx.serialization.Serializable @Serializable enum class ThemePreference { SYSTEM, LIGHT, DARK } @Serializable -enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD } +enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD, GARNET } val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } val LocalChatFontScale = compositionLocalOf { 1.0f } -/** - * The 6 preset themes — one file each, all built from the same - * [PaletteTemplate] shape (see `PaletteTemplate.kt`). - */ -private fun themeFor(preset: ThemePreset): ThemePalette = - when (preset) { - ThemePreset.DEFAULT -> DefaultTheme - ThemePreset.MONOCHROME -> MonochromeTheme - ThemePreset.GRUVBOX -> GruvboxTheme - ThemePreset.CATPPUCCIN -> CatppuccinTheme - ThemePreset.AMOLED -> AmoledTheme - ThemePreset.NORD -> NordTheme - } - /** * Resolve the Material 3 [ColorScheme] for a preset + dark flag. * @@ -53,7 +34,7 @@ internal fun resolveColorScheme( preset: ThemePreset, darkTheme: Boolean, ): ColorScheme { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its scheme is never null for either mode. return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme)) } @@ -66,7 +47,7 @@ internal fun resolveStatusColors( preset: ThemePreset, darkTheme: Boolean, ): HermesStatusColors { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its status colors are never null for either mode. return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme)) } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt new file mode 100644 index 00000000..20e1f5cb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.theme + +import com.m57.hermescontrol.theme.presets.AmoledTheme +import com.m57.hermescontrol.theme.presets.CatppuccinTheme +import com.m57.hermescontrol.theme.presets.DefaultTheme +import com.m57.hermescontrol.theme.presets.GarnetTheme +import com.m57.hermescontrol.theme.presets.GruvboxTheme +import com.m57.hermescontrol.theme.presets.MonochromeTheme +import com.m57.hermescontrol.theme.presets.NordTheme + +internal data class ThemeDefinition( + val preset: ThemePreset, + val palette: ThemePalette, +) + +// Regression theme-contract: selection, resolution, and tests share this registry. +internal val ThemeRegistry = + listOf( + ThemeDefinition(ThemePreset.DEFAULT, DefaultTheme), + ThemeDefinition(ThemePreset.MONOCHROME, MonochromeTheme), + ThemeDefinition(ThemePreset.GRUVBOX, GruvboxTheme), + ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme), + ThemeDefinition(ThemePreset.AMOLED, AmoledTheme), + ThemeDefinition(ThemePreset.NORD, NordTheme), + ThemeDefinition(ThemePreset.GARNET, GarnetTheme), + ) + +internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt index e8954976..7ee9b189 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt @@ -59,6 +59,20 @@ val AmoledTheme = onSurface = AmoledTextBright, surfaceVariant = AmoledContainerHigh, onSurfaceVariant = AmoledTextDim, + surfaceDim = AmoledFloor, + surfaceBright = AmoledContainerHighest, + primaryFixed = AmoledTextBright, + primaryFixedDim = AmoledTextBright, + onPrimaryFixed = AmoledFloor, + onPrimaryFixedVariant = AmoledFloor, + secondaryFixed = AmoledSecondary, + secondaryFixedDim = AmoledSecondary, + onSecondaryFixed = AmoledFloor, + onSecondaryFixedVariant = AmoledFloor, + tertiaryFixed = AmoledTertiary, + tertiaryFixedDim = AmoledTertiary, + onTertiaryFixed = AmoledFloor, + onTertiaryFixedVariant = AmoledFloor, surfaceContainerLowest = AmoledFloor, surfaceContainerLow = AmoledFloor, surfaceContainer = AmoledContainer, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt index fc23d57d..11176bfd 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt @@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8) private val MochaText = Color(0xFFCDD6F4) private val MochaSubtext1 = Color(0xFFBAC2DE) private val MochaSubtext0 = Color(0xFFA6ADC8) -private val MochaOverlay0 = Color(0xFF6C7086) private val MochaSurface1 = Color(0xFF45475A) private val MochaSurface0 = Color(0xFF313244) private val MochaBase = Color(0xFF1E1E2E) @@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B) // --------------------------------------------------------------------- private val LatteMauve = Color(0xFF8839EF) + +// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1 +// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively. +private val LatteDeepInk = Color(0xFF202334) + +// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1. +// White/Blue reaches 4.94:1 for normal text. +private val LatteOnBlue = Color(0xFFFFFFFF) private val LatteBlue = Color(0xFF1E66F5) private val LattePink = Color(0xFFEA76CB) private val LatteGreen = Color(0xFF40A02B) private val LatteYellow = Color(0xFFDF8E1D) private val LatteRed = Color(0xFFD20F39) -private val LatteText = Color(0xFF4C4F69) + +// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1). private val LatteSubtext0 = Color(0xFF6C6F85) -private val LatteOverlay0 = Color(0xFF9CA0B0) +private val LatteText = Color(0xFF4C4F69) private val LatteSurface1 = Color(0xFFBCC0CC) private val LatteSurface0 = Color(0xFFCCD0DA) private val LatteBase = Color(0xFFEFF1F5) private val LatteMantle = Color(0xFFE6E9EF) private val LatteCrust = Color(0xFFDCE0E8) -// Bespoke tint used only for the light primary container — not part of -// the official Catppuccin swatch set, kept local to this file. -private val LattePrimaryContainer = Color(0xFFE6CAFA) -private val LatteOnPrimaryContainer = Color(0xFF380075) +// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim +// share official accents rather than inventing tonal steps; on-roles use Crust. +// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1. +// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json /** Catppuccin theme — Mocha (dark) / Latte (light). */ val CatppuccinTheme = @@ -73,6 +81,20 @@ val CatppuccinTheme = onSurface = MochaText, surfaceVariant = MochaSurface0, onSurfaceVariant = MochaSubtext0, + surfaceDim = MochaBase, + surfaceBright = MochaSurface1, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = MochaCrust, surfaceContainerLow = MochaMantle, surfaceContainer = MochaBase, @@ -81,7 +103,7 @@ val CatppuccinTheme = inverseSurface = MochaText, inverseOnSurface = MochaBase, inversePrimary = LatteMauve, - outline = MochaOverlay0, + outline = MochaSubtext0, outlineVariant = MochaSurface1, scrim = MochaCrust, status = @@ -105,48 +127,62 @@ val CatppuccinTheme = PaletteColors( primary = LatteMauve, onPrimary = LatteBase, - primaryContainer = LattePrimaryContainer, - onPrimaryContainer = LatteOnPrimaryContainer, + primaryContainer = LatteBase, + onPrimaryContainer = LatteMauve, secondary = LatteBlue, - onSecondary = LatteBase, + onSecondary = LatteOnBlue, secondaryContainer = LatteMantle, onSecondaryContainer = LatteText, tertiary = LattePink, - onTertiary = LatteBase, - tertiaryContainer = LatteSurface1, + onTertiary = LatteDeepInk, + tertiaryContainer = LatteSurface0, onTertiaryContainer = LatteText, background = LatteBase, onBackground = LatteText, surface = LatteBase, onSurface = LatteText, surfaceVariant = LatteSurface0, - onSurfaceVariant = LatteSubtext0, + onSurfaceVariant = LatteText, + surfaceDim = LatteSurface0, + surfaceBright = LatteBase, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = LatteCrust, surfaceContainerLow = LatteMantle, surfaceContainer = LatteBase, surfaceContainerHigh = LatteSurface0, - surfaceContainerHighest = LatteSurface1, + surfaceContainerHighest = LatteSurface0, inverseSurface = LatteText, inverseOnSurface = LatteBase, inversePrimary = MochaMauve, - outline = LatteOverlay0, + outline = LatteSubtext0, outlineVariant = LatteSurface1, scrim = LatteCrust, status = HermesStatusColors( success = LatteGreen, successContainer = LatteSurface0, - onSuccess = LatteBase, + onSuccess = LatteDeepInk, warning = LatteYellow, warningContainer = LatteSurface0, - onWarning = LatteBase, + onWarning = LatteDeepInk, error = LatteRed, errorContainer = LatteSurface0, onError = LatteBase, - onErrorContainer = LatteRed, + onErrorContainer = LatteText, info = LatteBlue, infoContainer = LatteSurface0, - onInfo = LatteBase, + onInfo = LatteOnBlue, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt index fcb7f67c..6fc42111 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt @@ -73,6 +73,20 @@ val DefaultTheme = onSurface = SlateDarkFg1, surfaceVariant = SlateDarkBg1, onSurfaceVariant = SlateDarkFg3, + surfaceDim = SlateDarkBg0, + surfaceBright = SlateDarkBg4, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateDarkBg0, surfaceContainerLow = SlateDarkBg1, surfaceContainer = SlateDarkBg2, @@ -121,6 +135,20 @@ val DefaultTheme = onSurface = SlateLightFg1, surfaceVariant = SlateLightBg1, onSurfaceVariant = SlateLightFg3, + surfaceDim = SlateLightBg4, + surfaceBright = SlateLightBg0, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateLightBg0, surfaceContainerLow = SlateLightBg1, surfaceContainer = SlateLightBg2, @@ -129,7 +157,7 @@ val DefaultTheme = inverseSurface = SlateLightFg1, inverseOnSurface = SlateLightBg0, inversePrimary = SlateBrightTeal, - outline = SlateGray, + outline = SlateLightFg3, outlineVariant = SlateLightBg3, scrim = SlateLightBg0, status = diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt new file mode 100644 index 00000000..b9b737a3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/GarnetScheme.kt @@ -0,0 +1,185 @@ +package com.m57.hermescontrol.theme.presets + +import androidx.compose.ui.graphics.Color +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.PaletteColors +import com.m57.hermescontrol.theme.buildTheme + +// --------------------------------------------------------------------- +// Default slate surfaces retained; accents use the Garnet red family. +// Garnet Dark — named swatches +// --------------------------------------------------------------------- + +private val GarnetDarkBg0 = Color(0xFF0F1416) +private val GarnetDarkBg1 = Color(0xFF171C1E) +private val GarnetDarkBg2 = Color(0xFF1B2022) +private val GarnetDarkBg3 = Color(0xFF252B2D) +private val GarnetDarkBg4 = Color(0xFF303638) +private val GarnetDarkFg1 = Color(0xFFDEE3E5) +private val GarnetDarkFg3 = Color(0xFFBEC8CA) +private val GarnetGray = Color(0xFF889294) // same neutral gray in both modes + +// "Bright" accents — high-visibility variants against dark surfaces. +private val GarnetBrightCopper = Color(0xFFEABD96) +private val GarnetBrightRose = Color(0xFFE5BDB8) +private val GarnetBrightGreen = Color(0xFF81D692) +private val GarnetBrightYellow = Color(0xFFECC248) +private val GarnetBrightRed = Color(0xFFFFB4AB) +private val GarnetBrightInfo = Color(0xFF8ECEFF) + +// --------------------------------------------------------------------- +// Garnet Light — named swatches +// --------------------------------------------------------------------- + +private val GarnetLightBg0 = Color(0xFFF5FAFC) +private val GarnetLightBg1 = Color(0xFFEFF4F6) +private val GarnetLightBg2 = Color(0xFFE9EEF0) +private val GarnetLightBg3 = Color(0xFFE3E8EA) +private val GarnetLightBg4 = Color(0xFFDEE3E5) +private val GarnetLightFg1 = Color(0xFF171C1E) +private val GarnetLightFg3 = Color(0xFF3F484A) + +// "Faded" accents — muted variants for light background contrast. +private val GarnetAccent = Color(0xFF990000) +private val GarnetFadedCopper = Color(0xFF805531) +private val GarnetFadedRose = Color(0xFF775651) +private val GarnetFadedGreen = Color(0xFF126D2B) +private val GarnetFadedYellow = Color(0xFF725C00) +private val GarnetFadedRed = Color(0xFFBA1A1A) +private val GarnetFadedInfo = Color(0xFF006393) + +// #990000 is the seed and light primary. Its contrast on the dark canvas is only +// 2.08:1; #FFB4AB supplies readable dark-mode text/icons (10.93:1). +private val GarnetLightRedContainer = Color(0xFFFFDAD5) +private val GarnetDarkRoseContainer = Color(0xFF573935) +private val GarnetDarkCopperContainer = Color(0xFF634025) +private val GarnetLightCopperContainer = Color(0xFFFFDCC1) + +/** Garnet — Default slate surfaces with a #990000 red accent and matching warm tones. */ +val GarnetTheme = + buildTheme( + dark = + PaletteColors( + primary = GarnetBrightRed, + onPrimary = GarnetDarkBg0, + primaryContainer = GarnetAccent, + onPrimaryContainer = GarnetLightBg0, + secondary = GarnetBrightRose, + onSecondary = GarnetDarkBg0, + secondaryContainer = GarnetDarkRoseContainer, + onSecondaryContainer = GarnetBrightRose, + tertiary = GarnetBrightCopper, + onTertiary = GarnetDarkBg0, + tertiaryContainer = GarnetDarkCopperContainer, + onTertiaryContainer = GarnetBrightCopper, + background = GarnetDarkBg0, + onBackground = GarnetDarkFg1, + surface = GarnetDarkBg0, + onSurface = GarnetDarkFg1, + surfaceVariant = GarnetDarkBg1, + onSurfaceVariant = GarnetDarkFg3, + surfaceDim = GarnetDarkBg0, + surfaceBright = GarnetDarkBg4, + primaryFixed = GarnetBrightRed, + primaryFixedDim = GarnetBrightRed, + onPrimaryFixed = GarnetDarkBg0, + onPrimaryFixedVariant = GarnetDarkBg0, + secondaryFixed = GarnetBrightRose, + secondaryFixedDim = GarnetBrightRose, + onSecondaryFixed = GarnetDarkBg0, + onSecondaryFixedVariant = GarnetDarkBg0, + tertiaryFixed = GarnetBrightCopper, + tertiaryFixedDim = GarnetBrightCopper, + onTertiaryFixed = GarnetDarkBg0, + onTertiaryFixedVariant = GarnetDarkBg0, + surfaceContainerLowest = GarnetDarkBg0, + surfaceContainerLow = GarnetDarkBg1, + surfaceContainer = GarnetDarkBg2, + surfaceContainerHigh = GarnetDarkBg3, + surfaceContainerHighest = GarnetDarkBg4, + inverseSurface = GarnetDarkFg1, + inverseOnSurface = GarnetDarkBg0, + inversePrimary = GarnetAccent, + outline = GarnetGray, + outlineVariant = GarnetDarkBg3, + scrim = GarnetDarkBg0, + status = + HermesStatusColors( + success = GarnetBrightGreen, + successContainer = GarnetDarkBg3, + onSuccess = GarnetDarkBg0, + warning = GarnetBrightYellow, + warningContainer = GarnetDarkBg3, + onWarning = GarnetDarkBg0, + error = GarnetBrightRed, + errorContainer = GarnetDarkBg3, + onError = GarnetDarkBg0, + onErrorContainer = GarnetBrightRed, + info = GarnetBrightInfo, + infoContainer = GarnetDarkBg3, + onInfo = GarnetDarkBg0, + ), + ), + light = + PaletteColors( + primary = GarnetAccent, + onPrimary = GarnetLightBg0, + primaryContainer = GarnetLightRedContainer, + onPrimaryContainer = GarnetAccent, + secondary = GarnetFadedRose, + onSecondary = GarnetLightBg0, + secondaryContainer = GarnetLightRedContainer, + onSecondaryContainer = GarnetFadedRose, + tertiary = GarnetFadedCopper, + onTertiary = GarnetLightBg0, + tertiaryContainer = GarnetLightCopperContainer, + onTertiaryContainer = GarnetFadedCopper, + background = GarnetLightBg0, + onBackground = GarnetLightFg1, + surface = GarnetLightBg0, + onSurface = GarnetLightFg1, + surfaceVariant = GarnetLightBg1, + onSurfaceVariant = GarnetLightFg3, + surfaceDim = GarnetLightBg4, + surfaceBright = GarnetLightBg0, + primaryFixed = GarnetBrightRed, + primaryFixedDim = GarnetBrightRed, + onPrimaryFixed = GarnetDarkBg0, + onPrimaryFixedVariant = GarnetDarkBg0, + secondaryFixed = GarnetBrightRose, + secondaryFixedDim = GarnetBrightRose, + onSecondaryFixed = GarnetDarkBg0, + onSecondaryFixedVariant = GarnetDarkBg0, + tertiaryFixed = GarnetBrightCopper, + tertiaryFixedDim = GarnetBrightCopper, + onTertiaryFixed = GarnetDarkBg0, + onTertiaryFixedVariant = GarnetDarkBg0, + surfaceContainerLowest = GarnetLightBg0, + surfaceContainerLow = GarnetLightBg1, + surfaceContainer = GarnetLightBg2, + surfaceContainerHigh = GarnetLightBg3, + surfaceContainerHighest = GarnetLightBg4, + inverseSurface = GarnetLightFg1, + inverseOnSurface = GarnetLightBg0, + inversePrimary = GarnetBrightRed, + outline = GarnetLightFg3, + outlineVariant = GarnetLightBg3, + scrim = GarnetLightBg0, + status = + HermesStatusColors( + success = GarnetFadedGreen, + successContainer = GarnetLightBg3, + onSuccess = GarnetLightBg0, + warning = GarnetFadedYellow, + warningContainer = GarnetLightBg3, + onWarning = GarnetLightBg0, + error = GarnetFadedRed, + errorContainer = GarnetLightBg3, + onError = GarnetLightBg0, + onErrorContainer = GarnetFadedRed, + info = GarnetFadedInfo, + infoContainer = GarnetLightBg3, + onInfo = GarnetLightBg0, + ), + ), + ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt index f56965f6..36fca5a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt @@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme // Gruvbox Dark (medium contrast) — named swatches // --------------------------------------------------------------------- +// Official hard-contrast bg0; medium bg0/red only reaches 4.29:1. +private val GruvboxDarkInk = Color(0xFF1D2021) private val GruvboxDarkBg0 = Color(0xFF282828) private val GruvboxDarkBg1 = Color(0xFF3C3836) private val GruvboxDarkBg2 = Color(0xFF504945) private val GruvboxDarkBg3 = Color(0xFF665C54) -private val GruvboxDarkBg4 = Color(0xFF7C6F64) private val GruvboxDarkFg1 = Color(0xFFEBDBB2) private val GruvboxDarkFg2 = Color(0xFFD5C4A1) private val GruvboxDarkFg3 = Color(0xFFBDAE93) -private val GruvboxGray = Color(0xFF928374) // same neutral gray in both modes // "Bright" accents — the variants gruvbox uses against a dark background. -private val GruvboxBrightRed = Color(0xFFFB4934) +// Derived role, not an upstream swatch: bright red/bg0 is only 4.29:1. +// Lighter red reaches 4.92:1 for error text and field labels on bg0. +private val GruvboxErrorText = Color(0xFFFF5F4F) private val GruvboxBrightGreen = Color(0xFFB8BB26) private val GruvboxBrightYellow = Color(0xFFFABD2F) private val GruvboxBrightBlue = Color(0xFF83A598) @@ -35,7 +37,6 @@ private val GruvboxLightBg0 = Color(0xFFFBF1C7) private val GruvboxLightBg1 = Color(0xFFEBDBB2) private val GruvboxLightBg2 = Color(0xFFD5C4A1) private val GruvboxLightBg3 = Color(0xFFBDAE93) -private val GruvboxLightBg4 = Color(0xFFA89984) private val GruvboxLightFg1 = Color(0xFF3C3836) private val GruvboxLightFg2 = Color(0xFF504945) private val GruvboxLightFg3 = Color(0xFF665C54) @@ -43,28 +44,38 @@ private val GruvboxLightFg3 = Color(0xFF665C54) // "Faded" accents — the muted variants gruvbox uses against a light // background, so contrast holds up without the neon dark-mode saturation. private val GruvboxFadedRed = Color(0xFF9D0006) + +// Derived roles, not upstream Gruvbox swatches: light bg0/faded green is 4.29:1; +// white/green reaches 4.86:1. Hard dark bg0/faded yellow is below 4.5:1; +// deeper neutral ink/yellow reaches 4.97:1. +private val GruvboxOnFadedGreen = Color(0xFFFFFFFF) +private val GruvboxOnFadedYellow = Color(0xFF121212) private val GruvboxFadedGreen = Color(0xFF79740E) private val GruvboxFadedYellow = Color(0xFFB57614) private val GruvboxFadedBlue = Color(0xFF076678) private val GruvboxFadedPurple = Color(0xFF8F3F71) private val GruvboxFadedOrange = Color(0xFFAF3A03) -/** Gruvbox theme — bright accents on dark bg0–bg4, faded accents on light bg0–bg4. */ +// Regression theme-contract: fixed roles reuse bright accents in both modes; +// Fixed/Dim share a swatch. Containers stop at bg3 for >= 4.5:1 body text. +// Upstream: https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim + +/** Gruvbox theme — bright accents on dark bg0–bg3, faded accents on light bg0–bg3. */ val GruvboxTheme = buildTheme( dark = PaletteColors( primary = GruvboxBrightOrange, onPrimary = GruvboxDarkBg0, - primaryContainer = GruvboxDarkBg4, + primaryContainer = GruvboxDarkBg2, onPrimaryContainer = GruvboxDarkFg1, secondary = GruvboxBrightBlue, onSecondary = GruvboxDarkBg0, - secondaryContainer = GruvboxDarkBg3, + secondaryContainer = GruvboxDarkBg2, onSecondaryContainer = GruvboxDarkFg2, tertiary = GruvboxBrightPurple, onTertiary = GruvboxDarkBg0, - tertiaryContainer = GruvboxDarkBg4, + tertiaryContainer = GruvboxDarkBg2, onTertiaryContainer = GruvboxDarkFg1, background = GruvboxDarkBg0, onBackground = GruvboxDarkFg1, @@ -72,15 +83,29 @@ val GruvboxTheme = onSurface = GruvboxDarkFg1, surfaceVariant = GruvboxDarkBg1, onSurfaceVariant = GruvboxDarkFg3, + surfaceDim = GruvboxDarkBg0, + surfaceBright = GruvboxDarkBg3, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxDarkBg0, surfaceContainerLow = GruvboxDarkBg1, surfaceContainer = GruvboxDarkBg2, surfaceContainerHigh = GruvboxDarkBg3, - surfaceContainerHighest = GruvboxDarkBg4, + surfaceContainerHighest = GruvboxDarkBg3, inverseSurface = GruvboxDarkFg1, inverseOnSurface = GruvboxDarkBg0, inversePrimary = GruvboxFadedOrange, - outline = GruvboxGray, + outline = GruvboxDarkFg2, outlineVariant = GruvboxDarkBg3, scrim = GruvboxDarkBg0, status = @@ -91,9 +116,9 @@ val GruvboxTheme = warning = GruvboxBrightYellow, warningContainer = GruvboxDarkBg3, onWarning = GruvboxDarkBg0, - error = GruvboxBrightRed, + error = GruvboxErrorText, errorContainer = GruvboxDarkBg3, - onError = GruvboxDarkBg0, + onError = GruvboxDarkInk, onErrorContainer = GruvboxDarkFg1, info = GruvboxBrightBlue, infoContainer = GruvboxDarkBg3, @@ -104,15 +129,15 @@ val GruvboxTheme = PaletteColors( primary = GruvboxFadedOrange, onPrimary = GruvboxLightBg0, - primaryContainer = GruvboxLightBg4, + primaryContainer = GruvboxLightBg2, onPrimaryContainer = GruvboxLightFg1, secondary = GruvboxFadedBlue, onSecondary = GruvboxLightBg0, - secondaryContainer = GruvboxLightBg3, + secondaryContainer = GruvboxLightBg2, onSecondaryContainer = GruvboxLightFg2, tertiary = GruvboxFadedPurple, onTertiary = GruvboxLightBg0, - tertiaryContainer = GruvboxLightBg4, + tertiaryContainer = GruvboxLightBg2, onTertiaryContainer = GruvboxLightFg1, background = GruvboxLightBg0, onBackground = GruvboxLightFg1, @@ -120,29 +145,43 @@ val GruvboxTheme = onSurface = GruvboxLightFg1, surfaceVariant = GruvboxLightBg1, onSurfaceVariant = GruvboxLightFg3, + surfaceDim = GruvboxLightBg3, + surfaceBright = GruvboxLightBg0, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxLightBg0, surfaceContainerLow = GruvboxLightBg1, surfaceContainer = GruvboxLightBg2, surfaceContainerHigh = GruvboxLightBg3, - surfaceContainerHighest = GruvboxLightBg4, + surfaceContainerHighest = GruvboxLightBg3, inverseSurface = GruvboxLightFg1, inverseOnSurface = GruvboxLightBg0, inversePrimary = GruvboxBrightOrange, - outline = GruvboxGray, + outline = GruvboxLightFg2, outlineVariant = GruvboxLightBg3, scrim = GruvboxLightBg0, status = HermesStatusColors( success = GruvboxFadedGreen, successContainer = GruvboxLightBg3, - onSuccess = GruvboxLightBg0, + onSuccess = GruvboxOnFadedGreen, warning = GruvboxFadedYellow, warningContainer = GruvboxLightBg3, - onWarning = GruvboxLightBg0, + onWarning = GruvboxOnFadedYellow, error = GruvboxFadedRed, errorContainer = GruvboxLightBg3, onError = GruvboxLightBg0, - onErrorContainer = GruvboxFadedRed, + onErrorContainer = GruvboxLightFg1, info = GruvboxFadedBlue, infoContainer = GruvboxLightBg3, onInfo = GruvboxLightBg0, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt index f4e1d13f..866a851b 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt @@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer -private val MonoLightOutline = Color(0xFF808080) private val MonoLightOutlineVariant = Color(0xFFCCCCCC) private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error @@ -71,6 +70,20 @@ val MonochromeTheme = onSurface = MonoDarkTextBright, surfaceVariant = MonoDarkContainerHigh, onSurfaceVariant = MonoDarkTextDim, + surfaceDim = MonoDarkBg, + surfaceBright = MonoDarkContainerHighest, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoDarkFloor, surfaceContainerLow = MonoDarkBg, surfaceContainer = MonoDarkContainer, @@ -112,13 +125,27 @@ val MonochromeTheme = tertiary = MonoLightTertiary, onTertiary = MonoLightBg, tertiaryContainer = MonoLightContainerHigh, - onTertiaryContainer = MonoLightTertiary, + onTertiaryContainer = MonoLightSecondary, background = MonoLightBg, onBackground = MonoLightTextBright, surface = MonoLightBg, onSurface = MonoLightTextBright, surfaceVariant = MonoLightContainerHigh, onSurfaceVariant = MonoLightTextDim, + surfaceDim = MonoLightContainerHighest, + surfaceBright = MonoLightBg, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoLightCeiling, surfaceContainerLow = MonoLightBg, surfaceContainer = MonoLightContainer, @@ -127,7 +154,7 @@ val MonochromeTheme = inverseSurface = MonoLightTextBright, inverseOnSurface = MonoLightBg, inversePrimary = MonoDarkTextBright, - outline = MonoLightOutline, + outline = MonoLightTertiary, outlineVariant = MonoLightOutlineVariant, scrim = Color.Black, status = @@ -144,7 +171,7 @@ val MonochromeTheme = onErrorContainer = MonoLightTextBright, info = MonoLightInfo, infoContainer = MonoLightContainerHigh, - onInfo = MonoLightBg, + onInfo = MonoLightTextBright, ), ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt index 599bdda1..b702227e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt @@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme // supply the surfaces; Frost (7–10) and Aurora (11–15) supply the // accents. Unlike Gruvbox, Nord defines one accent set — not a // separate bright/faded pair per mode — so the same accent swatches -// are reused in both palettes below. +// are reused for fixed roles; primary/error need documented accessible tones. // --------------------------------------------------------------------- +// Derived role, not an upstream Nord swatch: nord0/red is 3.05:1 and +// nord0/purple is 4.41:1. Darker Polar Night ink meets 4.5:1 on both. +private val NordPolarInk = Color(0xFF0F1218) private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest private val Nord1 = Color(0xFF3B4252) private val Nord2 = Color(0xFF434C5E) @@ -23,11 +26,23 @@ private val Nord5 = Color(0xFFE5E9F0) private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info -private val Nord11 = Color(0xFFBF616A) // Aurora red — error + +// Derived roles, not upstream Nord swatches. Nord10/Snow Storm is only 3.50:1; +// deeper Frost ink reaches 4.92:1 on nord6 for primary text/focus labels. +private val NordFrostInk = Color(0xFF486991) + +// Official Aurora red/nord0 is 3.05:1; a brighter red reaches 4.67:1. +private val NordErrorBright = Color(0xFFDE858F) + +// Official Aurora red/nord6 is 3.55:1; deeper red reaches 5.69:1. +private val NordErrorInk = Color(0xFFA53547) private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary +// Fixed/Dim reuse each official accent in both modes (no invented tonal ladder). +// Upstream: https://www.nordtheme.com/docs/colors-and-palettes/ + /** * Nord theme. * @@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary * tiers on each side share a value (surfaceContainerHigh == * surfaceContainerHighest) rather than inventing an off-palette hex. * - * Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/ - * onTertiary/status "on" colors use Polar Night (dark text) in *both* - * modes — reusing Snow Storm for light-mode "on" text (the convention in + * Pastel Frost/Aurora fills use dark ink in both modes — reusing + * Snow Storm for light-mode "on" text (the convention in * the Catppuccin/Gruvbox presets) would fail contrast against colors like * Aurora yellow, which stays light even without a separate light-mode - * variant. + * variant. Light primary/error use Snow Storm ink on their darker fills. */ val NordTheme = buildTheme( @@ -56,7 +70,7 @@ val NordTheme = secondaryContainer = Nord2, onSecondaryContainer = Nord4, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord3, onTertiaryContainer = Nord6, background = Nord0, @@ -65,6 +79,20 @@ val NordTheme = onSurface = Nord6, surfaceVariant = Nord1, onSurfaceVariant = Nord4, + surfaceDim = Nord0, + surfaceBright = Nord3, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord0, surfaceContainerLow = Nord1, surfaceContainer = Nord2, @@ -72,8 +100,8 @@ val NordTheme = surfaceContainerHighest = Nord3, inverseSurface = Nord6, inverseOnSurface = Nord0, - inversePrimary = Nord8, - outline = Nord3, + inversePrimary = NordFrostInk, + outline = Nord4, outlineVariant = Nord2, scrim = Nord0, status = @@ -84,9 +112,9 @@ val NordTheme = warning = Nord13, warningContainer = Nord2, onWarning = Nord0, - error = Nord11, + error = NordErrorBright, errorContainer = Nord2, - onError = Nord0, + onError = NordPolarInk, onErrorContainer = Nord6, info = Nord9, infoContainer = Nord2, @@ -95,8 +123,8 @@ val NordTheme = ), light = PaletteColors( - primary = Nord8, - onPrimary = Nord0, + primary = NordFrostInk, + onPrimary = Nord6, primaryContainer = Nord4, onPrimaryContainer = Nord0, secondary = Nord9, @@ -104,7 +132,7 @@ val NordTheme = secondaryContainer = Nord5, onSecondaryContainer = Nord1, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord4, onTertiaryContainer = Nord0, background = Nord6, @@ -113,6 +141,20 @@ val NordTheme = onSurface = Nord0, surfaceVariant = Nord5, onSurfaceVariant = Nord2, + surfaceDim = Nord4, + surfaceBright = Nord6, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord6, surfaceContainerLow = Nord5, surfaceContainer = Nord5, @@ -132,10 +174,10 @@ val NordTheme = warning = Nord13, warningContainer = Nord5, onWarning = Nord0, - error = Nord11, + error = NordErrorInk, errorContainer = Nord5, - onError = Nord0, - onErrorContainer = Nord11, + onError = Nord6, + onErrorContainer = Nord0, info = Nord9, infoContainer = Nord5, onInfo = Nord0, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index 124af95b..d7439f2c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -16,7 +16,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams +import com.m57.hermescontrol.data.ws.contract.ProfilesListParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.CoroutineDispatcher @@ -29,6 +31,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement @@ -243,14 +247,8 @@ class BotsViewModel( // First try fetching profiles via WebSocket RPC (profiles.list) which includes ui_meta (groups, custom avatars). var profilesWithMeta: List? = null try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = OkHttpProvider.json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { profilesWithMeta = resp.profiles @@ -362,7 +360,13 @@ class BotsViewModel( ), ) val botSoul = composeBotSoul(name, title, description) - wsClientConfigureBot(name, botMeta, soul = botSoul) + // Fire-and-forget as before: the metadata write never gated the reload or onSuccess. + launch { + try { + wsClientConfigureBot(name, botMeta, soul = botSoul) + } catch (_: Exception) { + } + } loadBots() onSuccess() } else { @@ -396,7 +400,7 @@ class BotsViewModel( ), ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } loadBots() @@ -440,7 +444,7 @@ class BotsViewModel( group = existingGroups.firstOrNull() ?: groupName, ) try { - wsClientConfigureBot(name, updatedMeta).await() + wsClientConfigureBot(name, updatedMeta) } catch (_: Exception) { } } @@ -475,14 +479,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = updatedDeleted, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } catch (_: Exception) { } @@ -509,7 +512,7 @@ class BotsViewModel( group = filtered.firstOrNull(), ) try { - wsClientConfigureBot(bot.name, updatedMeta).await() + wsClientConfigureBot(bot.name, updatedMeta) } catch (_: Exception) { } } @@ -540,14 +543,13 @@ class BotsViewModel( rooms = updatedRooms, deleted = deletedMap, ) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = mapOf("hermes-bots-groups" to newSnapshot.toMap()).toJsonObject(), + ), + ) } } } catch (_: Exception) { @@ -575,11 +577,11 @@ class BotsViewModel( return lines.joinToString("\n") } - private fun wsClientConfigureBot( + private suspend fun wsClientConfigureBot( name: String, meta: BotRosterMeta, soul: String? = null, - ): kotlinx.coroutines.CompletableDeferred { + ) { val metaMap = buildMap { meta.title?.let { put("title", it) } @@ -599,16 +601,15 @@ class BotsViewModel( } } - val params = - buildMap { - put("name", name) - put("ui_meta", mapOf("hermes-bots" to metaMap)) - soul?.let { put("soul", it) } - } - - return HermesWsClient.request( - WsMethods.PROFILES_CONFIGURE, - params, + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = name, + uiMeta = mapOf("hermes-bots" to metaMap).toJsonObject(), + soul = soul, + ), ) } + + private fun Map.toJsonObject(): JsonObject = toJsonElement() as JsonObject } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 8d6e7fd5..52940d82 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -16,7 +16,12 @@ import com.m57.hermescontrol.data.remote.safeApiCall 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.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams +import com.m57.hermescontrol.data.ws.contract.ProfilesListParams +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary import kotlinx.coroutines.CompletableDeferred @@ -30,6 +35,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import java.util.UUID @@ -323,14 +330,8 @@ class GroupChatViewModel( private suspend fun fetchProfiles(): List { try { - val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() - val jsonElement = - when (rpcResult) { - is JsonElement -> rpcResult - null -> null - else -> rpcResult.toJsonElement() - } - if (jsonElement != null) { + val jsonElement = HermesWsClient.call(RpcMethods.PROFILES_LIST, ProfilesListParams) + if (jsonElement !is JsonNull) { val resp = json.decodeFromJsonElement(jsonElement) if (!resp.profiles.isNullOrEmpty()) { return resp.profiles @@ -530,14 +531,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") } @@ -559,17 +559,24 @@ class GroupChatViewModel( val title = "Group: $groupName" try { - val createParams = - buildMap { - put("profile", bot.name) - put("title", title) - put("source", "desktop") - put("hidden", true) - } - val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams) - val res = deferred.await() - val sessionInfo = extractSessionInfo(res) - if (sessionInfo != null) { + val res = + HermesWsClient.call( + method = RpcMethods.SESSION_CREATE, + params = + SessionCreateParams( + source = "desktop", + profile = bot.name, + title = title, + hidden = true, + ), + ) + val runtimeId = res.sessionId?.takeIf { it.isNotBlank() } + if (runtimeId != null) { + val sessionInfo = + MemberSession( + runtimeSessionId = runtimeId, + storedSessionId = res.storedSessionId, + ) memberSessions[bot.name] = sessionInfo return sessionInfo } @@ -625,14 +632,13 @@ class GroupChatViewModel( val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) return true } catch (e: Exception) { @@ -701,11 +707,10 @@ class GroupChatViewModel( // turn boundary can be proven for them. They stay uncorrelated // (reply notifications from group turns are never auto-dismissed // from REST hydration) instead of borrowing another turn's bound. - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeId, "text" to prompt), - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeId, text = prompt), + ) } catch (submitErr: Exception) { Log.w( "GroupChatViewModel", @@ -735,11 +740,10 @@ class GroupChatViewModel( storedId?.let { inFlightTurns[it] = turnDeferred } try { - HermesWsClient - .request( - WsMethods.PROMPT_SUBMIT, - mapOf("session_id" to runtimeId, "text" to prompt), - ).await() + HermesWsClient.call( + method = RpcMethods.PROMPT_SUBMIT, + params = PromptSubmitParams(sessionId = runtimeId, text = prompt), + ) } catch (retryErr: Exception) { Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}") return null @@ -901,8 +905,8 @@ class GroupChatViewModel( for (sid in activeSessions.distinct()) { try { HermesWsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to sid), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(sid), ) } catch (e: Exception) { Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}") @@ -1184,14 +1188,13 @@ class GroupChatViewModel( val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) - HermesWsClient - .request( - WsMethods.PROFILES_CONFIGURE, - mapOf( - "name" to defaultProfile.name, - "ui_meta" to uiMetaPayload, - ), - ).await() + HermesWsClient.call( + RpcMethods.PROFILES_CONFIGURE, + ProfilesConfigureParams( + name = defaultProfile.name, + uiMeta = uiMetaPayload.toJsonElement() as JsonObject, + ), + ) } catch (e: Exception) { Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") } @@ -1202,19 +1205,4 @@ class GroupChatViewModel( val clean = text.trim().lowercase() return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass." } - - private fun extractSessionInfo(response: Any?): MemberSession? { - if (response is Map<*, *>) { - val runtimeId = - response["session_id"]?.toString()?.trim('\"', ' ') - ?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ') - val storedId = - response["stored_session_id"]?.toString()?.trim('\"', ' ') - ?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ') - if (!runtimeId.isNullOrBlank()) { - return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId) - } - } - return null - } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt new file mode 100644 index 00000000..da12f70c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/BrowserReturnTracker.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.ui.chat + +/** Tracks a launched browser across pause/resume and screen disposal (issue #1321). */ +internal class BrowserReturnTracker { + data class Returned( + val operationId: String?, + ) + + private var inFlight = false + private var departed = false + private var operationId: String? = null + + fun start(operationId: String? = null) { + this.operationId = operationId + inFlight = true + departed = false + } + + fun onPause() { + if (inFlight) departed = true + } + + /** Only an actual departure followed by resume counts as returning from the browser. */ + fun onResume(): Returned? { + if (!inFlight || !departed) return null + val returned = Returned(operationId) + cancel() + return returned + } + + /** Drop the tracking after a failed launch; the lifecycle guard handles the failed lease. */ + fun cancel() { + inFlight = false + departed = false + operationId = null + } + + /** Non-configuration disposal must release an outstanding departed activity lease. */ + fun abandon(): Boolean { + val outstanding = inFlight && departed + cancel() + return outstanding + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt index 32d68d66..313e57de 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ApprovalPendingParams +import com.m57.hermescontrol.data.ws.contract.ApprovalReceivedParams +import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +27,7 @@ class ChatApprovalsDelegate( private val ioDispatcher: CoroutineDispatcher, private val uiState: MutableStateFlow, private val runtimeSessionId: () -> String?, - private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val rpc: TypedRpcSender, private val trackRequest: (id: String, method: String) -> Unit, private val addSystemMessage: (text: String) -> Unit, private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, @@ -99,13 +104,12 @@ class ChatApprovalsDelegate( if (event.serverRequestId == null && requestId != null && sessionId != null) { scope.launch(ioDispatcher) { runCatching { - wsSend( - WsMethods.APPROVAL_RECEIVED, - mapOf( - "session_id" to sessionId, - "request_id" to requestId, + rpc.send( + RpcMethods.APPROVAL_RECEIVED, + ApprovalReceivedParams( + sessionId = sessionId, + requestId = requestId, ), - null, ) } } @@ -115,9 +119,9 @@ class ChatApprovalsDelegate( fun replayPendingApproval(sessionId: String) { val targetSessionId = runtimeSessionId() ?: sessionId scope.launch(ioDispatcher) { - wsSend( - WsMethods.APPROVAL_PENDING, - mapOf("session_id" to targetSessionId), + rpc.send( + RpcMethods.APPROVAL_PENDING, + ApprovalPendingParams(sessionId = targetSessionId), ) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) } } } @@ -190,16 +194,14 @@ class ChatApprovalsDelegate( ) return@launch } - val params = - mutableMapOf( - "session_id" to sessionId, - "choice" to choice, - "all" to false, - ) - if (requestId != null) params["request_id"] = requestId - wsSend( - WsMethods.APPROVAL_RESPOND, - params, + rpc.send( + RpcMethods.APPROVAL_RESPOND, + ApprovalRespondParams( + sessionId = sessionId, + choice = choice, + all = false, + requestId = requestId, + ), ) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) } // The queue can hold more pendings — surface the next one. replayPendingApproval(sessionId) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt deleted file mode 100644 index 106360c2..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ /dev/null @@ -1,696 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.ClipData -import android.text.format.DateFormat -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.theme.DarkOnSurface -import com.m57.hermescontrol.theme.HermesStatusColors -import com.m57.hermescontrol.theme.LightOnSurface -import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.theme.onColorFor -import com.m57.hermescontrol.util.BidiUtils -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -/** - * The user-message bubble — the universal anchor in the full-bleed chat - * renderer (issue #866). Agent prose renders full-bleed; user messages keep - * this bubble so the conversation stays scannable. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ChatBubble( - message: ChatMessage, - searchQuery: String = "", - isCurrentMatch: Boolean = false, - onOpenAttachment: (Attachment) -> Unit = {}, - onSaveAttachment: (Attachment) -> Unit = {}, - savingAttachmentPath: String? = null, - openingAttachmentPath: String? = null, - canSaveAttachment: Boolean = true, - onImageClick: (ImageViewerModel) -> Unit = {}, - messageStatsEnabled: Boolean = false, - showUserMessageTokens: Boolean = true, - modifier: Modifier = Modifier, -) { - val screenWidth = LocalConfiguration.current.screenWidthDp.dp - val maxBubbleWidth = screenWidth * 0.80f - - AnimatedVisibility( - visible = true, - enter = - fadeIn() + - expandVertically( - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ), - ) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - - // Copy feedback: briefly show ✓ then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - val statusColors = LocalHermesStatusColors.current - - val highlightedText = - remember(message.content, searchQuery, isCurrentMatch, statusColors) { - if (searchQuery.isNotBlank()) { - buildHighlightedString( - message.content, - searchQuery, - isCurrentMatch, - statusColors, - ) - } else { - AnnotatedString(message.content) - } - } - Box( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.CenterEnd, - ) { - val primary = MaterialTheme.colorScheme.primary - val userBubbleTextColor = - if (primary.luminance() > 0.5f) { - if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { - MaterialTheme.colorScheme.onPrimary - } else { - LightOnSurface - } - } else { - if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { - MaterialTheme.colorScheme.onPrimary - } else { - DarkOnSurface - } - } - Box { - Surface( - modifier = - Modifier - .widthIn(max = maxBubbleWidth) - .clip( - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = 16.dp, - bottomEnd = 4.dp, - ), - ).background(color = primary) - .testTag("chat_bubble_user"), - color = Color.Transparent, - tonalElevation = 0.dp, - ) { - val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } - val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { - Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { - SelectionContainer { - Text( - text = highlightedText, - color = userBubbleTextColor, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = - if (isRtl) { - TextDirection.Rtl - } else { - TextDirection.Ltr - }, - ), - ) - } - // Render inline attachments - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = userBubbleTextColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - Spacer(modifier = Modifier.height(4.dp)) - } - } - if (!message.isStreaming) { - FlowRow( - modifier = - Modifier - .align(Alignment.End) - .padding(top = 4.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(null, message.content)), - ) - } - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(12.dp), - tint = userBubbleTextColor.copy(alpha = 0.7f), - ) - } - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = - formatTimestamp( - message.timestamp, - DateFormat.is24HourFormat(LocalContext.current), - ), - color = userBubbleTextColor.copy(alpha = 0.6f), - style = MaterialTheme.typography.labelSmall, - ) - if (messageStatsEnabled && showUserMessageTokens && - message.tokenCount != null && message.tokenCount > 0 - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_token_count"), - ) - } - } - } - } - } - } - } - } - } - } -} - -@Composable -private fun SelfImprovementReviewCard( - content: String, - modifier: Modifier = Modifier, -) { - val cleanText = - content - .removePrefix("💾") - .replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "") - .trim() - val isSkill = - cleanText.contains("skill", ignoreCase = true) || - cleanText.contains("SKILL.md", ignoreCase = true) - val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology - val title = - if (isSkill) { - "Self-Improvement Review • Skill Patched" - } else { - "Self-Improvement Review • Memory Updated" - } - - Card( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag("self_improvement_review_card"), - shape = RoundedCornerShape(10.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(Modifier.height(4.dp)) - Text( - text = cleanText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun SystemBubble( - message: ChatMessage, - onRespondApproval: (String) -> Unit = {}, - modifier: Modifier = Modifier, -) { - if (message.content.contains("Self-improvement review:", ignoreCase = true)) { - SelfImprovementReviewCard(content = message.content, modifier = modifier) - return - } - - val approvalInfo = message.approvalInfo - var confirmAlways by remember(message.id) { mutableStateOf(false) } - - Column( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = message.content, - style = - MaterialTheme.typography.bodySmall.copy( - fontStyle = FontStyle.Italic, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - - // Approval action buttons — dynamic from backend `choices` - // (desktop `approval.tsx` parity: once/session/always/deny, - // smart-denied → once/deny only, allowPermanent=false hides Always). - if (approvalInfo != null) { - Spacer(Modifier.height(8.dp)) - val rawChoices = approvalInfo.choices ?: listOf("once", "deny") - val visibleChoices = - rawChoices.filter { choice -> - when (choice) { - "always" -> approvalInfo.allowPermanent != false - else -> true - } - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (choice in visibleChoices) { - when (choice) { - "deny" -> { - FilledTonalButton( - onClick = { onRespondApproval("deny") }, - modifier = - Modifier - .height(36.dp) - .testTag("deny_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Deny") - } - } - - "session" -> { - FilledTonalButton( - onClick = { onRespondApproval("session") }, - modifier = - Modifier - .height(36.dp) - .testTag("session_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Allow session") - } - } - - "always" -> { - FilledTonalButton( - onClick = { confirmAlways = true }, - modifier = - Modifier - .height(36.dp) - .testTag("always_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text("Always allow") - } - } - - else -> { - // "once" (+ legacy "approve" → normalized to once in VM) - FilledTonalButton( - onClick = { onRespondApproval(choice) }, - modifier = - Modifier - .height(36.dp) - .testTag("approve_button"), - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), - ) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(if (choice == "approve") "Approve" else "Run") - } - } - } - } - } - // "Always allow" persists the pattern permanently — confirm first - // (desktop confirm-modal parity). - if (confirmAlways) { - AlertDialog( - onDismissRequest = { confirmAlways = false }, - title = { Text("Always allow this command?") }, - text = { - Text( - "This persists the pattern permanently so future " + - "matching commands run without asking.", - ) - }, - confirmButton = { - TextButton( - onClick = { - confirmAlways = false - onRespondApproval("always") - }, - ) { - Text("Always allow") - } - }, - dismissButton = { - TextButton(onClick = { confirmAlways = false }) { - Text("Cancel") - } - }, - ) - } - } - } -} - -internal fun formatTimestamp( - timestamp: Long, - is24Hour: Boolean, -): String { - val pattern = if (is24Hour) "HH:mm" else "h:mm a" - return DateTimeFormatter - .ofPattern(pattern) - .withZone(ZoneId.systemDefault()) - .format(Instant.ofEpochMilli(timestamp)) -} - -/** - * Build an AnnotatedString with search matches highlighted. - */ -private fun buildHighlightedString( - text: String, - query: String, - isCurrentMatch: Boolean = false, - statusColors: HermesStatusColors, -): AnnotatedString { - val (highlightColor, highlightText) = - if (isCurrentMatch) { - statusColors.warning to statusColors.onWarning - } else { - statusColors.warningContainer to onColorFor(statusColors.warningContainer) - } - return buildAnnotatedString { - var i = 0 - while (i < text.length) { - val matchEnd = text.indexOf(query, i, ignoreCase = true) - if (matchEnd == -1) { - // No more matches — append the rest - append(text.substring(i)) - i = text.length - } else { - // Append text before the match - if (matchEnd > i) { - append(text.substring(i, matchEnd)) - } - // Append the match highlighted - withStyle(SpanStyle(background = highlightColor, color = highlightText)) { - append(text.substring(matchEnd, matchEnd + query.length)) - } - i = matchEnd + query.length - } - } - } -} - -/** - * Renders an attachment inline inside a chat bubble. - * Images are displayed as thumbnails; other files show a compact card. - */ -@Composable -internal fun InlineAttachment( - attachment: Attachment, - textColor: Color, - onOpen: (Attachment) -> Unit, - onSave: (Attachment) -> Unit, - savingPath: String?, - openingPath: String?, - canSave: Boolean, - onImageClick: (ImageViewerModel) -> Unit, -) { - val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name - val isSaving = savingPath != null && savingPath == attachmentPath - val isOpening = openingPath != null && openingPath == attachmentPath - val clickable = Modifier.clickable { onOpen(attachment) } - if (attachment.isVideo || attachment.isAudio) { - var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) } - com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( - uri = attachment.gatewayUrl ?: attachment.uri, - title = attachment.name, - mimeType = attachment.mimeType, - onFullScreenClick = { showMediaDialog = true }, - ) - if (showMediaDialog) { - com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( - mediaUri = attachment.gatewayUrl ?: attachment.uri, - title = attachment.name, - mimeType = attachment.mimeType, - onDismissRequest = { showMediaDialog = false }, - ) - } - } else if (attachment.isImage) { - // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation. - com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( - model = attachment.uri, - contentDescription = attachment.name, - isGif = attachment.isGif, - onClick = { - onImageClick( - ImageViewerModel( - model = attachment.uri, - name = attachment.name, - mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType, - ), - ) - }, - ) - } else { - // Non-image file — native save picker on the trailing action. - Surface( - shape = RoundedCornerShape(12.dp), - color = textColor.copy(alpha = 0.08f), - border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)), - modifier = clickable, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - modifier = Modifier.padding(9.dp).size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = attachment.name, - color = textColor, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = attachment.formattedSize, - color = textColor.copy(alpha = 0.6f), - style = MaterialTheme.typography.labelSmall, - ) - } - if (isOpening) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(10.dp)) - } - if (attachment.gatewayUrl != null) { - IconButton( - onClick = { onSave(attachment) }, - enabled = canSave, - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.chat_save_attachment, attachment.name), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index 92bce3f6..aa031ec6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -84,7 +84,7 @@ class ChatClarifyDelegate( ChatMessage( role = MessageRole.USER, content = displayContent, - displayKind = "clarify_response", + displayKind = DisplayKind.CLARIFY_RESPONSE, ) uiState.update { state -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt index 14ab2ba6..b541d561 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegate.kt @@ -3,14 +3,27 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectorError import com.m57.hermescontrol.data.ws.ConnectorParser -import com.m57.hermescontrol.data.ws.ConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +sealed interface ConnectionOperationRequest { + data class Respond( + val params: ConnectionRespondParams, + ) : ConnectionOperationRequest + + data class Wake( + val params: ConnectorsOperationStatusParams, + ) : ConnectionOperationRequest +} + sealed class ConnectionPendingAction { abstract val opId: String abstract val observedSeq: Long @@ -45,10 +58,7 @@ data class ConnectionOperationUiState( ) fun interface ConnectionOperationRequester { - suspend fun request( - method: String, - params: Map, - ): Any? + suspend fun request(action: ConnectionOperationRequest): Any? } internal class ConnectionResumeCheckpoint( @@ -163,19 +173,20 @@ class ChatConnectionOperationDelegate( observedSeq = current.seq, ), ) ?: return - val answer = - buildMap { - put("name", target) - put("status", if (approved) "approved" else "skipped") - if (safeEnv.isNotEmpty()) put("env", safeEnv) - } + val answerTarget = + ConnectionAnswerTarget( + name = target, + status = if (approved) "approved" else "skipped", + env = safeEnv.takeIf { it.isNotEmpty() }, + ) dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("targets" to listOf(answer)), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(targets = listOf(answerTarget)), + ), ), ) } @@ -183,12 +194,13 @@ class ChatConnectionOperationDelegate( suspend fun continueOperation() { val snapshot = begin(ConnectionPendingAction.Continue(currentOp(), currentSeq())) ?: return dispatch( - method = WsMethods.CONNECTION_RESPOND, - params = - mapOf( - "owner" to ownerParams(), - "op_id" to snapshot.opId, - "result" to mapOf("settled_by" to "continue"), + action = + ConnectionOperationRequest.Respond( + ConnectionRespondParams( + owner = owner(), + opId = snapshot.opId, + result = ConnectionAnswer(settledBy = "continue"), + ), ), ) } @@ -198,36 +210,40 @@ class ChatConnectionOperationDelegate( if (current.opId != expectedOpId) return val snapshot = begin(ConnectionPendingAction.Wake(current.opId, current.seq)) ?: return dispatch( - method = WsMethods.CONNECTORS_OPERATION_WAKE, - params = mapOf("owner" to ownerParams(), "op_id" to snapshot.opId), + action = + ConnectionOperationRequest.Wake( + ConnectorsOperationStatusParams( + owner = owner(), + opId = snapshot.opId, + ), + ), clearOnSuccess = true, unknownOperationSettles = true, ) } private suspend fun dispatch( - method: String, - params: Map, + action: ConnectionOperationRequest, clearOnSuccess: Boolean = false, unknownOperationSettles: Boolean = false, ) { - val action = _state.value.pendingAction + val pending = _state.value.pendingAction val actionGeneration = generation try { - requester.request(method, params) + requester.request(action) // Success only acknowledges receipt. Keep the exactly-once lock until // a newer authoritative snapshot advances the operation sequence. - if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == action) { + if (clearOnSuccess && generation == actionGeneration && _state.value.pendingAction == pending) { _state.value = _state.value.copy(pendingAction = null) } } catch (error: CancellationException) { throw error } catch (error: Exception) { if (unknownOperationSettles && error is HermesWsClient.HermesRpcException && error.code == 4004) { - if (generation == actionGeneration && _state.value.pendingAction == action) { - action?.opId?.let(::markSettled) + if (generation == actionGeneration && _state.value.pendingAction == pending) { + pending?.opId?.let(::markSettled) } - } else if (generation == actionGeneration && _state.value.pendingAction == action) { + } else if (generation == actionGeneration && _state.value.pendingAction == pending) { _state.value = _state.value.copy( pendingAction = null, @@ -269,8 +285,8 @@ class ChatConnectionOperationDelegate( snapshot.sessionId == sessionId } - private fun ownerParams(): Map = - if (accountOwned) mapOf("type" to "account") else ConnectorRepository.sessionOwner(checkNotNull(sessionId)) + private fun owner(): ConnectorOwner = + if (accountOwned) ConnectorOwner.account() else ConnectorOwner.session(checkNotNull(sessionId)) private fun currentOp(): String = _state.value.operation diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt index 6aff6dab..f3052293 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatConnectorsViewModel.kt @@ -28,6 +28,17 @@ class ChatConnectorsViewModel( ) val uiState: StateFlow = delegateInstance.uiState + private val browserReturnTracker = BrowserReturnTracker() + + fun browserLaunched() = browserReturnTracker.start() + + fun browserLaunchFailed() = browserReturnTracker.cancel() + + fun browserPaused() = browserReturnTracker.onPause() + + fun browserReturned(): Boolean = browserReturnTracker.onResume() != null + + fun abandonBrowser(): Boolean = browserReturnTracker.abandon() init { var lastContext: Triple? = null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt index 53d45508..69732b47 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatListEntries.kt @@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER = */ internal fun ChatMessage.isUserTurnBoundary(): Boolean = role == MessageRole.USER && - (displayKind == null || displayKind == "steer" || displayKind == "clarify_response") && + (displayKind == null || displayKind == DisplayKind.STEER || displayKind == DisplayKind.CLARIFY_RESPONSE) && !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt index 6e2bab35..a4f8b891 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMediaDelegate.kt @@ -60,6 +60,7 @@ class ChatMediaDelegate( size = 0, gatewayUrl = url, source = AttachmentSource.GATEWAY, + contentOffset = item.offset, ) } if (newAttachments.isEmpty()) return @@ -272,15 +273,3 @@ class ChatMediaDelegate( uiState.update { it.copy(openError = null) } } } - -internal fun sameMessages( - left: List, - right: List, -): Boolean = - left.size == right.size && - left.zip(right).all { (a, b) -> - a.id == b.id && - a.role == b.role && - a.content == b.content && - a.reasoningText == b.reasoningText - } 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 badd3eb2..485cc931 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 @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.MessageReaction import java.util.UUID /** @@ -113,11 +114,13 @@ data class ChatMessage( * Scoped to the owning profile store; transient. */ val serverRowId: Long? = null, + /** Emoji tapbacks on this message (REST `display_metadata` or live `message.reaction`); transient. */ + val reactions: List = emptyList(), ) /** 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-") } + get() = restId ?: id.takeIf(RestMessageId::isRest) /** A persisted RUNNING snapshot is not evidence of current tool activity. */ internal val ChatMessage.isToolRunning: Boolean diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt new file mode 100644 index 00000000..a4d782c3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentity.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Known `display_kind` wire values (#1336). [ChatMessage.displayKind] stays a raw + * string because the backend can add kinds; these names keep the ones the app + * interprets in one place. + */ +internal object DisplayKind { + const val STEER = "steer" + const val CLARIFY_RESPONSE = "clarify_response" + const val LOCAL_FEEDBACK = "local_feedback" + const val MODEL_SWITCH = "model_switch" + const val PERSONALITY_SWITCH = "personality_switch" + const val AUTO_CONTINUE = "auto_continue" + const val ASYNC_DELEGATION_COMPLETE = "async_delegation_complete" + const val SKILL_INVOCATION = "skill_invocation" + const val INTERNAL_NOTIFICATION = "internal_notification" + const val MAX_ITERATIONS_REACHED = "max_iterations_reached" + + /** Kinds that tag real user or local rows rather than timeline markers. */ + val nonMarkerKinds = setOf(STEER, CLARIFY_RESPONSE, LOCAL_FEEDBACK) +} + +/** + * The single owner of the REST transcript row-id format (#1336): + * `rest--`, where rowKey is the server row id under newest-anchored + * paging or an absolute position under legacy paging. + */ +internal object RestMessageId { + private const val PREFIX = "rest-" + + fun of( + sessionId: String, + rowKey: Any, + ): String = "$PREFIX$sessionId-$rowKey" + + fun sessionPrefix(sessionId: String): String = "$PREFIX$sessionId-" + + fun isRest(id: String): Boolean = id.startsWith(PREFIX) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 092e8a9a..9840d544 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -12,7 +12,8 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ModelCatalogStore -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.toAny import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -51,7 +52,7 @@ class ChatModelSwitchDelegate( private val ioDispatcher: CoroutineDispatcher, private val uiState: MutableStateFlow, private val runtimeSessionId: () -> String?, - private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val wsSend: (params: ConfigSetParams, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, private val addAssistantMessage: (text: String) -> Unit, private val handleSlashCommand: (command: String) -> Unit, @@ -64,7 +65,7 @@ class ChatModelSwitchDelegate( { refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) }, private val getPinnedModels: () -> List = { AuthManager.getPinnedModels() }, private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, - private val wsRequest: (suspend (method: String, params: Map) -> Any?)? = null, + private val wsRequest: (suspend (params: ConfigSetParams) -> JsonElement)? = null, ) { private val pendingModelSwitchRequests = ConcurrentHashMap() private val pendingFastSwitchRequests = ConcurrentHashMap() @@ -300,20 +301,15 @@ class ChatModelSwitchDelegate( unconfirmedTargetModel = spec val switchSeq = ++modelSwitchSequence val params = - mutableMapOf( - "key" to "model", - "value" to spec, - "session_id" to sessionId, + ConfigSetParams( + key = "model", + value = spec, + sessionId = sessionId, + confirmExpensiveModel = true.takeIf { confirmExpensive }, ) - if (confirmExpensive) { - params["confirm_expensive_model"] = true - } scope.launch(ioDispatcher) { - wsSend( - WsMethods.CONFIG_SET, - params, - ) { id -> - trackRequest(id, WsMethods.CONFIG_SET) + wsSend(params) { id -> + trackRequest(id, RpcMethods.CONFIG_SET.name) pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq) } } @@ -408,14 +404,13 @@ class ChatModelSwitchDelegate( uiState.update { it.copy(isFastModeChanging = true) } scope.launch(ioDispatcher) { wsSend( - WsMethods.CONFIG_SET, - mapOf( - "key" to "fast", - "value" to if (target) "fast" else "normal", - "session_id" to sessionId, + ConfigSetParams( + key = "fast", + value = if (target) "fast" else "normal", + sessionId = sessionId, ), ) { id -> - trackRequest(id, WsMethods.CONFIG_SET) + trackRequest(id, RpcMethods.CONFIG_SET.name) pendingFastSwitchRequests[id] = ActiveFastSwitch(target) } } @@ -539,18 +534,18 @@ class ChatModelSwitchDelegate( scope.launch(ioDispatcher) { try { val params = - mapOf( - "key" to "reasoning", - "value" to level, - "session_id" to sessionId, - "scope" to scopeName, + ConfigSetParams( + key = "reasoning", + value = level, + sessionId = sessionId, + scope = scopeName, ) val result = if (wsRequest != null) { - wsRequest.invoke(WsMethods.CONFIG_SET, params) + wsRequest.invoke(params) } else { - wsSend(WsMethods.CONFIG_SET, params) { reqId -> - trackRequest(reqId, WsMethods.CONFIG_SET) + wsSend(params) { reqId -> + trackRequest(reqId, RpcMethods.CONFIG_SET.name) } null } @@ -558,14 +553,7 @@ class ChatModelSwitchDelegate( if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) { val ackLevel = if (wsRequest != null) { - // HermesWsClient.request() completes with the RAW JsonElement. - // Tests and a few injected callers may provide an already-decoded map. - val map = - when (result) { - is JsonElement -> result.toAny() as? Map<*, *> - is Map<*, *> -> result - else -> null - } + val map = result?.toAny() as? Map<*, *> val resKey = map?.get("key") as? String val resVal = map?.get("value") as? String if (resKey != "reasoning" || resVal.isNullOrEmpty()) { 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 1568e8dd..b0b53a94 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 @@ -106,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip +import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard @@ -122,6 +123,7 @@ import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptActions import com.m57.hermescontrol.ui.common.ActionProgressDialog import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner @@ -167,6 +169,7 @@ fun ChatScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val timelineState by viewModel.timelineState.collectAsStateWithLifecycle() + val transcriptState by viewModel.transcriptState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() @@ -180,42 +183,26 @@ fun ChatScreen( val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current - var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } - var browserAuthDeparted by rememberSaveable { mutableStateOf(false) } - var connectionBrowserOperationId by remember { mutableStateOf(null) } - var connectionBrowserDeparted by remember { mutableStateOf(false) } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> { - if (browserAuthInFlight) { - browserAuthDeparted = true - } - if (connectionBrowserOperationId != null) { - connectionBrowserDeparted = true - } + connectorsViewModel.browserPaused() + viewModel.connectionBrowserPaused() connectorsViewModel.onPause() } Lifecycle.Event.ON_RESUME -> { viewModel.refreshSettings() connectorsViewModel.onResume() - val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted - val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } + val legacyBrowserReturned = connectorsViewModel.browserReturned() + val operationId = viewModel.connectionBrowserReturned() if (legacyBrowserReturned || operationId != null) { ExternalActivityLifecycleGuard.externalActivityReturned() } - if (legacyBrowserReturned) { - browserAuthInFlight = false - browserAuthDeparted = false - } - if (operationId != null) { - connectionBrowserOperationId = null - connectionBrowserDeparted = false - viewModel.wakeConnectionOperation(operationId) - } + operationId?.let(viewModel::wakeConnectionOperation) } else -> {} @@ -230,18 +217,11 @@ fun ChatScreen( .firstOrNull() val isChangingConfigs = activity?.isChangingConfigurations == true if (!isChangingConfigs) { - val externalActivityOutstanding = - (browserAuthInFlight && browserAuthDeparted) || - (connectionBrowserOperationId != null && connectionBrowserDeparted) - if (externalActivityOutstanding) { + val legacyOutstanding = connectorsViewModel.abandonBrowser() + val connectionOutstanding = viewModel.abandonConnectionBrowser() + if (legacyOutstanding || connectionOutstanding) { ExternalActivityLifecycleGuard.externalActivityReturned() } - if (browserAuthInFlight && browserAuthDeparted) { - browserAuthInFlight = false - browserAuthDeparted = false - } - connectionBrowserOperationId = null - connectionBrowserDeparted = false connectorsViewModel.onPause() connectorsViewModel.hide() } @@ -349,8 +329,7 @@ fun ChatScreen( } LaunchedEffect(timelineState.historyAnchorRowId) { if (timelineState.isHistorical && displayedMessages.isNotEmpty()) { - scrollController.pauseFollowing() - listState.scrollToItem(0) + scrollController.jumpToHistoryStart() } } val showScrollToBottom by remember(timelineState.isHistorical, displayedMessages) { @@ -375,7 +354,6 @@ fun ChatScreen( viewModel.consumeComposerTextRestore() } } - var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } LaunchedEffect(showSubagentInspectionSheet) { @@ -413,21 +391,17 @@ fun ChatScreen( addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } launchExternalActivity { - browserAuthDeparted = false - browserAuthInFlight = true + connectorsViewModel.browserLaunched() context.startActivity(intent) } } catch (_: ActivityNotFoundException) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } catch (_: SecurityException) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } catch (_: Exception) { - browserAuthInFlight = false - browserAuthDeparted = false + connectorsViewModel.browserLaunchFailed() connectorsViewModel.launchError(browserLaunchError) } } @@ -759,63 +733,41 @@ fun ChatScreen( // Full-bleed chat renderer (issue #866) — the single chat // surface since the bubble renderer was removed. FullBleedChatList( - messages = displayedMessages, - streamingState = if (timelineState.isHistorical) StreamingState() else streamingState, - isAgentTyping = state.isAgentTyping && !timelineState.isHistorical, + transcript = + transcriptState.copy( + savingAttachmentPath = pendingSavePath ?: transcriptState.savingAttachmentPath, + speakingMessageId = speakingMessageId, + ), + actions = + TranscriptActions( + onLoadOlder = viewModel::loadOlderMessages, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + onImageClick = { viewingImage = it }, + onRespondApproval = viewModel::respondToApproval, + onRespondClarify = viewModel::respondToClarify, + onRespondClarifyBatch = viewModel::respondToClarifyBatch, + onDismissClarify = viewModel::dismissClarify, + onRespondVaultUnlock = viewModel::respondToVaultUnlock, + onDismissVaultUnlock = viewModel::dismissVaultUnlock, + onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, + onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, + onRespondVaultCode = viewModel::respondToVaultCode, + onDismissVaultCode = viewModel::dismissVaultCode, + onToggleSpeak = { message -> + val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" + speechController.toggle( + SpeechRequest( + scopeKey = scopeKey, + messageId = message.id, + text = SpeechText.stripMarkdownForSpeech(message.content), + ), + ) + }, + ), searchState = searchState, - typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical, - typingEffectDelayMs = state.typingEffectDelayMs, - messageStatsEnabled = state.messageStatsEnabled, - showUserMessageTokens = state.showUserMessageTokens, - showAssistantMessageTokens = state.showAssistantMessageTokens, - showTokensPerSecond = state.showTokensPerSecond, - maxToolCallsPerTurn = state.maxToolCallsPerTurn, - isLoading = state.isLoading && !timelineState.isHistorical, - isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical, - hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical, - pagingSessionId = - state.currentSessionId?.let { currentSessionId -> - if (timelineState.isHistorical) { - currentSessionId + ":history:" + timelineState.historyAnchorRowId - } else { - currentSessionId - } - }, listState = listState, scrollController = scrollController, - lastAnimatedMessageId = lastAnimatedMessageId, - onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, - viewModel = viewModel, - clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical }, - onRespondClarify = viewModel::respondToClarify, - onRespondClarifyBatch = viewModel::respondToClarifyBatch, - onDismissClarify = viewModel::dismissClarify, - vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultUnlock = viewModel::respondToVaultUnlock, - onDismissVaultUnlock = viewModel::dismissVaultUnlock, - vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, - onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, - vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical }, - onRespondVaultCode = viewModel::respondToVaultCode, - onDismissVaultCode = viewModel::dismissVaultCode, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, - openingAttachmentPath = state.openingAttachmentPath, - isCompressing = state.isCompressing && !timelineState.isHistorical, - compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical }, - onImageClick = { viewingImage = it }, - speakingMessageId = speakingMessageId, - onToggleSpeak = { message -> - val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}" - speechController.toggle( - SpeechRequest( - scopeKey = scopeKey, - messageId = message.id, - text = SpeechText.stripMarkdownForSpeech(message.content), - ), - ) - }, replyErrorContent = state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> { @@ -917,6 +869,7 @@ fun ChatScreen( isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote, isRecordingVoice = mediaLaunchers.isRecordingVoice, isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked, + amplitudeProvider = mediaLaunchers.amplitudeProvider, onStopGeneration = { viewModel.interruptSession() }, isAgentTyping = state.isAgentTyping, canInterrupt = state.canInterrupt, @@ -1069,9 +1022,11 @@ fun ChatScreen( } viewingImage?.let { image -> - ImageViewerDialog( - image = image, - onDismiss = { viewingImage = null }, + MediaViewerDialog( + mediaUri = image.model, + onDismissRequest = { viewingImage = null }, + title = image.name.ifBlank { null }, + mimeType = image.mimeType, ) } @@ -1084,8 +1039,7 @@ fun ChatScreen( if (ConnectorUrlValidator.isValidHttpsUrl(url)) { try { launchExternalActivity { - connectionBrowserOperationId = operationId - connectionBrowserDeparted = false + viewModel.connectionBrowserLaunched(operationId) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -1093,8 +1047,7 @@ fun ChatScreen( context.startActivity(intent) } } catch (_: Exception) { - connectionBrowserOperationId = null - connectionBrowserDeparted = false + viewModel.connectionBrowserLaunchFailed() scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt index 0548af40..8ed80979 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt @@ -1,5 +1,38 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow +import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache + +/** The rendered transcript surface where a search occurrence belongs. */ +enum class SearchTarget { + CONTENT, + REASONING, + TOOL, +} + +/** Search only the tool header and collapsed summary; raw payload is hidden until expanded. */ +internal fun searchableToolText(message: ChatMessage): String { + val view = ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning) + val summary = view?.let { composeToolSummaryLines(it, message.toolName) } + return listOfNotNull( + (view?.serverDisplayName ?: message.toolName)?.takeIf { it.isNotBlank() }, + summary?.first, + summary?.second, + message.progressPreview?.takeIf { message.isToolRunning && it.isNotBlank() }, + ).joinToString("\n") +} + +internal fun searchTextFor( + message: ChatMessage, + target: SearchTarget, +): String = + when (target) { + SearchTarget.CONTENT -> message.content + SearchTarget.REASONING -> message.reasoningText + SearchTarget.TOOL -> searchableToolText(message) + } + /** * One search hit: which message it lives in and where in that message's * visible content the word starts (character offset). The offset lets the @@ -8,6 +41,7 @@ package com.m57.hermescontrol.ui.chat data class SearchMatch( val messageIndex: Int, val contentOffset: Int, + val target: SearchTarget = SearchTarget.CONTENT, ) /** Result of a search scan, including the exact total before any cap. */ @@ -24,28 +58,11 @@ data class SearchResult( */ class ChatSearchController { /** - * Find all WORD OCCURRENCES of [query] in the conversation's VISIBLE - * text — user bubbles and agent prose only — flattened into one entry per - * occurrence, carrying each hit's character offset in its message. - * - * Tool rows, system events, and reasoning text are excluded: - * - they are not visible prose the user is looking for, so matches there - * were invisible (nothing highlighted) yet still navigated/scrolled to - * - tool payloads dominate the chat's byte weight, so scanning them made - * search slow on long sessions - * Reasoning lives in [ChatMessage.reasoningText] (not `content`), so it - * was never matched — this keeps it that way explicitly. - * - * Matching is a literal, allocation-light [String.indexOf] scan - * (`ignoreCase = true`) instead of a regex — queries are always quoted - * literally, so regex was pure overhead: one MatchResult allocation per - * hit plus a compiled Pattern per query. Scanning the ORIGINAL content - * keeps offsets exact (no lowercase transform, which can change string - * length on some scripts). - * - * Result is capped at [MAX_SEARCH_MATCHES] entries to bound allocations - * and navigation cost on degenerate queries (single letter, "the"); - * [SearchResult.totalMatches] still reports the exact total. + * Find literal occurrences in rendered user/assistant prose, reasoning, + * and the visible tool header/summary. Never scan hidden raw tool payloads. + * Offsets are relative to each target surface, not the whole message. + * Scans the original strings so case folding cannot shift offsets; stores + * at most [MAX_SEARCH_MATCHES] hits while retaining the exact total. */ fun findMatches( messages: List, @@ -54,19 +71,43 @@ class ChatSearchController { if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false) val result = mutableListOf() var total = 0 - for ((idx, message) in messages.withIndex()) { - if (message.role != MessageRole.USER && message.role != MessageRole.ASSISTANT) continue + + fun scan( + index: Int, + text: String, + target: SearchTarget, + ) { var from = 0 while (true) { - val hit = message.content.indexOf(query, from, ignoreCase = true) + val hit = text.indexOf(query, from, ignoreCase = true) if (hit < 0) break total++ - if (result.size < MAX_SEARCH_MATCHES) { - result.add(SearchMatch(idx, hit)) - } + if (result.size < MAX_SEARCH_MATCHES) result.add(SearchMatch(index, hit, target)) from = hit + query.length } } + for ((idx, message) in messages.withIndex()) { + when (message.role) { + MessageRole.USER -> { + if (!message.isTimelineMarker() && !message.isSyntheticSystemRow()) { + scan(idx, message.content, SearchTarget.CONTENT) + } + } + + MessageRole.ASSISTANT -> { + if (message.reasoningText.isNotBlank()) scan(idx, message.reasoningText, SearchTarget.REASONING) + if (message.content.isNotBlank()) scan(idx, message.content, SearchTarget.CONTENT) + } + + MessageRole.TOOL -> { + scan(idx, searchableToolText(message), SearchTarget.TOOL) + } + + MessageRole.SYSTEM -> { + // System rows are not part of in-chat search. + } + } + } return SearchResult(result, total, capped = total > MAX_SEARCH_MATCHES) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt index 913de983..97453ff5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt @@ -74,6 +74,7 @@ class ChatSearchDelegate( searchState.matchIndices = result.matches.map { m -> m.messageIndex } searchState.matchOffsets = result.matches.map { m -> m.contentOffset } + searchState.matchTargets = result.matches.map { m -> m.target } searchState.matchTotal = result.totalMatches searchState.matchCapped = result.capped searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1 @@ -106,6 +107,7 @@ class ChatSearchDelegate( private fun resetMatches() { searchState.matchIndices = emptyList() searchState.matchOffsets = emptyList() + searchState.matchTargets = emptyList() searchState.matchTotal = 0 searchState.matchCapped = false searchState.currentIndex = -1 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt new file mode 100644 index 00000000..09720764 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.onColorFor + +/** Highlight literal search hits in plain transcript labels, reasoning, and tool summaries. */ +internal fun buildHighlightedString( + text: String, + query: String, + isCurrentMatch: Boolean = false, + statusColors: HermesStatusColors, +): AnnotatedString { + if (query.isEmpty()) return AnnotatedString(text) + val (highlightColor, highlightText) = + if (isCurrentMatch) { + statusColors.warning to statusColors.onWarning + } else { + statusColors.warningContainer to onColorFor(statusColors.warningContainer) + } + return buildAnnotatedString { + var i = 0 + while (i < text.length) { + val matchStart = text.indexOf(query, i, ignoreCase = true) + if (matchStart == -1) { + append(text.substring(i)) + break + } + append(text.substring(i, matchStart)) + withStyle(SpanStyle(background = highlightColor, color = highlightText)) { + append(text.substring(matchStart, matchStart + query.length)) + } + i = matchStart + query.length + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt index fd6d4114..c5eb0116 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt @@ -34,6 +34,10 @@ class ChatSearchState { var matchOffsets by mutableStateOf(emptyList()) internal set + /** Parallel to [matchIndices]: rendered surface for each occurrence. */ + var matchTargets by mutableStateOf(emptyList()) + internal set + var currentIndex by mutableStateOf(-1) internal set 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 1e67bfc2..af73df3f 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 @@ -1,20 +1,24 @@ package com.m57.hermescontrol.ui.chat -import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.SessionMessage -import com.m57.hermescontrol.data.remote.GatewayFileClient -import com.m57.hermescontrol.notification.ReplyNotificationTracker +import com.m57.hermescontrol.notification.ReplyNotificationTarget import com.m57.hermescontrol.ui.chat.tool.ToolJson import kotlinx.serialization.json.JsonObject /** * Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s. * - * Reads the supplied transcript snapshot and recovers notification identity through - * ReplyNotificationTracker's synchronized lookup. Safe on the history dispatcher; - * it never mutates ViewModel state or dismisses a notification. + * Pure: every input is plain data. Callers resolve the active reply-notification + * target and the gateway media URL builder, so this never touches Android, + * AuthManager, or the notification tracker (#1337). + * + * @param activeReplyTarget the reply notification currently posted, if any; its + * durable server row id reserves that row's completion identity. + * @param mediaUrl builds an authenticated gateway URL for a `MEDIA:` path, or null + * when no gateway is configured. + * @param nowMs fallback timestamp for rows without one. */ internal fun mapServerMessages( sessionId: String, @@ -24,7 +28,9 @@ internal fun mapServerMessages( liveMessages: List, isPagingOlder: Boolean = false, stableRowIds: Boolean = latestPaging, - context: android.content.Context? = null, + activeReplyTarget: ReplyNotificationTarget? = null, + mediaUrl: (path: String) -> String? = { null }, + nowMs: Long = System.currentTimeMillis(), ): List { val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id } val liveByExactId = @@ -45,9 +51,9 @@ internal fun mapServerMessages( fun restIdAt(index: Int): String = if (stableRowIds) { - "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}" + RestMessageId.of(sessionId, requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }) } else { - "rest-$sessionId-${offset + index}" + RestMessageId.of(sessionId, offset + index) } val wsCompletionIdByRestIndex = mutableMapOf() @@ -55,7 +61,7 @@ internal fun mapServerMessages( if (!isPagingOlder) { // #129: reserve durable identity before matching any repeated live prose. // REST-only recovery remains fail-closed when the target row is absent. - val activeTarget = ReplyNotificationTracker.getActiveTarget(context)?.takeIf { it.sessionId == sessionId } + val activeTarget = activeReplyTarget?.takeIf { it.sessionId == sessionId } val durableTarget = activeTarget?.takeIf { it.serverMessageId != null } if (durableTarget != null) { val exactIndex = @@ -179,7 +185,7 @@ internal fun mapServerMessages( ?.times(1000) ?.toLong() ?: existingById[restId]?.timestamp - ?: System.currentTimeMillis() + ?: nowMs val rawContent = if (role == MessageRole.TOOL && msg.display_metadata != null) { @@ -229,18 +235,11 @@ internal fun mapServerMessages( if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { val items = HostMediaExtractor.extract(rawContent) if (items.isNotEmpty()) { - val baseUrl = AuthManager.getBaseUrl() - val token = AuthManager.getToken().orEmpty() finalContent = HostMediaExtractor.strip(rawContent) attachments = items .mapNotNull { item -> - val url = - GatewayFileClient.buildMediaUrl( - baseUrl, - token, - item.path, - ) ?: return@mapNotNull null + val url = mediaUrl(item.path) ?: return@mapNotNull null Attachment( uri = url, name = mediaNameFromPath(item.path), @@ -248,6 +247,7 @@ internal fun mapServerMessages( size = 0, gatewayUrl = url, source = AttachmentSource.GATEWAY, + contentOffset = item.offset, ) }.takeIf { it.isNotEmpty() } } @@ -274,12 +274,13 @@ internal fun mapServerMessages( tokenCount = tokenCount, completionId = completionId, serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, + reactions = msg.reactions, ), ) } // REST echoes must not reserve a match before the richer WS copy of that tool. - val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") } + val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !RestMessageId.isRest(it.id) } val mappedTools = mapped.filter { it.role == MessageRole.TOOL } val matches = matchTranscriptMessages(mappedTools, liveTools) val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index 9652cf5f..2aa0191f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -2,26 +2,18 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch /** * Holds chat message-streaming state and logic, extracted from [ChatViewModel] * to keep the god-object focused on messaging/session concerns. * - * Behavior is identical to the original inline implementation: it owns the - * streaming buffers and the two token/delta handlers, and mutates the shared - * [uiState] + [streamingState] flows. The owning ViewModel keeps [streamingState] - * as the single source of truth for the reduced [StreamingState] (applied via the - * WS event reducer); this controller only writes into it for buffer-driven flushes. - * - * [isCurrentSession] and [isTestEnvironment] are injected so the controller - * stays free of ViewModel-specific context while preserving exact behavior. - * [scope] mirrors the [com.m57.hermescontrol.ui.chat.ChatSearchDelegate] seam - * and is reserved for future streaming coroutine work. + * Owns the three throttled streams and their synchronous transition flushes, and mutates + * the shared [uiState] + [streamingState] flows. The owning ViewModel keeps + * [streamingState] as the single source of truth for the reduced [StreamingState]. + * [isCurrentSession] and [isTestEnvironment] preserve the existing session + * filtering and immediate-flush test behavior. */ class ChatStreamingController( private val scope: CoroutineScope, @@ -34,36 +26,22 @@ class ChatStreamingController( const val FLUSH_INTERVAL_MS = 33L } - private val streamingBuffer = java.lang.StringBuilder() - private var lastFlushMs = 0L - private var trailingMessageFlushJob: Job? = null - - private val thinkingBuffer = java.lang.StringBuilder() - private var lastThinkingFlushMs = 0L - private var trailingThinkingFlushJob: Job? = null - - private val reasoningBuffer = java.lang.StringBuilder() - private var lastReasoningFlushMs = 0L - private var trailingReasoningFlushJob: Job? = null - private fun nowMs(): Long = System.nanoTime() / 1_000_000L + private val tokens = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushTokens) + private val thinking = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushThinking) + private val reasoning = StreamingBuffer(scope, FLUSH_INTERVAL_MS, ::nowMs, ::flushReasoning) + /** * Resets all streaming buffers. Centralizes the clear logic that was * previously scattered across MessageStart, MessageComplete, MessageDone, * ToolStart, ClarifyRequest, session switches, and interrupt handling. */ fun resetStreaming() { - trailingMessageFlushJob?.cancel() - trailingThinkingFlushJob?.cancel() - trailingReasoningFlushJob?.cancel() - flushReasoning() - streamingBuffer.clear() - thinkingBuffer.clear() - reasoningBuffer.clear() - lastFlushMs = 0L - lastThinkingFlushMs = 0L - lastReasoningFlushMs = 0L + reasoning.flushPending() + tokens.clear() + thinking.clear() + reasoning.clear() // Issue #755: the shared streaming state must not carry the previous // message's reasoning into the next one. The finalized message keeps // its own copy (persisted to Room), so clearing here is safe. @@ -84,19 +62,13 @@ class ChatStreamingController( * Used at tool.start: the reducer keeps the streaming message + its * reasoning alive across the tool call (issue #771), and the VM must * not undo that by resetting the shared streaming state. The buffers - * themselves are drained (flushPendingReasoning) before reduce, so a + * themselves are drained before reduce, so a * buffer-only clear is safe and prevents stale deltas from re-flushing. */ fun clearStreamingBuffers() { - trailingMessageFlushJob?.cancel() - trailingThinkingFlushJob?.cancel() - trailingReasoningFlushJob?.cancel() - streamingBuffer.clear() - thinkingBuffer.clear() - reasoningBuffer.clear() - lastFlushMs = 0L - lastThinkingFlushMs = 0L - lastReasoningFlushMs = 0L + tokens.clear() + thinking.clear() + reasoning.clear() } /** Resets buffers and starts a fresh streaming message (called on MessageStart). */ @@ -104,78 +76,31 @@ class ChatStreamingController( resetStreaming() } - /** - * Flushes any throttled reasoning buffer before a state-transition event - * (MessageStart / MessageComplete / MessageDone / ToolStart). Without this, - * a reasoning delta that arrived just before a transition could be dropped - * because the next flush only fires on the ~33ms timer — so we force it out - * synchronously so the finalized message carries the latest reasoning. - */ - fun flushPendingReasoning() { - trailingReasoningFlushJob?.cancel() - flushReasoning() - } - - /** - * Force-flushes any throttled TOKEN buffer onto the streaming message - * before a state-transition event (ToolStart / MessageComplete / - * MessageDone). `handleMessageToken` only flushes when >=33ms have passed - * since the last flush, so a delta that lands just before the transition - * can still be sitting in the buffer. The reducer seals the streaming - * message into a finalized orphan at tool.start (issue #842) — sealing a - * stale copy truncated the narration's tail (observed on-device: the - * orphan ended "...chickpea" while the REST row ended "...chickpea - * goodness:"), which then failed the logical-match against the clean - * REST row and re-added the commentary as a ghost bubble. Flushing the - * tokens synchronously first makes the sealed orphan complete. - */ - fun flushPendingTokens() { - trailingMessageFlushJob?.cancel() - val currentContent = streamingBuffer.toString() - if (currentContent.isEmpty()) return - lastFlushMs = nowMs() - streamingState.update { state -> - val current = state.streamingMessage ?: return@update state - state.copy( - streamingMessage = - current.copy( - content = currentContent, - reasoningText = - if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText, - ), - ) + /** Drain throttled reasoning and tokens before the reducer seals or resets the message. */ + fun flushPendingTransition() { + reasoning.flushPending() + tokens.flushPending { content -> + streamingState.update { state -> + val current = state.streamingMessage ?: return@update state + state.copy( + streamingMessage = + current.copy( + content = content, + reasoningText = + if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText, + ), + ) + } } } fun handleMessageToken(event: WsEvent.MessageToken) { if (!isCurrentSession(event.sessionId)) return - streamingBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingMessageFlushJob?.cancel() - flushTokens(now) - } else { - scheduleTrailingMessageFlush() - } + tokens.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingMessageFlush() { - if (trailingMessageFlushJob?.isActive == true) return - trailingMessageFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushTokens(nowMs()) - } - } - - private fun flushTokens(now: Long) { - val currentContent = streamingBuffer.toString() - if (currentContent.isEmpty()) return - lastFlushMs = now + private fun flushTokens(currentContent: String) { streamingState.update { state -> val current = state.streamingMessage if (current != null) { @@ -210,33 +135,10 @@ class ChatStreamingController( fun handleThinkingDelta(event: WsEvent.ThinkingDelta) { if (!isCurrentSession(event.sessionId)) return - thinkingBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = - (now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingThinkingFlushJob?.cancel() - flushThinking(now) - } else { - scheduleTrailingThinkingFlush() - } + thinking.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingThinkingFlush() { - if (trailingThinkingFlushJob?.isActive == true) return - trailingThinkingFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushThinking(nowMs()) - } - } - - private fun flushThinking(now: Long) { - val currentContent = thinkingBuffer.toString() - if (currentContent.isEmpty()) return - lastThinkingFlushMs = now + private fun flushThinking(currentContent: String) { streamingState.update { state -> state.copy( isThinking = true, @@ -248,33 +150,10 @@ class ChatStreamingController( fun handleReasoningDelta(event: WsEvent.ReasoningDelta) { if (!isCurrentSession(event.sessionId)) return - reasoningBuffer.append(event.token) - val now = nowMs() - - // Always flush in tests, or if enough time has passed - val shouldFlush = - (now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment() - if (shouldFlush) { - trailingReasoningFlushJob?.cancel() - flushReasoning(now) - } else { - scheduleTrailingReasoningFlush() - } + reasoning.append(event.token, isTestEnvironment()) } - private fun scheduleTrailingReasoningFlush() { - if (trailingReasoningFlushJob?.isActive == true) return - trailingReasoningFlushJob = - scope.launch { - delay(FLUSH_INTERVAL_MS) - flushReasoning(nowMs()) - } - } - - private fun flushReasoning(now: Long = nowMs()) { - val currentContent = reasoningBuffer.toString() - if (currentContent.isEmpty()) return - lastReasoningFlushMs = now + private fun flushReasoning(currentContent: String) { streamingState.update { state -> state.copy( isReasoning = true, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt index eaa56386..0685c0ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -27,7 +27,6 @@ class ChatSubagentsDelegate( ) { companion object { private const val TAG = "ChatSubagentsDelegate" - const val SUBAGENT_TAIL_MAX_BYTES = 16384 } private var pollingJob: Job? = null @@ -130,7 +129,6 @@ class ChatSubagentsDelegate( subagentRepository.tailSubagent( sessionId = sessionId, subagentId = subagentId, - maxBytes = SUBAGENT_TAIL_MAX_BYTES, ) if (tailResponse != null) { 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 4d0214f1..c6b38fbd 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 @@ -174,12 +174,12 @@ internal class TranscriptComparison( // Rows generated only by Mobile never have a canonical REST counterpart. // Do not let coincidentally identical server text claim their identity. if (a.role == MessageRole.ASSISTANT && - (a.displayKind == "local_feedback" || b.displayKind == "local_feedback") + (a.displayKind == DisplayKind.LOCAL_FEEDBACK || b.displayKind == DisplayKind.LOCAL_FEEDBACK) ) { return false } if (a.role == MessageRole.USER && - (a.displayKind == "clarify_response" || b.displayKind == "clarify_response") + (a.displayKind == DisplayKind.CLARIFY_RESPONSE || b.displayKind == DisplayKind.CLARIFY_RESPONSE) ) { return false } @@ -201,6 +201,9 @@ internal class TranscriptComparison( ) { return true } + // #842 / #1335: prefix drift only exists for sealed assistant narration; two REST + // rows already returned above, so user rows must never collapse by prefix. + if (a.role != MessageRole.ASSISTANT) return false return ta.length >= 40 && tb.length >= 40 && (tb.startsWith(ta) || ta.startsWith(tb)) } } @@ -320,8 +323,8 @@ internal fun dedupeCachedMessages( confirmedOnly: Boolean = false, ): List { val unique = messages.dedupeById() - val rest = unique.filter { it.id.startsWith("rest-") } - val live = unique.filterNot { it.id.startsWith("rest-") } + val rest = unique.filter { RestMessageId.isRest(it.id) } + val live = unique.filterNot { RestMessageId.isRest(it.id) } if (rest.isEmpty() || live.isEmpty()) return unique val matches = matchTranscriptMessages(rest, live).mapIndexed { index, match -> @@ -346,6 +349,7 @@ internal fun dedupeCachedMessages( message.copy( restId = it.canonicalRestId, serverRowId = message.serverRowId ?: it.serverRowId, + reactions = it.reactions.ifEmpty { message.reactions }, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, isRestoredUnconfirmed = false, @@ -402,7 +406,7 @@ internal fun mergeCachedTranscriptPage( ) } - match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> { + RestMessageId.isRest(match.id) && !RestMessageId.isRest(message.id) -> { message } @@ -416,6 +420,7 @@ internal fun mergeCachedTranscriptPage( content = preservedContent ?: rich.content, restId = match.canonicalRestId ?: message.canonicalRestId, serverRowId = match.serverRowId ?: message.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, completionId = match.completionId ?: message.completionId, displayKind = match.displayKind ?: message.displayKind, isRestoredUnconfirmed = @@ -454,13 +459,14 @@ internal fun mergeTranscriptWithLive( match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, displayKind = message.displayKind ?: match.displayKind, isHistoricalCache = false, isRestoredUnconfirmed = false, ) } - preserveLiveIds && match != null && !match.id.startsWith("rest-") -> { + preserveLiveIds && match != null && !RestMessageId.isRest(match.id) -> { val mergedContent = if (match.role == MessageRole.ASSISTANT && ChatVerifierFooter.split(match.content) != null && @@ -473,6 +479,7 @@ internal fun mergeTranscriptWithLive( match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, @@ -536,6 +543,7 @@ private fun List.inTranscriptOrder( 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 + val timedCanonical = mapNotNull { m -> m.canonicalOrder?.takeIf { it >= 0L }?.let { m.timestamp to it } } var precedingCanonical: Long? = null var hasPendingPredecessor = false var pendingLocalOrder: Long? = null @@ -547,6 +555,11 @@ private fun List.inTranscriptOrder( precedingCanonical = order hasPendingPredecessor = false pendingLocalOrder = null + } else if (message.localOrder != null && message.isPermanentlyLocal()) { + // Restored from Room, where local rows sort after every server row. Seat it by time after the + // last confirmed row that is not newer, or before the loaded window when it predates it. + localAnchors[message.id] = + timedCanonical.filter { it.first <= message.timestamp }.maxOfOrNull { it.second } ?: beforeCanonical } else if (message.isPermanentlyLocal()) { localAnchors[message.id] = if (hasPendingPredecessor) { @@ -583,8 +596,8 @@ private fun List.inTranscriptOrder( internal fun ChatMessage.isPermanentlyLocal(): Boolean = role == MessageRole.SYSTEM || - (role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) || - (role == MessageRole.ASSISTANT && displayKind == "local_feedback") + (role == MessageRole.USER && (content.startsWith("/") || displayKind == DisplayKind.CLARIFY_RESPONSE)) || + (role == MessageRole.ASSISTANT && displayKind == DisplayKind.LOCAL_FEEDBACK) internal fun ChatMessage.isSessionStartMarker(): Boolean = role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") @@ -632,6 +645,6 @@ internal fun mergeIncrementalTranscriptPage( internal fun serverMessageIndex( id: String, sessionId: String, -): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull() +): Int? = id.removePrefix(RestMessageId.sessionPrefix(sessionId)).takeIf { it != id }?.toIntOrNull() internal fun List.dedupeById(): List = associateBy { it.id }.values.toList() 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 465e4356..1cb6e707 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 @@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.model.reasoningSupport import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.safeApiCall @@ -36,10 +37,34 @@ 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.contract.CommandDispatchParams +import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams +import com.m57.hermescontrol.data.ws.contract.ConfigGetParams +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.FileAttachParams +import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams +import com.m57.hermescontrol.data.ws.contract.PromptBtwParams +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams +import com.m57.hermescontrol.data.ws.contract.SlashExecParams +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.data.ws.toJsonElement +import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.correlationScopeId +import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.common.ActionProgressController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -51,6 +76,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -441,13 +469,32 @@ class ChatViewModel( constructor(application: Application) : this(application, startCleanup = true) private val connectionOperationDelegate = - ChatConnectionOperationDelegate { method, params -> - HermesWsClient.request(method, params).await() + ChatConnectionOperationDelegate { action -> + when (action) { + is ConnectionOperationRequest.Respond -> { + HermesWsClient.call(RpcMethods.CONNECTION_RESPOND, action.params) + } + + is ConnectionOperationRequest.Wake -> { + HermesWsClient.call(RpcMethods.CONNECTORS_OPERATION_WAKE, action.params) + } + } } // ── Internal state ─────────────────────────────────────────────────── private val _uiState = MutableStateFlow(ChatUiState()) val connectionOperationState: StateFlow = connectionOperationDelegate.state + private val connectionBrowserReturnTracker = BrowserReturnTracker() + + fun connectionBrowserLaunched(operationId: String) = connectionBrowserReturnTracker.start(operationId) + + fun connectionBrowserLaunchFailed() = connectionBrowserReturnTracker.cancel() + + fun connectionBrowserPaused() = connectionBrowserReturnTracker.onPause() + + fun connectionBrowserReturned(): String? = connectionBrowserReturnTracker.onResume()?.operationId + + fun abandonConnectionBrowser(): Boolean = connectionBrowserReturnTracker.abandon() private val _streamingState = MutableStateFlow(StreamingState()) @@ -488,7 +535,7 @@ class ChatViewModel( private data class PendingBranchRequest( val generation: Long, - val params: Map, + val params: SessionBranchWholeParams, ) private val sessionRequestById = ConcurrentHashMap() @@ -655,13 +702,13 @@ class ChatViewModel( ioDispatcher = ioDispatcher, uiState = _uiState, runtimeSessionId = { runtimeSessionId }, - wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + wsSend = { params, onSent -> wsClient.send(RpcMethods.CONFIG_SET, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, addAssistantMessage = { text -> addAssistantMessage(text) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, fetchContextUsage = { fetchContextUsage() }, onModelSwitchInitiated = { onModelSwitchInitiated() }, - wsRequest = { method, params -> wsClient.request(method, params).await() }, + wsRequest = { params -> wsClient.call(RpcMethods.CONFIG_SET, params) }, ).apply { attachScopeObserver(viewModelScope) } @@ -682,7 +729,14 @@ class ChatViewModel( ioDispatcher = ioDispatcher, uiState = _uiState, runtimeSessionId = { runtimeSessionId }, - wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String = wsClient.send(method, params, onSent) + }, trackRequest = { id, method -> trackRequest(id, method) }, addSystemMessage = { text -> addSystemMessage(text) }, respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, @@ -734,6 +788,33 @@ class ChatViewModel( _uiState.value, ) + val transcriptState: StateFlow = + combine(uiState, timelineState, streamingState) { chat, timeline, streaming -> + TranscriptUiState.resolve(chat, timeline, streaming, savingAttachmentPath = null, speakingMessageId = null) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + TranscriptUiState.resolve(_uiState.value, _timelineState.value, _streamingState.value, null, null), + ) + + init { + // Parse tool payloads off the main thread before the transcript + // composes them (issue #1327); ToolBubble then reads a cache hit. + viewModelScope.launch(searchDispatcher) { + _uiState + .map { it.messages } + .distinctUntilChanged() + .conflate() + .collect { messages -> + for (message in messages) { + if (message.role == MessageRole.TOOL) { + ToolViewCache.prewarm(message.content, message.toolName, message.isToolRunning) + } + } + } + } + } + /** * Session ID to resume when the WebSocket connects. Set synchronously by * [ChatScreen] via `SideEffect` during composition — before any WS event @@ -999,13 +1080,7 @@ class ChatViewModel( is WsEvent.MessageDone, is WsEvent.ToolStart, -> { - streamingController.flushPendingReasoning() - // Issue #842: the token buffer can hold deltas that landed - // <33ms before the transition. The reducer seals the - // streaming message into the orphan at tool.start — flush - // first so the seal carries the COMPLETE narration (a - // truncated seal fails the later REST dedupe and ghosts). - streamingController.flushPendingTokens() + streamingController.flushPendingTransition() } else -> {} @@ -1042,6 +1117,9 @@ class ChatViewModel( } is WsEvent.MessageToken -> { + // The interrupted partial is already sealed. Wait for its authoritative + // completion instead of letting trailing deltas create a second stream. + if (_streamingState.value.interruptedMessage != null && isCurrentSession(event.sessionId)) return if (isCurrentSession(event.sessionId)) mainTurnBusy = true streamingController.handleMessageToken(event) } @@ -1944,9 +2022,23 @@ class ChatViewModel( 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. + streamingController.flushPendingTransition() + val interruptedStream = _streamingState.value sealStreamingMessageIfAny() + val interruptedMessage = + interruptedStream.streamingMessage?.let { stream -> + _uiState.value.messages.firstOrNull { it.id == stream.id } + } _uiState.update { it.copy(isAgentTyping = false) } - _streamingState.update { StreamingState() } + _streamingState.update { + interruptedStream.copy( + streamingMessage = null, + interruptedMessage = interruptedMessage, + isThinking = false, + thinkingText = "", + isReasoning = false, + ) + } streamingController.resetStreaming() if (status == "interrupted") addSystemMessage("Session interrupted") mainTurnBusy = false @@ -2183,12 +2275,12 @@ class ChatViewModel( (error as? JsonRpcError)?.code ?: (error as? Map<*, *>)?.get("code") as? Int if (code == -32601 && pendingBranch != null && pendingBranch.generation == sessionGeneration) { - val params = pendingBranch.params + val p = pendingBranch.params val generation = pendingBranch.generation viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH, - params, + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = p.sessionId, name = p.name), onSent = { branchId -> trackSessionRequest(branchId, WsMethods.SESSION_BRANCH, generation) }, @@ -2212,6 +2304,10 @@ class ChatViewModel( // ── Send message ───────────────────────────────────────────────────── + /** Authenticated gateway URL for a host `MEDIA:` path; kept out of the pure mapper (#1337). */ + private fun gatewayMediaUrl(path: String): String? = + GatewayFileClient.buildMediaUrl(AuthManager.getBaseUrl(), AuthManager.getToken().orEmpty(), path) + private fun sendScope(): String = listOf( AuthManager.getBaseUrl(), @@ -2411,8 +2507,8 @@ class ChatViewModel( deleteSnapshotOnRollback = true, ) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { id -> trackHardInterrupt(id, staged.id, owner, reservation.mainTurnEpoch) }, ) } @@ -2778,8 +2874,8 @@ class ChatViewModel( deleteSnapshotOnRollback = false, ) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { requestId -> trackHardInterrupt(requestId, id, owner, interruptedTurnEpoch) }, ) } @@ -3000,8 +3096,8 @@ class ChatViewModel( viewModelScope.launch { commitReceiptAndEnqueue(owner, receipt, null, deleteSnapshotOnRollback = false) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to owner.agentSessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(owner.agentSessionId), onSent = { id -> trackHardInterrupt(id, userMessage.id, owner, interruptedTurnEpoch) }, ) } @@ -3327,13 +3423,13 @@ class ChatViewModel( if (attachment.isImage) { val deferred = enqueueOwned { - wsClient.request( - WsMethods.IMAGE_ATTACH_BYTES, - mapOf( - "session_id" to owner.agentSessionId, - "content_base64" to "data:${attachment.mimeType};base64,$b64", - "filename" to attachment.name, - "ext" to attachment.fileExtension, + wsClient.requestTyped( + RpcMethods.IMAGE_ATTACH_BYTES, + ImageAttachBytesParams( + sessionId = owner.agentSessionId, + contentBase64 = "data:${attachment.mimeType};base64,$b64", + filename = attachment.name, + ext = attachment.fileExtension, ), ) } ?: return@launch @@ -3346,12 +3442,12 @@ class ChatViewModel( } 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, + wsClient.requestTyped( + RpcMethods.FILE_ATTACH, + FileAttachParams( + sessionId = owner.agentSessionId, + dataUrl = "data:${attachment.mimeType};base64,$b64", + name = attachment.name, ), ) } ?: return@launch @@ -3394,8 +3490,8 @@ class ChatViewModel( if (mode == BusySendMode.GUIDE) { enqueueOwned { wsClient.send( - WsMethods.SESSION_STEER, - mapOf("session_id" to owner.agentSessionId, "text" to fullText), + RpcMethods.SESSION_STEER, + SessionCorrectionParams(owner.agentSessionId, fullText), onSent = { id -> trackOutgoingRequest( id, @@ -3532,18 +3628,6 @@ class ChatViewModel( private fun attachmentTooLargeMessage(attachment: Attachment): String = "Attachment too large: ${attachment.name} (maximum 10 MB)" - /** - * Send a JSON-RPC call and suspend until the response arrives, delegating - * the deferred + 120s timeout to [HermesWsClient.request] (issue #526). - * Throws [HermesWsClient.HermesRpcException] on RPC error, or - * [kotlinx.coroutines.TimeoutCancellationException] if the server never - * answers within the timeout. - */ - private suspend fun sendRpcAndAwait( - method: String, - params: Map, - ): Any? = HermesWsClient.request(method, params).await() - // ── Attachment management ───────────────────────────────────────────── /** @@ -3650,6 +3734,10 @@ class ChatViewModel( interruptSession() } + is SlashResult.Stop -> { + stopSessionAndProcesses() + } + is SlashResult.NewSession -> { val currentTitle = _uiState.value.chatTitle if (currentTitle.equals("Bot Chat", ignoreCase = true)) { @@ -3787,14 +3875,10 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val result = - wsClient - .request( - WsMethods.CONFIG_GET, - mapOf( - "key" to "reasoning", - "session_id" to sessionId, - ), - ).await() + wsClient.call( + RpcMethods.CONFIG_GET, + ConfigGetParams(key = "reasoning", sessionId = sessionId), + ) val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response") val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown" @@ -3842,13 +3926,13 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val params = - buildMap { - put("key", "reasoning") - put("value", parsed.value) - put("session_id", sessionId) - parsed.scopeName?.let { put("scope", it) } - } - val result = wsClient.request(WsMethods.CONFIG_SET, params).await() + ConfigSetParams( + key = "reasoning", + value = parsed.value, + sessionId = sessionId, + scope = parsed.scopeName, + ) + val result = wsClient.call(RpcMethods.CONFIG_SET, params) val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response") val responseKey = map["key"] as? String val responseValue = map["value"] as? String @@ -3896,13 +3980,12 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val rpcResult = + val taskId = wsClient - .request( - WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to trimmed), - ).await() - val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String + .call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = sessionId, text = trimmed), + ).taskId if (!taskId.isNullOrBlank()) { _uiState.update { state -> state.btwState?.let { current -> @@ -3988,12 +4071,11 @@ class ChatViewModel( return } val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim() - val params = mutableMapOf("session_id" to sessionId) - if (arg.isNotBlank()) params["name"] = arg + val params = SessionBranchWholeParams(sessionId = sessionId, name = arg.takeIf { it.isNotBlank() }) val generation = sessionGeneration viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_BRANCH_WHOLE, + RpcMethods.SESSION_BRANCH_WHOLE, params, onSent = { id -> branchWholeRequests[id] = PendingBranchRequest(generation, params) @@ -4021,12 +4103,11 @@ class ChatViewModel( // #576). For those we fall back to slash.exec, which runs the // full COMMAND_REGISTRY through the worker. val result = - wsClient - .request( - WsMethods.COMMAND_DISPATCH, - mapOf("name" to name, "arg" to arg, "session_id" to sessionId), - ).await() - handleDispatchResult(result) + wsClient.call( + RpcMethods.COMMAND_DISPATCH, + CommandDispatchParams(name = name, arg = arg, sessionId = sessionId), + ) + handleDispatchResult(result.toAny()) } catch (e: HermesWsClient.HermesRpcException) { val msg = e.message.orEmpty() // Registry miss on command.dispatch: the backend emits exactly @@ -4038,15 +4119,14 @@ class ChatViewModel( // which routes the full CLI command set through the worker. try { val result = - wsClient - .request( - WsMethods.SLASH_EXEC, - mapOf( - "command" to "/$name${if (arg.isNotEmpty()) " $arg" else ""}", - "session_id" to sessionId, - ), - ).await() - val output = (result as? Map<*, *>)?.get("output") as? String + wsClient.call( + RpcMethods.SLASH_EXEC, + SlashExecParams( + sessionId = sessionId, + command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}", + ), + ) + val output = (result.toAny() as? Map<*, *>)?.get("output") as? String if (!output.isNullOrBlank()) addAssistantMessage(output) } catch (e2: HermesWsClient.HermesRpcException) { addAssistantMessage("/$name: ${e2.message}") @@ -4093,7 +4173,7 @@ class ChatViewModel( ChatMessage( role = MessageRole.ASSISTANT, content = text, - displayKind = "local_feedback", + displayKind = DisplayKind.LOCAL_FEEDBACK, ) _uiState.update { it.copy(messages = it.messages + msg) } @@ -4118,12 +4198,11 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { val result = - wsClient - .request( - WsMethods.COMMAND_DISPATCH, - mapOf("name" to "undo", "arg" to count, "session_id" to sessionId), - ).await() - handleDispatchResult(result) + wsClient.call( + RpcMethods.COMMAND_DISPATCH, + CommandDispatchParams(name = "undo", arg = count, sessionId = sessionId), + ) + handleDispatchResult(result.toAny()) } catch (e: HermesWsClient.HermesRpcException) { addAssistantMessage(e.message ?: "Failed to undo.") } catch (e: Exception) { @@ -4151,21 +4230,16 @@ class ChatViewModel( viewModelScope.launch(ioDispatcher) { try { - val params = - buildMap { - put("session_id", sessionId) - if (focusTopic.isNotBlank()) { - put("focus_topic", focusTopic) - } - } // Use extended timeout (e.g. 300_000L / 5 minutes) so LLM summarization doesn't timeout val result = - wsClient - .request( - WsMethods.SESSION_COMPRESS, - params, - timeoutMs = 300_000L, - ).await() + wsClient.call( + RpcMethods.SESSION_COMPRESS, + SessionCompressParams( + sessionId = sessionId, + focusTopic = focusTopic.takeIf { it.isNotBlank() }, + ), + timeoutMs = 300_000L, + ) handleCompressionResult(result) } catch (e: HermesWsClient.HermesRpcException) { @@ -4196,20 +4270,28 @@ class ChatViewModel( val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId // 1. Authoritative transcript replacement + // Rows without a server id can't be keyed under newest-anchored paging; the mapper would throw and + // abort the whole result (feedback included). Fall back to a REST reload for those. + var needsReload = false if (response?.messages != null && sessionId != null) { - val replacementMessages = - withContext(historyDispatcher) { - mapServerMessages( - sessionId = sessionId, - messages = response.messages, - offset = 0, - latestPaging = latestPaging, - liveMessages = emptyList(), - context = getApplication(), - ) - } - _uiState.update { it.copy(messages = replacementMessages) } - persistHistoryPage(replacementMessages, sessionId) + if (!latestPaging || response.messages.all { it.id != null }) { + val replacementMessages = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = 0, + latestPaging = latestPaging, + liveMessages = emptyList(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, + ) + } + _uiState.update { it.copy(messages = replacementMessages) } + persistHistoryPage(replacementMessages, sessionId) + } else { + needsReload = true + } } // 2. Display result summary via addAssistantMessage @@ -4225,6 +4307,12 @@ class ChatViewModel( } else { response?.message ?: "Context compressed." } + if (needsReload && sessionId != null) { + // Mirror handlePrefillResult: rehydrate from REST first, then post the feedback so it survives. + withContext(ioDispatcher) { repo.clearMessagesForSession(sessionId) } + _uiState.update { it.copy(messages = emptyList()) } + loadSessionMessages(sessionId, sessionGeneration) + } addAssistantMessage(feedback) // 3. Refresh usage & sessions @@ -4284,13 +4372,35 @@ class ChatViewModel( } viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to sessionId), + RpcMethods.SESSION_INTERRUPT, + SessionInterruptParams(sessionId), onSent = { id -> trackRequest(id, WsMethods.SESSION_INTERRUPT) }, ) } } + /** + * `/stop`, matching the desktop app: interrupt the active turn (same path as the composer Stop button), then + * kill every background process via `process.stop`. The button stays interrupt-only. + */ + private fun stopSessionAndProcesses() { + interruptSession() + viewModelScope.launch(ioDispatcher) { + val message = + try { + val killed = wsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams).killed ?: 0 + when { + killed > 0 -> "Stopped $killed background process${if (killed == 1) "" else "es"}." + else -> "No background processes to stop." + } + } catch (e: Exception) { + if (e is CancellationException) throw e + "Could not stop background processes: ${e.message ?: e.javaClass.simpleName}" + } + addSystemMessage(message) + } + } + /** * Send course-correction guidance to a live subagent child session (issue #1030). */ @@ -4392,8 +4502,8 @@ class ChatViewModel( val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_CREATE, - params = mapOf("source" to "desktop"), + RpcMethods.SESSION_CREATE, + SessionCreateParams(source = "desktop"), onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) }, ) } @@ -4413,7 +4523,8 @@ class ChatViewModel( fun loadSessions() { viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_LIST, + RpcMethods.SESSION_LIST, + SessionListParams, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) }, ) } @@ -4422,7 +4533,8 @@ class ChatViewModel( private fun fetchCommandCatalog() { viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.COMMANDS_CATALOG, + RpcMethods.COMMANDS_CATALOG, + CommandsCatalogParams, onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) }, ) } @@ -4699,11 +4811,12 @@ class ChatViewModel( liveMessages = _uiState.value.messages, isPagingOlder = true, stableRowIds = true, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } if (!valid()) return@launch - val targetId = "rest-$sessionId-$rowId" + val targetId = RestMessageId.of(sessionId, rowId) if (page.none { it.id == targetId || it.canonicalRestId == targetId }) { _timelineState.update { it.copy(windowErrorMessage = "The selected prompt is no longer available.") @@ -5009,7 +5122,8 @@ class ChatViewModel( serverOffset, useLatest, current, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) @@ -5060,7 +5174,7 @@ class ChatViewModel( // A mapped page can reuse a live WS message. Never overwrite its newer persisted // version with the snapshot used for mapping; WS owns persistence of those IDs. val pageIds = page.mapNotNull { it.canonicalRestId }.toSet() - val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") } + val aliases = _uiState.value.messages.filter { it.restId in pageIds && !RestMessageId.isRest(it.id) } withContext(historyDispatcher) { repo.persistMessages( page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } }, @@ -5205,16 +5319,14 @@ class ChatViewModel( val connectionCheckpoint = connectionOperationDelegate.resumeCheckpoint() val profile = AuthManager.activeProfileId.value val params = - mutableMapOf( - "session_id" to sessionId, - "omit_messages" to true, + SessionResumeParams( + sessionId = sessionId, + omitMessages = true, + profile = profile?.takeIf { it.isNotBlank() }, ) - if (!profile.isNullOrBlank()) { - params["profile"] = profile - } viewModelScope.launch(ioDispatcher) { wsClient.send( - WsMethods.SESSION_RESUME, + RpcMethods.SESSION_RESUME, params, onSent = { id -> trackSessionRequest( @@ -5466,7 +5578,8 @@ class ChatViewModel( useLatest, current, isPagingOlder = true, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) @@ -5547,7 +5660,8 @@ class ChatViewModel( current, // Sync fetches recent replies, so confirm live completion identities. isPagingOlder = false, - context = getApplication(), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(getApplication()), + mediaUrl = ::gatewayMediaUrl, ) } ?: return@launch persistHistoryPage(page, sessionId) @@ -5704,9 +5818,9 @@ class ChatViewModel( if (rpcSessionId != null) { try { val result = - sendRpcAndAwait( - WsMethods.SESSION_CONTEXT_BREAKDOWN, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val ctx = parseContextBreakdown(result) @@ -5743,9 +5857,9 @@ class ChatViewModel( if (!isSwitchPending) { try { val usage = - sendRpcAndAwait( - WsMethods.SESSION_USAGE, - mapOf("session_id" to rpcSessionId), + wsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(rpcSessionId), ) coroutineContext.ensureActive() val snapshot = parseUsageSnapshot(usage) 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 1a0c983b..65ecedfe 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 @@ -129,6 +129,7 @@ object ChatWsEventReducer { is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId is WsEvent.TranscriptResyncRequired -> event.sessionId + is WsEvent.MessageReactionUpdated -> event.sessionId is WsEvent.VaultUnlockRequest -> event.sessionId is WsEvent.VaultUnlockExpire -> event.sessionId is WsEvent.VaultSaveLoginRequest -> event.sessionId @@ -252,6 +253,8 @@ object ChatWsEventReducer { // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.MessageReactionUpdated -> onMessageReaction(state, streamingState, event) + // Change events (issue #784) are consumed by their screens' ViewModels is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState) @@ -457,7 +460,7 @@ object ChatWsEventReducer { val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) - val streaming = streamingState.streamingMessage + val streaming = streamingState.interruptedMessage ?: streamingState.streamingMessage // Prefer the authoritative reasoning carried in the message.complete // payload (the gateway's assembled full trace), then fall back to // whatever reasoning.delta deltas accumulated during streaming. @@ -663,7 +666,7 @@ object ChatWsEventReducer { streamingState.streamingMessage ?: return ReducerResult( state = state.copy(isAgentTyping = false), - streamingState = StreamingState(), + streamingState = streamingState.takeIf { it.interruptedMessage != null } ?: StreamingState(), ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { @@ -1079,6 +1082,22 @@ object ChatWsEventReducer { } } + /** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */ + private fun onMessageReaction( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.MessageReactionUpdated, + ): ReducerResult { + if (state.messages.none { it.serverRowId == event.rowId }) { + return ReducerResult(state = state, streamingState = streamingState) + } + val messages = + state.messages.map { + if (it.serverRowId == event.rowId) it.copy(reactions = event.reactions) else it + } + return ReducerResult(state = state.copy(messages = messages), streamingState = streamingState) + } + private fun onUnknown( state: ChatUiState, streamingState: StreamingState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt new file mode 100644 index 00000000..bf41d69f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ExpandedToolContent.kt @@ -0,0 +1,237 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import com.m57.hermescontrol.ui.chat.components.FileViewCard +import com.m57.hermescontrol.ui.chat.tool.ToolView + +/** + * Renders the engine's [ToolView] — the expanded body of a tool card. + * + * Order: error line → terminal streams ($ command / stdout / stderr / exit + * code) → file diff → search hits → plain detail → duration footer. + */ +@Composable +internal fun ExpandedToolContent( + view: ToolView, + contentColor: Color, + statusColors: HermesStatusColors, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val isTerminal = + view.stdout != null || + view.stderr != null || + view.exitCode != null || + view.terminalCommand != null + + // ── Error line ── + view.error?.let { + Text( + text = stringResource(R.string.chat_tool_execution_error, it), + style = + MaterialTheme.typography.bodySmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + + // ── Terminal: $ command + streams + exit code ── + if (isTerminal) { + view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command -> + Text( + text = "$ $command", + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stdout?.let { + Text( + text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.stderr?.let { + Text( + text = it, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + view.exitCode?.let { code -> + if (code != 0) { + Text( + text = stringResource(R.string.chat_tool_exit_code, code), + style = + MaterialTheme.typography.labelSmall.copy( + color = statusColors.warning, + fontWeight = FontWeight.Medium, + ), + ) + } + } + } else { + // ── Generated/resolved image preview ── + view.imageUrl?.let { url -> + AsyncImage( + model = url, + contentDescription = view.title, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + + // ── File diff ── + if (view.inlineDiff != null) { + DiffViewCard( + diffText = view.inlineDiff, + filePath = view.diffPath, + ) + } else if (view.fileContent != null) { + FileViewCard( + content = view.fileContent, + filePath = view.filePath, + ) + } + + // ── Search hits ── + if (!view.searchHits.isNullOrEmpty()) { + view.detailLabel?.let { label -> + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold, + ), + ) + } + view.searchHits.forEach { hit -> + Column(modifier = Modifier.padding(top = 2.dp)) { + if (hit.title.isNotEmpty()) { + Text( + text = hit.title, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontWeight = FontWeight.Medium, + ), + ) + } + if (hit.snippet.isNotEmpty()) { + Text( + text = hit.snippet, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontSize = 11.sp, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (hit.url.isNotEmpty()) { + Text( + text = hit.url, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + // ── Plain detail body ── + if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { + Text( + text = view.detail, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontSize = 12.sp, + ), + ) + } + } + + view.outputCut?.let { omitted -> + Text( + text = stringResource(R.string.chat_tool_output_omitted, omitted), + style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning), + ) + } + + // ── Duration footer ── + view.durationLabel?.let { + Text( + text = stringResource(R.string.tool_duration, it), + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt index d969dc58..c47d67ac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/HostMediaExtractor.kt @@ -21,6 +21,8 @@ internal object HostMediaExtractor { data class Item( val match: String, // the full matched directive (for stripping) val path: String, // normalized absolute path + // Issue #1367: char offset into strip(text) where the directive sat. + val offset: Int = 0, ) /** Matches `MEDIA:` (any extension), supporting quotes/backticks around the path. */ @@ -33,12 +35,18 @@ internal object HostMediaExtractor { /** Extract every `MEDIA:` directive from [text] as a normalized item. */ fun extract(text: String): List { if (!text.contains("MEDIA:")) return emptyList() + val total = strip(text).length + // Text kept so far, with every directive (valid or not) removed exactly as strip() does. + val kept = StringBuilder() + var cursor = 0 return MEDIA_TAG_RE .findAll(text) .mapNotNull { m -> + kept.append(text, cursor, m.range.first) + cursor = m.range.last + 1 val raw = m.groupValues.drop(1).firstOrNull { it.isNotBlank() } ?: return@mapNotNull null val path = normalizePath(raw) ?: return@mapNotNull null - Item(match = m.value, path = path) + Item(match = m.value, path = path, offset = strip(kept.toString()).length.coerceAtMost(total)) }.toList() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt deleted file mode 100644 index 431c6898..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageBytesResolver.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import android.net.Uri -import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Request -import java.util.Base64 - -/** - * Resolve a chat image [ImageViewerModel.model] into raw bytes so the viewer can - * Save/Share it *locally on the device* — never back to the Hermes server - * (issue #723). - * - * Every model kind handled here is exactly what Coil already renders in the - * bubble, so a model that displays is also resolvable here: - * - `data:image/...;base64,...` — agent-delivered inline media, - * - `content://...` — a locally-picked user image, - * - `http(s)://...` — gateway-served URL (carries the `?token=` auth param the - * same way the app fetches it; no backend change needed). - * - * All I/O runs on [Dispatchers.IO]. - */ -object ImageBytesResolver { - /** Outcome of [resolve]. */ - sealed interface Result { - /** Resolved bytes plus the concrete MIME type and file extension. */ - data class Bytes( - val bytes: ByteArray, - val mimeType: String, - val extension: String, - ) : Result - - /** Could not load the image; [message] is safe to surface to the user. */ - data class Error( - val message: String, - ) : Result - } - - suspend fun resolve( - context: Context, - model: String, - fallbackMime: String, - ): Result = - withContext(Dispatchers.IO) { - runCatching { - when { - model.startsWith("data:") -> decodeDataUrl(model, fallbackMime) - model.startsWith("content://") -> readContentUri(context, model, fallbackMime) - model.startsWith("https://") || model.startsWith("http://") -> fetchHttp(model, fallbackMime) - else -> Result.Error("Unsupported image source") - } - }.getOrElse { e -> Result.Error(e.message ?: "Failed to load image") } - } - - private fun decodeDataUrl( - model: String, - fallbackMime: String, - ): Result { - val comma = model.indexOf(',') - if (comma < 0) return Result.Error("Malformed data URL") - val meta = model.substring(0, comma) - val data = model.substring(comma + 1).replace(Regex("\\s+"), "") - val mime = - meta - .substringAfter("data:", "image/*") - .substringBefore(";") - .ifBlank { fallbackMime } - if (!meta.contains(";base64", ignoreCase = true)) { - return Result.Error("Only base64 data URLs are supported") - } - val bytes = - runCatching { Base64.getDecoder().decode(data) } - .getOrElse { return Result.Error("Could not decode image data") } - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun readContentUri( - context: Context, - model: String, - fallbackMime: String, - ): Result { - val uri = Uri.parse(model) - val bytes = - runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } - .getOrNull() - ?: return Result.Error("Could not open local image") - val mime = context.contentResolver.getType(uri) ?: fallbackMime - return Result.Bytes(bytes, mime, extensionForMime(mime)) - } - - private fun fetchHttp( - model: String, - fallbackMime: String, - ): Result { - val request = - Request - .Builder() - .url(model) - .build() - return runCatching { - OkHttpProvider.base.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})") - val bytes = resp.body.bytes() - val mime = - resp - .header("Content-Type") - ?.substringBefore(";") - ?.ifBlank { fallbackMime } - ?: fallbackMime - Result.Bytes(bytes, mime, extensionForMime(mime)) - } - }.getOrElse { e -> Result.Error(e.message ?: "Could not download image") } - } - - fun extensionForMime(mime: String): String = - when (mime.substringBefore(';').trim().lowercase()) { - "image/jpeg", "image/jpg" -> "jpg" - "image/png" -> "png" - "image/gif" -> "gif" - "image/webp" -> "webp" - "image/bmp" -> "bmp" - "image/heic", "image/heif" -> "heic" - "image/svg+xml" -> "svg" - else -> "img" - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt deleted file mode 100644 index f202609d..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ImageViewerDialog.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.widget.Toast -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil3.compose.AsyncImage -import com.m57.hermescontrol.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Full-screen image viewer (issue #723). - * - * Opens from any chat image (bubble thumbnail, markdown `![alt](url)`, inline - * attachment). Provides pinch-zoom + pan, a downswipe-to-dismiss gesture, and a - * top bar with **Save** (writes to device Downloads via [MediaImageStore]) and - * **Share** (Android share sheet) — both fully device-local, never touching the - * Hermes server. - * - * Save/Share resolve the image bytes through [ImageBytesResolver], which handles - * the same model kinds Coil already renders (`data:`, `content://`, `http(s)`). - */ -@Composable -fun ImageViewerDialog( - image: ImageViewerModel, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var scale by remember { mutableStateOf(1f) } - var offsetX by remember { mutableStateOf(0f) } - var offsetY by remember { mutableStateOf(0f) } - var isBusy by remember { mutableStateOf(false) } - - // Resolve string resources at composable scope (Lint forbids reading - // resource *values* from LocalContext.current inside coroutine lambdas). - val savedMsg = stringResource(R.string.image_viewer_saved) - val saveFailedMsg = stringResource(R.string.image_viewer_save_failed) - val loadFailedFmt = stringResource(R.string.image_viewer_load_failed) - val shareTitle = stringResource(R.string.image_viewer_share_title) - val shareFailedMsg = stringResource(R.string.image_viewer_share_failed) - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val result = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - val uri = - MediaImageStore.saveToDownloads( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - if (uri != null) { - savedMsg - } else { - saveFailedMsg - } - } - - is ImageBytesResolver.Result.Error -> { - String.format(loadFailedFmt, resolved.message) - } - } - withContext(Dispatchers.Main) { - isBusy = false - Toast.makeText(context, result, Toast.LENGTH_SHORT).show() - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch(Dispatchers.IO) { - val resolved = ImageBytesResolver.resolve(context, image.model, image.mimeType) - val intent = - when (resolved) { - is ImageBytesResolver.Result.Bytes -> { - val name = image.name.ifBlank { "hermes-image.${resolved.extension}" } - MediaImageStore.buildShareIntent( - context, - resolved.bytes, - name, - resolved.mimeType, - ) - } - - is ImageBytesResolver.Result.Error -> { - null - } - } - withContext(Dispatchers.Main) { - isBusy = false - if (intent != null) { - context.startActivity( - android.content.Intent - .createChooser(intent, shareTitle), - ) - } else { - Toast - .makeText( - context, - shareFailedMsg, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim, - ) { - Box(modifier = Modifier.fillMaxSize()) { - // Zoomable / pannable image. - AsyncImage( - model = image.model, - contentDescription = - image.name.ifBlank { stringResource(R.string.image_viewer_content_desc) }, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY, - ).pointerInput(Unit) { - detectTransformGestures( - onGesture = { _, pan, zoom, _ -> - val newScale = (scale * zoom).coerceIn(1f, 5f) - // While zoomed in, allow free panning; at min scale, - // only vertical drags are permitted (for dismiss). - scale = newScale - offsetX = if (newScale > 1f) offsetX + pan.x else 0f - offsetY += pan.y - // Downswipe-to-dismiss when at min zoom. - if (newScale <= 1f && offsetY > 120f) { - onDismiss() - } - }, - ) - }, - contentScale = ContentScale.Fit, - ) - - // Top action bar — Save / Share / Close. - Row( - modifier = - Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.image_viewer_close), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Row { - if (isBusy) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - } else { - IconButton(onClick = onSave, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = stringResource(R.string.image_viewer_save), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - IconButton(onClick = onShare, enabled = !isBusy) { - Icon( - imageVector = Icons.Filled.Share, - contentDescription = stringResource(R.string.image_viewer_share), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt new file mode 100644 index 00000000..c5716f4b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/InlineAttachment.kt @@ -0,0 +1,189 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment + +/** + * Renders a message's attachments, shared by user bubbles and agent messages. + * Emits directly into the caller's Column (no wrapper) so spacing is unchanged: + * 6dp before the list, 4dp after each item. Renders nothing when empty. + */ +@Composable +internal fun InlineAttachmentList( + attachments: List?, + textColor: Color, + onOpen: (Attachment) -> Unit, + onSave: (Attachment) -> Unit, + savingPath: String?, + openingPath: String?, + canSave: Boolean, + onImageClick: (ImageViewerModel) -> Unit, +) { + if (attachments.isNullOrEmpty()) return + Spacer(modifier = Modifier.height(6.dp)) + attachments.forEach { attachment -> + InlineAttachment( + attachment = attachment, + textColor = textColor, + onOpen = onOpen, + onSave = onSave, + savingPath = savingPath, + openingPath = openingPath, + canSave = canSave, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(4.dp)) + } +} + +/** + * Renders an attachment inline inside a chat bubble. + * Images are displayed as thumbnails; other files show a compact card. + */ +@Composable +internal fun InlineAttachment( + attachment: Attachment, + textColor: Color, + onOpen: (Attachment) -> Unit, + onSave: (Attachment) -> Unit, + savingPath: String?, + openingPath: String?, + canSave: Boolean, + onImageClick: (ImageViewerModel) -> Unit, +) { + val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name + val isSaving = savingPath != null && savingPath == attachmentPath + val isOpening = openingPath != null && openingPath == attachmentPath + val clickable = Modifier.clickable { onOpen(attachment) } + if (attachment.isVideo || attachment.isAudio) { + var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) } + com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( + uri = attachment.gatewayUrl ?: attachment.uri, + title = attachment.name, + mimeType = attachment.mimeType, + onFullScreenClick = { showMediaDialog = true }, + ) + if (showMediaDialog) { + com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( + mediaUri = attachment.gatewayUrl ?: attachment.uri, + title = attachment.name, + mimeType = attachment.mimeType, + onDismissRequest = { showMediaDialog = false }, + ) + } + } else if (attachment.isImage) { + // Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation. + com.m57.hermescontrol.ui.chat.components.GifImageThumbnail( + model = attachment.uri, + contentDescription = attachment.name, + isGif = attachment.isGif, + onClick = { + onImageClick( + ImageViewerModel( + model = attachment.uri, + name = attachment.name, + mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType, + ), + ) + }, + ) + } else { + // Non-image file — native save picker on the trailing action. + Surface( + shape = RoundedCornerShape(12.dp), + color = textColor.copy(alpha = 0.08f), + border = BorderStroke(1.dp, textColor.copy(alpha = 0.16f)), + modifier = clickable, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + modifier = Modifier.padding(9.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = attachment.name, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = attachment.formattedSize, + color = textColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + ) + } + if (isOpening) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(10.dp)) + } + if (attachment.gatewayUrl != null) { + IconButton( + onClick = { onSave(attachment) }, + enabled = canSave, + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.chat_save_attachment, attachment.name), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} 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 836712b6..aa2c60d6 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 @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation @@ -43,6 +44,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -61,6 +63,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun import com.m57.hermescontrol.ui.chat.markdown.FnNote import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler @@ -69,19 +72,20 @@ import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable import com.m57.hermescontrol.ui.chat.markdown.MdBlock import com.m57.hermescontrol.ui.chat.markdown.TableAlign +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath import com.m57.hermescontrol.util.BidiUtils private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") -private val TABLE_COL_WIDTH = 160.dp private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr /** - * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. - * While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap - * to the formatted view on completion (and for all historical/restored messages). + * Renders chat assistant text as Markdown, including while it streams. The + * upstream token buffer coalesces deltas; parsing is memoized for identical text + * so completion does not swap a plain-text row for a differently sized block tree. * * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*, * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, keys, headings, @@ -100,25 +104,8 @@ fun MarkdownText( ) { val statusColors = LocalHermesStatusColors.current val highlights = searchHighlightColors(statusColors) - if (isStreaming) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } - val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) { - Text( - text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text, - color = textColor, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - modifier = modifier, - ) - } - return - } - val linkColor = MaterialTheme.colorScheme.primary - val blocks = remember(text) { parseBlocks(text) } + val blocks = remember(text, isStreaming) { coalesceBulletRuns(parseBlocks(text)) } val latexMeasurer = rememberLatexMeasurer() Column(modifier = modifier.fillMaxWidth()) { @@ -129,6 +116,8 @@ fun MarkdownText( code = block.code, language = block.language, onCopy = { /* clipboard handled internally */ }, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, ) } @@ -153,29 +142,28 @@ fun MarkdownText( } is MdBlock.Heading -> { - val fontSize = + val headingStyle = when (block.level) { - 1 -> 22.sp - 2 -> 20.sp - 3 -> 18.sp - 4 -> 16.sp - 5 -> 15.sp - else -> 14.sp + 1 -> MaterialTheme.typography.headlineMedium + 2 -> MaterialTheme.typography.headlineSmall + 3 -> MaterialTheme.typography.titleMedium + 4 -> MaterialTheme.typography.titleSmall + 5 -> MaterialTheme.typography.labelLarge + else -> MaterialTheme.typography.labelMedium } val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = - MaterialTheme.typography.bodyMedium - .copy( - fontSize = fontSize, - fontWeight = FontWeight.Bold, - textDirection = bidiTextDirection(isRtl), - ), + headingStyle.copy( + fontWeight = FontWeight.Bold, + textDirection = bidiTextDirection(isRtl), + ), searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, linkColor = linkColor, @@ -185,6 +173,47 @@ fun MarkdownText( } } + is BulletRun -> { + val density = LocalDensity.current + val bodyStyle = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Ltr) + val textMeasurer = rememberTextMeasurer() + val runText = + remember( + block, + density, + bodyStyle, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + ) { + buildBulletRunText( + run = block, + density = density, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + prefixWidth = { prefix -> + with(density) { + textMeasurer + .measure(prefix, bodyStyle) + .size.width + .toSp() + } + }, + ) + } + Text( + text = runText, + color = textColor, + style = bodyStyle, + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + ) + } + is MdBlock.Bullet -> { val indent = (block.level * 16).dp val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } @@ -210,20 +239,31 @@ fun MarkdownText( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + isRtlOverride = isRtl, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -259,20 +299,31 @@ fun MarkdownText( }, modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + isRtlOverride = isRtl, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -296,20 +347,31 @@ fun MarkdownText( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 6.dp), ) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + Column(modifier = Modifier.weight(1f)) { + MarkdownInlineText( + text = block.text, + isRtlOverride = isRtl, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + ) + if (block.nestedSource.isNotEmpty()) { + MarkdownText( + text = block.nestedSource, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } } } } @@ -331,41 +393,54 @@ fun MarkdownText( .background(textColor.copy(alpha = 0.35f)), ) Spacer(modifier = Modifier.width(8.dp)) - MarkdownInlineText( - text = block.text, - textColor = textColor, - latexMeasurer = latexMeasurer, - style = - MaterialTheme.typography.bodyMedium.copy( - fontStyle = FontStyle.Italic, - textDirection = bidiTextDirection(isRtl), - ), - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - modifier = Modifier.weight(1f), - ) + val nestedBlocks = remember(block.text) { parseBlocks(block.text) } + if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) { + MarkdownInlineText( + text = block.text, + isRtlOverride = isRtl, + textColor = textColor, + latexMeasurer = latexMeasurer, + style = + MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic, + textDirection = bidiTextDirection(isRtl), + ), + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + modifier = Modifier.weight(1f), + ) + } else { + MarkdownText( + text = block.text, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + modifier = Modifier.weight(1f), + onImageClick = onImageClick, + ) + } } } } is MdBlock.Video -> { val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } - var showVideoDialog by remember { mutableStateOf(false) } + var showVideoDialog by remember(block.uri) { mutableStateOf(false) } androidx.compose.foundation.layout.Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), ) { - com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer( - videoUri = resolvedUri, + com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer( + uri = resolvedUri, onFullScreenClick = { showVideoDialog = true }, ) if (showVideoDialog) { - com.m57.hermescontrol.ui.chat.components.VideoViewerDialog( - videoUri = resolvedUri, + com.m57.hermescontrol.ui.chat.components.MediaViewerDialog( + mediaUri = resolvedUri, onDismissRequest = { showVideoDialog = false }, ) } @@ -478,6 +553,7 @@ fun MarkdownText( ) MarkdownInlineText( text = note.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = @@ -502,6 +578,7 @@ fun MarkdownText( CompositionLocalProvider(LocalLayoutDirection provides blockDirection) { MarkdownInlineText( text = block.text, + isRtlOverride = isRtl, textColor = textColor, latexMeasurer = latexMeasurer, style = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt index 2cd42077..776fe1d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaImageStore.kt @@ -2,42 +2,21 @@ package com.m57.hermescontrol.ui.chat import android.content.ContentValues import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build import android.provider.MediaStore -import androidx.core.content.FileProvider -import java.io.ByteArrayInputStream -import java.io.File import java.io.InputStream /** - * Persist / dispatch chat images on the *device* (Downloads / Gallery / share - * sheet) — never back to the Hermes server (issue #723). Every operation works - * only on bytes the viewer already resolved locally, so save/share are fully - * device-local and independent of the backend. + * Streams non-chat downloads (backup archives) into device Downloads. Chat + * media save/share goes through [MediaExportHelper] (issue #1328). */ object MediaImageStore { /** - * Write [bytes] into the device's Download collection via [MediaStore] so the - * file appears in system Downloads / Gallery apps. Returns the public [Uri] - * on success, or `null` if the write failed. - * - * On API 29+ the file lands under `Download/Hermes` using - * `RELATIVE_PATH`; on older APIs it falls back to the top-level - * `Images` collection. - */ - fun saveToDownloads( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Uri? = saveToDownloads(context, ByteArrayInputStream(bytes), bytes.size.toLong(), displayName, mimeType) - - /** - * Streaming variant of [saveToDownloads] — for large payloads (e.g. backup - * archives, 300+ MB) that must NOT be held in memory as a [ByteArray]. - * Copies [input] to the Download collection in chunks. Same return contract. + * Stream [input] into the device's Download collection via [MediaStore] — + * for large payloads (e.g. backup archives, 300+ MB) that must NOT be held + * in memory. Returns the public [Uri] on success, or `null` on failure. + * On API 29+ the file lands under `Download/Hermes`. */ fun saveToDownloads( context: Context, @@ -85,38 +64,6 @@ object MediaImageStore { } } - /** - * Build a share [Intent] for [bytes] via a [FileProvider] cache file so the - * image can be sent to another app (Messages, WhatsApp, …). Returns `null` - * if the temp file could not be written. - */ - fun buildShareIntent( - context: Context, - bytes: ByteArray, - displayName: String, - mimeType: String, - ): Intent? { - val safe = sanitizeName(displayName) - val dir = File(context.cacheDir, "shared_images").also { it.mkdirs() } - val file = File(dir, safe) - return try { - file.writeBytes(bytes) - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file, - ) - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - } catch (e: Exception) { - null - } - } - /** * Replace unsafe characters in a display name and cap its length so it is a * valid [MediaStore] / file name. Preserves the original extension when one diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt index 227447d8..e3c0bf09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MediaTypes.kt @@ -140,67 +140,41 @@ fun normalizeFrameworkMime(mime: String?): String = ?.takeIf { it.isNotBlank() } ?: "application/octet-stream" +/** + * MIME aliases and preferred extensions that [MEDIA_BY_EXT] can't express + * (it's keyed by extension, so `image/jpeg` would otherwise resolve to its + * first entry, `jpeg`). HEIC is save-only here: it isn't classified as inline media. + */ +private val EXT_BY_MIME_ALIAS: Map = + mapOf( + "image/jpeg" to "jpg", + "image/jpg" to "jpg", + "image/heic" to "heic", + "image/heif" to "heic", + "audio/mp3" to "mp3", + "audio/x-m4a" to "m4a", + "audio/x-wav" to "wav", + "audio/wave" to "wav", + "application/x-flac" to "flac", + "application/ogg" to "ogg", + ) + /** Returns the canonical file extension for a given MIME type. */ fun extensionForMime(mime: String): String { val clean = mime.substringBefore(';').trim().lowercase() - for ((ext, pair) in MEDIA_BY_EXT) { - if (pair.second + EXT_BY_MIME_ALIAS[clean]?.let { return it } + MEDIA_BY_EXT.entries + .firstOrNull { (_, pair) -> + pair.second .substringBefore(';') .trim() .lowercase() == clean - ) { - return ext - } - } - return when (clean) { - "image/jpeg", "image/jpg" -> { - "jpg" - } - - "video/mp4" -> { - "mp4" - } - - "video/x-matroska" -> { - "mkv" - } - - "video/quicktime" -> { - "mov" - } - - "audio/mpeg", "audio/mp3" -> { - "mp3" - } - - "audio/mp4", "audio/x-m4a" -> { - "m4a" - } - - "audio/wav", "audio/x-wav", "audio/wave" -> { - "wav" - } - - "audio/flac", "application/x-flac" -> { - "flac" - } - - "audio/ogg", "application/ogg" -> { - "ogg" - } - - "audio/aac" -> { - "aac" - } - - else -> { - when { - clean.startsWith("video/") -> "mp4" - clean.startsWith("audio/") -> "mp3" - clean.startsWith("image/") -> "img" - else -> "bin" - } - } + }?.let { return it.key } + return when { + clean.startsWith("video/") -> "mp4" + clean.startsWith("audio/") -> "mp3" + clean.startsWith("image/") -> "img" + else -> "bin" } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt new file mode 100644 index 00000000..95bd57b4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageSegment.kt @@ -0,0 +1,84 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment + +/** Issue #1367: one in-order piece of an agent message body. */ +internal sealed interface MessageSegment { + data class Text( + val text: String, + ) : MessageSegment + + data class Media( + val attachment: Attachment, + ) : MessageSegment +} + +/** + * Interleave [attachments] into [content] at the position their `MEDIA:` directive occupied + * ([Attachment.contentOffset]). Attachments without an offset trail the text, as before. + * + * Offsets snap forward to the end of their line, and past a still-open code fence, so media + * never splits a sentence's markdown, list item, or fenced block. + */ +internal fun splitByMedia( + content: String, + attachments: List?, +): List { + if (attachments.isNullOrEmpty()) { + return if (content.isBlank()) emptyList() else listOf(MessageSegment.Text(content)) + } + val inline = attachments.filter { it.contentOffset != null }.sortedBy { it.contentOffset } + val trailing = attachments.filter { it.contentOffset == null } + val out = mutableListOf() + var cursor = 0 + inline.forEach { attachment -> + val pos = snapToBlockEnd(content, attachment.contentOffset ?: 0).coerceAtLeast(cursor) + addText(out, content.substring(cursor, pos)) + out += MessageSegment.Media(attachment) + cursor = pos + } + addText(out, content.substring(cursor)) + trailing.forEach { out += MessageSegment.Media(it) } + return out +} + +private fun addText( + out: MutableList, + text: String, +) { + if (text.isNotBlank()) out += MessageSegment.Text(text.trim('\n')) +} + +private fun snapToBlockEnd( + content: String, + offset: Int, +): Int { + var pos = offset.coerceIn(0, content.length) + pos = lineEnd(content, pos) + // Inside an unclosed fence? Advance to the end of the closing fence line. + while (fenceOpenBefore(content, pos)) { + val next = pos + 1 + if (next > content.length) return content.length + pos = lineEnd(content, next) + } + return pos +} + +private fun lineEnd( + content: String, + from: Int, +): Int { + val idx = content.indexOf('\n', from) + return if (idx < 0) content.length else idx +} + +private fun fenceOpenBefore( + content: String, + pos: Int, +): Boolean { + var open = false + content.substring(0, pos).lineSequence().forEach { line -> + if (line.trimStart().startsWith("```")) open = !open + } + return open +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt new file mode 100644 index 00000000..699d2899 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MessageTimestamp.kt @@ -0,0 +1,16 @@ +package com.m57.hermescontrol.ui.chat + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal fun formatTimestamp( + timestamp: Long, + is24Hour: Boolean, +): String { + val pattern = if (is24Hour) "HH:mm" else "h:mm a" + return DateTimeFormatter + .ofPattern(pattern) + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(timestamp)) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index c1aba996..65221a8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -27,7 +27,11 @@ class SlashCommandDispatcher { val cmd = parts[0].lowercase() return when (cmd) { - "/stop", "/interrupt" -> { + "/stop" -> { + SlashResult.Stop + } + + "/interrupt" -> { SlashResult.Interrupt } @@ -96,6 +100,9 @@ sealed class SlashResult { /** Interrupt the active session (client-side immediate). */ data object Interrupt : SlashResult() + /** Desktop `/stop`: interrupt the active turn, then kill background processes (`process.stop`). */ + data object Stop : SlashResult() + /** Create a new session (client-side immediate). */ data object NewSession : SlashResult() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt new file mode 100644 index 00000000..445151e2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingBuffer.kt @@ -0,0 +1,58 @@ +package com.m57.hermescontrol.ui.chat + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** One throttled streaming channel: cumulative text, last flush and trailing timer. */ +internal class StreamingBuffer( + private val scope: CoroutineScope, + private val intervalMs: Long, + private val nowMs: () -> Long, + private val flush: (String) -> Unit, +) { + private val content = StringBuilder() + private var lastFlushMs = 0L + private var trailingJob: Job? = null + + fun append( + delta: String, + flushImmediately: Boolean, + ) { + content.append(delta) + val now = nowMs() + if (flushImmediately || lastFlushMs == 0L || now - lastFlushMs >= intervalMs) { + trailingJob?.cancel() + flushNow(now) + } else if (trailingJob?.isActive != true) { + trailingJob = + scope.launch { + delay(intervalMs) + flushNow(nowMs()) + } + } + } + + /** Keep the cumulative text: subsequent deltas append to the same message. */ + fun flushPending(override: ((String) -> Unit)? = null) { + trailingJob?.cancel() + flushNow(nowMs(), override) + } + + fun clear() { + trailingJob?.cancel() + trailingJob = null + content.clear() + lastFlushMs = 0L + } + + private fun flushNow( + now: Long, + override: ((String) -> Unit)? = null, + ) { + if (content.isEmpty()) return + lastFlushMs = now + (override ?: flush)(content.toString()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt index 03ef553d..24c01180 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt @@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.UsageSnapshotResponse data class StreamingState( val streamingMessage: ChatMessage? = null, + /** Sealed by an interrupt acknowledgement; late completion must update this row, not append another. */ + val interruptedMessage: ChatMessage? = null, val isThinking: Boolean = false, val thinkingText: String = "", val isReasoning: Boolean = false, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt new file mode 100644 index 00000000..4e7436dc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SystemBubble.kt @@ -0,0 +1,272 @@ +package com.m57.hermescontrol.ui.chat + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +private fun SelfImprovementReviewCard( + content: String, + modifier: Modifier = Modifier, +) { + val cleanText = + content + .removePrefix("💾") + .replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "") + .trim() + val isSkill = + cleanText.contains("skill", ignoreCase = true) || + cleanText.contains("SKILL.md", ignoreCase = true) + val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology + val title = + if (isSkill) { + "Self-Improvement Review • Skill Patched" + } else { + "Self-Improvement Review • Memory Updated" + } + + Card( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag("self_improvement_review_card"), + shape = RoundedCornerShape(10.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = cleanText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SystemBubble( + message: ChatMessage, + onRespondApproval: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + if (message.content.contains("Self-improvement review:", ignoreCase = true)) { + SelfImprovementReviewCard(content = message.content, modifier = modifier) + return + } + + val approvalInfo = message.approvalInfo + var confirmAlways by remember(message.id) { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = message.content, + style = + MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + // Approval action buttons — dynamic from backend `choices` + // (desktop `approval.tsx` parity: once/session/always/deny, + // smart-denied → once/deny only, allowPermanent=false hides Always). + if (approvalInfo != null) { + Spacer(Modifier.height(8.dp)) + val rawChoices = approvalInfo.choices ?: listOf("once", "deny") + val visibleChoices = + rawChoices.filter { choice -> + when (choice) { + "always" -> approvalInfo.allowPermanent != false + else -> true + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (choice in visibleChoices) { + when (choice) { + "deny" -> { + FilledTonalButton( + onClick = { onRespondApproval("deny") }, + modifier = + Modifier + .height(36.dp) + .testTag("deny_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Deny") + } + } + + "session" -> { + FilledTonalButton( + onClick = { onRespondApproval("session") }, + modifier = + Modifier + .height(36.dp) + .testTag("session_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Allow session") + } + } + + "always" -> { + FilledTonalButton( + onClick = { confirmAlways = true }, + modifier = + Modifier + .height(36.dp) + .testTag("always_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Always allow") + } + } + + else -> { + // "once" (+ legacy "approve" → normalized to once in VM) + FilledTonalButton( + onClick = { onRespondApproval(choice) }, + modifier = + Modifier + .height(36.dp) + .testTag("approve_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(if (choice == "approve") "Approve" else "Run") + } + } + } + } + } + // "Always allow" persists the pattern permanently — confirm first + // (desktop confirm-modal parity). + if (confirmAlways) { + AlertDialog( + onDismissRequest = { confirmAlways = false }, + title = { Text("Always allow this command?") }, + text = { + Text( + "This persists the pattern permanently so future " + + "matching commands run without asking.", + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmAlways = false + onRespondApproval("always") + }, + ) { + Text("Always allow") + } + }, + dismissButton = { + TextButton(onClick = { confirmAlways = false }) { + Text("Cancel") + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 8e09d120..e4a578b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -60,7 +59,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration @@ -69,23 +67,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.CodeTerminalBg -import com.m57.hermescontrol.theme.CodeTerminalBorder -import com.m57.hermescontrol.theme.CodeTerminalMuted -import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard import com.m57.hermescontrol.ui.chat.components.DiffViewCard import com.m57.hermescontrol.ui.chat.components.FileViewCard -import com.m57.hermescontrol.ui.chat.components.highlightSyntax -import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder -import kotlinx.coroutines.Dispatchers +import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** * Composes the collapsed summary lines for a tool card. @@ -135,6 +125,8 @@ internal fun composeToolSummaryLines( internal fun ToolBubble( message: ChatMessage, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } var showRawJson by remember { mutableStateOf(false) } @@ -144,7 +136,7 @@ internal fun ToolBubble( val view = remember(message.content, message.toolName, message.isToolRunning) { - parseToolOutput(message.content, message.toolName, message.isToolRunning) + ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning) } val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) @@ -179,12 +171,26 @@ internal fun ToolBubble( .padding(horizontal = 10.dp, vertical = 6.dp), ) { // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors, view?.serverDisplayName) + ToolBubbleHeader( + message = message, + config = config, + contentColor = contentColor, + statusColors = statusColors, + displayName = view?.serverDisplayName, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + ) // ── Tool progress preview (tool.progress) ── if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { Text( - text = message.progressPreview, + text = + buildHighlightedString( + message.progressPreview, + searchQuery, + isCurrentMatch, + statusColors, + ), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.7f), @@ -218,7 +224,7 @@ internal fun ToolBubble( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = firstLine, + text = buildHighlightedString(firstLine, searchQuery, isCurrentMatch, statusColors), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.7f), @@ -231,7 +237,7 @@ internal fun ToolBubble( } if (secondLine != null) { Text( - text = secondLine, + text = buildHighlightedString(secondLine, searchQuery, isCurrentMatch, statusColors), style = MaterialTheme.typography.bodySmall.copy( color = contentColor.copy(alpha = 0.5f), @@ -314,383 +320,6 @@ internal fun ToolBubble( } } -/** - * Renders the engine's [ToolView] — the expanded body of a tool card. - * - * Order: error line → terminal streams ($ command / stdout / stderr / exit - * code) → file diff → search hits → plain detail → duration footer. - */ -@Composable -private fun ExpandedToolContent( - view: ToolView, - contentColor: Color, - statusColors: HermesStatusColors, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - val isTerminal = - view.stdout != null || - view.stderr != null || - view.exitCode != null || - view.terminalCommand != null - - // ── Error line ── - view.error?.let { - Text( - text = stringResource(R.string.chat_tool_execution_error, it), - style = - MaterialTheme.typography.bodySmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - - // ── Terminal: $ command + streams + exit code ── - if (isTerminal) { - view.terminalCommand?.takeIf { it.isNotEmpty() }?.let { command -> - Text( - text = "$ $command", - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.stdout?.let { - Text( - text = it, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.stderr?.let { - Text( - text = it, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - view.exitCode?.let { code -> - if (code != 0) { - Text( - text = stringResource(R.string.chat_tool_exit_code, code), - style = - MaterialTheme.typography.labelSmall.copy( - color = statusColors.warning, - fontWeight = FontWeight.Medium, - ), - ) - } - } - } else { - // ── Generated/resolved image preview ── - view.imageUrl?.let { url -> - AsyncImage( - model = url, - contentDescription = view.title, - contentScale = ContentScale.Crop, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .clip(RoundedCornerShape(8.dp)), - ) - } - - // ── File diff ── - if (view.inlineDiff != null) { - DiffViewCard( - diffText = view.inlineDiff, - filePath = view.diffPath, - ) - } else if (view.fileContent != null) { - FileViewCard( - content = view.fileContent, - filePath = view.filePath, - ) - } - - // ── Search hits ── - if (!view.searchHits.isNullOrEmpty()) { - view.detailLabel?.let { label -> - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold, - ), - ) - } - view.searchHits.forEach { hit -> - Column(modifier = Modifier.padding(top = 2.dp)) { - if (hit.title.isNotEmpty()) { - Text( - text = hit.title, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontWeight = FontWeight.Medium, - ), - ) - } - if (hit.snippet.isNotEmpty()) { - Text( - text = hit.snippet, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontSize = 11.sp, - ), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - if (hit.url.isNotEmpty()) { - Text( - text = hit.url, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontSize = 11.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - - // ── Plain detail body ── - if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { - Text( - text = view.detail, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 240.dp) - .verticalScroll(rememberScrollState()), - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 12.sp, - ), - ) - } - } - - view.outputCut?.let { omitted -> - Text( - text = stringResource(R.string.chat_tool_output_omitted, omitted), - style = MaterialTheme.typography.labelSmall.copy(color = statusColors.warning), - ) - } - - // ── Duration footer ── - view.durationLabel?.let { - Text( - text = stringResource(R.string.tool_duration, it), - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) - } - } -} - -@Composable -private fun HeaderRow( - message: ChatMessage, - config: ToolDisplayConfig, - contentColor: Color, - statusColors: HermesStatusColors, - displayName: String? = null, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Status icon or spinner - if (message.isToolRunning) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary, - ) - } else { - val icon = - when (message.toolStatus) { - ToolStatus.COMPLETED -> Icons.Filled.CheckCircle - ToolStatus.FAILED -> Icons.Filled.Error - else -> Icons.Filled.Build - } - val tint = - when (message.toolStatus) { - ToolStatus.COMPLETED -> statusColors.success - ToolStatus.FAILED -> statusColors.error - else -> contentColor.copy(alpha = 0.6f) - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = tint, - ) - } - - Text( - text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), - style = - MaterialTheme.typography.labelMedium.copy( - color = contentColor, - fontFamily = FontFamily.Monospace, - ), - ) - } -} - -/** - * Security risk chip for [tool.output_risk] events. - * - * Shows a compact ⚠ badge when the backend flagged tool output as risky. - * Renders in the tool card between the header row and the summary line. - */ -@Composable -internal fun SecurityRiskChip( - riskData: ToolOutputRiskData, - contentColor: Color, - modifier: Modifier = Modifier, -) { - val statusColors = LocalHermesStatusColors.current - val (chipColor, label) = - when { - riskData.risk == "high" -> statusColors.error to "Risky output" - riskData.risk == "medium" -> statusColors.warning to "Caution" - else -> statusColors.warning to "Redacted" - } - - Row( - modifier = - modifier - .padding(top = 4.dp, start = 22.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = chipColor, - modifier = Modifier.size(14.dp), - ) - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Medium, - ), - color = chipColor, - ) - if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { - Text( - text = stringResource(R.string.tool_redacted), - style = MaterialTheme.typography.labelSmall, - color = chipColor.copy(alpha = 0.7f), - ) - } - } -} - -@Composable -private fun ToolRawJsonView( - rawContent: String, - modifier: Modifier = Modifier, -) { - var formatJson by remember { mutableStateOf(true) } - val formattedContent = remember(rawContent) { ToolJson.prettyPrintJson(rawContent) } - val isFormatDifferent = formattedContent != rawContent - val displayText = if (formatJson && isFormatDifferent) formattedContent else rawContent - - val highlighted by produceState( - initialValue = remember(displayText) { AnnotatedString(displayText) }, - key1 = displayText, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(displayText) - } - } - - CodeTerminalCard( - textToCopy = displayText, - modifier = modifier, - testTag = "tool_raw_json", - title = "JSON", - copyContentDescription = stringResource(R.string.content_desc_copy), - headerActions = { - if (isFormatDifferent) { - Text( - text = - if (formatJson) { - stringResource(R.string.chat_tool_compact_json) - } else { - stringResource(R.string.chat_tool_format_json) - }, - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("tool_json_format_toggle") - .clickable(role = Role.Button) { formatJson = !formatJson } - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - }, - ) { - SelectionContainer { - Text( - text = highlighted, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = CodeTerminalText, - softWrap = true, - ) - } - } -} - @Composable private fun CopyButton( visible: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt new file mode 100644 index 00000000..63da8c1b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +internal fun ToolBubbleHeader( + message: ChatMessage, + config: ToolDisplayConfig, + contentColor: Color, + statusColors: HermesStatusColors, + displayName: String? = null, + searchQuery: String = "", + isCurrentMatch: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Status icon or spinner + if (message.isToolRunning) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary, + ) + } else { + val icon = + when (message.toolStatus) { + ToolStatus.COMPLETED -> Icons.Filled.CheckCircle + ToolStatus.FAILED -> Icons.Filled.Error + else -> Icons.Filled.Build + } + val tint = + when (message.toolStatus) { + ToolStatus.COMPLETED -> statusColors.success + ToolStatus.FAILED -> statusColors.error + else -> contentColor.copy(alpha = 0.6f) + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = tint, + ) + } + + Text( + text = + buildHighlightedString( + displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback), + searchQuery, + isCurrentMatch, + statusColors, + ), + style = + MaterialTheme.typography.labelMedium.copy( + color = contentColor, + fontFamily = FontFamily.Monospace, + ), + ) + } +} + +/** + * Security risk chip for [tool.output_risk] events. + * + * Shows a compact ⚠ badge when the backend flagged tool output as risky. + * Renders in the tool card between the header row and the summary line. + */ +@Composable +internal fun SecurityRiskChip( + riskData: ToolOutputRiskData, + contentColor: Color, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + val (chipColor, label) = + when { + riskData.risk == "high" -> statusColors.error to "Risky output" + riskData.risk == "medium" -> statusColors.warning to "Caution" + else -> statusColors.warning to "Redacted" + } + + Row( + modifier = + modifier + .padding(top = 4.dp, start = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = chipColor, + modifier = Modifier.size(14.dp), + ) + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = chipColor, + ) + if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { + Text( + text = stringResource(R.string.tool_redacted), + style = MaterialTheme.typography.labelSmall, + color = chipColor.copy(alpha = 0.7f), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt new file mode 100644 index 00000000..b995cf13 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolRawJsonView.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.foundation.clickable +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard +import com.m57.hermescontrol.ui.chat.components.highlightSyntax +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun ToolRawJsonView( + rawContent: String, + modifier: Modifier = Modifier, +) { + var formatJson by remember { mutableStateOf(true) } + val clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) } + + // Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing. + val formatted by produceState?>(initialValue = null, key1 = rawContent) { + value = + withContext(Dispatchers.Default) { + val full = ToolJson.prettyPrintJson(rawContent) + if (full != rawContent) full to ToolJson.clampForDisplay(full) else null + } + } + + val isFormatDifferent = formatted != null + val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw + val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent + + val highlighted by produceState( + initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) }, + key1 = displayText, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(displayText) + } + } + + CodeTerminalCard( + textToCopy = copyText, + modifier = modifier, + testTag = "tool_raw_json", + title = "JSON", + copyContentDescription = stringResource(R.string.content_desc_copy), + headerActions = { + if (isFormatDifferent) { + Text( + text = + if (formatJson) { + stringResource(R.string.chat_tool_compact_json) + } else { + stringResource(R.string.chat_tool_format_json) + }, + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("tool_json_format_toggle") + .clickable(role = Role.Button) { formatJson = !formatJson } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + }, + ) { + SelectionContainer { + Text( + text = highlighted, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = CodeTerminalText, + softWrap = true, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 4af0ace6..7a61217c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -1,343 +1,19 @@ package com.m57.hermescontrol.ui.chat import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.AccountTree -import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.ChatBubble -import androidx.compose.material.icons.filled.Checklist -import androidx.compose.material.icons.filled.Computer -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Forum -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Keyboard -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Photo -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Visibility import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.ToolCatalog -/** - * Display configuration for a Hermes tool — controls the summary line, - * icon, and which arg field is shown when the bubble is collapsed. - * - * Based on the canonical tool schemas defined in the Hermes Agent source - * at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict). - */ +/** Display configuration for a Hermes tool — its name and header icon. */ data class ToolDisplayConfig( val name: String, - /** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */ - val summaryArgKey: String? = null, - /** Prefix for the summary line (e.g. "$ " for terminal). Emoji-free. */ - val summaryPrefix: String = "", /** Material icon for this tool type. */ val icon: ImageVector = Icons.Filled.Build, ) -/** Maps tool names to their display config. Ordered by expected frequency of use. */ +/** Icon lookup backed by the single [ToolCatalog] (issue #1326). */ object ToolSchemaRegistry { - private val knownTools: Map = - mapOf( - "terminal" to - ToolDisplayConfig( - name = "terminal", - summaryArgKey = "command", - summaryPrefix = "$ ", - icon = Icons.Filled.Terminal, - ), - "read_file" to - ToolDisplayConfig( - name = "read_file", - summaryArgKey = "path", - icon = Icons.Filled.Description, - ), - "write_file" to - ToolDisplayConfig( - name = "write_file", - summaryArgKey = "path", - icon = Icons.Filled.Edit, - ), - "patch" to - ToolDisplayConfig( - name = "patch", - summaryArgKey = "path", - icon = Icons.Filled.Build, - ), - "search_files" to - ToolDisplayConfig( - name = "search_files", - summaryArgKey = "pattern", - icon = Icons.Filled.Search, - ), - "web_search" to - ToolDisplayConfig( - name = "web_search", - summaryArgKey = "query", - icon = Icons.Filled.Public, - ), - "browser_navigate" to - ToolDisplayConfig( - name = "browser_navigate", - summaryArgKey = "url", - icon = Icons.Filled.Language, - ), - "browser_click" to - ToolDisplayConfig( - name = "browser_click", - summaryArgKey = "ref", - icon = Icons.Filled.TouchApp, - ), - "browser_snapshot" to - ToolDisplayConfig( - name = "browser_snapshot", - summaryArgKey = null, - icon = Icons.Filled.Photo, - ), - "clarify" to - ToolDisplayConfig( - name = "clarify", - summaryArgKey = "question", - icon = Icons.Filled.ChatBubble, - ), - "delegate_task" to - ToolDisplayConfig( - name = "delegate_task", - summaryArgKey = "goal", - icon = Icons.Filled.AccountTree, - ), - "execute_code" to - ToolDisplayConfig( - name = "execute_code", - summaryArgKey = "code", - icon = Icons.Filled.PlayArrow, - ), - "todo" to - ToolDisplayConfig( - name = "todo", - summaryArgKey = null, - icon = Icons.Filled.Checklist, - ), - "todo_list" to - ToolDisplayConfig( - name = "todo_list", - summaryArgKey = null, - icon = Icons.Filled.Checklist, - ), - "fact_store" to - ToolDisplayConfig( - name = "fact_store", - summaryArgKey = null, - icon = Icons.Filled.Psychology, - ), - "session_search" to - ToolDisplayConfig( - name = "session_search", - summaryArgKey = null, - icon = Icons.Filled.Search, - ), - // ── Action-based tools ── - "cronjob" to - ToolDisplayConfig( - name = "cronjob", - summaryArgKey = "action", - icon = Icons.Filled.Schedule, - ), - "cronjob_manage" to - ToolDisplayConfig( - name = "cronjob_manage", - summaryArgKey = "action", - icon = Icons.Filled.Schedule, - ), - "memory" to - ToolDisplayConfig( - name = "memory", - summaryArgKey = "action", - icon = Icons.Filled.Memory, - ), - "fact_feedback" to - ToolDisplayConfig( - name = "fact_feedback", - summaryArgKey = "action", - icon = Icons.Filled.ThumbUp, - ), - "process" to - ToolDisplayConfig( - name = "process", - summaryArgKey = "action", - icon = Icons.Filled.Settings, - ), - "process_manage" to - ToolDisplayConfig( - name = "process_manage", - summaryArgKey = "action", - icon = Icons.Filled.Settings, - ), - "skill_manage" to - ToolDisplayConfig( - name = "skill_manage", - summaryArgKey = "action", - icon = Icons.Filled.Build, - ), - // ── Web / Browser tools ── - "web_extract" to - ToolDisplayConfig( - name = "web_extract", - summaryArgKey = "urls", - icon = Icons.Filled.Language, - ), - "browser_type" to - ToolDisplayConfig( - name = "browser_type", - summaryArgKey = null, - icon = Icons.Filled.Keyboard, - ), - "browser_scroll" to - ToolDisplayConfig( - name = "browser_scroll", - summaryArgKey = "direction", - icon = Icons.Filled.TouchApp, - ), - "browser_back" to - ToolDisplayConfig( - name = "browser_back", - summaryArgKey = null, - icon = Icons.Filled.Language, - ), - "browser_press" to - ToolDisplayConfig( - name = "browser_press", - summaryArgKey = "key", - icon = Icons.Filled.Keyboard, - ), - "browser_get_images" to - ToolDisplayConfig( - name = "browser_get_images", - summaryArgKey = null, - icon = Icons.Filled.Image, - ), - "browser_vision" to - ToolDisplayConfig( - name = "browser_vision", - summaryArgKey = "question", - icon = Icons.Filled.Visibility, - ), - "browser_console" to - ToolDisplayConfig( - name = "browser_console", - summaryArgKey = null, - icon = Icons.Filled.Terminal, - ), - "browser_cdp" to - ToolDisplayConfig( - name = "browser_cdp", - summaryArgKey = "action", - icon = Icons.Filled.Build, - ), - "browser_dialog" to - ToolDisplayConfig( - name = "browser_dialog", - summaryArgKey = "action", - icon = Icons.Filled.ChatBubble, - ), - // ── Media / Vision tools ── - "vision_analyze" to - ToolDisplayConfig( - name = "vision_analyze", - summaryArgKey = "image_url", - icon = Icons.Filled.Visibility, - ), - "text_to_speech" to - ToolDisplayConfig( - name = "text_to_speech", - summaryArgKey = null, - icon = Icons.AutoMirrored.Filled.VolumeUp, - ), - "video_generate" to - ToolDisplayConfig( - name = "video_generate", - summaryArgKey = null, - icon = Icons.Filled.Movie, - ), - "image_generate" to - ToolDisplayConfig( - name = "image_generate", - summaryArgKey = null, - icon = Icons.Filled.Image, - ), - // ── Social / Messaging tools ── - "x_search" to - ToolDisplayConfig( - name = "x_search", - summaryArgKey = "query", - icon = Icons.Filled.Forum, - ), - "send_message" to - ToolDisplayConfig( - name = "send_message", - summaryArgKey = null, - icon = Icons.AutoMirrored.Filled.Send, - ), - // ── Skills tools ── - "skills_list" to - ToolDisplayConfig( - name = "skills_list", - summaryArgKey = null, - icon = Icons.AutoMirrored.Filled.MenuBook, - ), - "skill_view" to - ToolDisplayConfig( - name = "skill_view", - summaryArgKey = "name", - icon = Icons.Filled.AutoStories, - ), - // ── On-demand tools ── - "tool_search" to - ToolDisplayConfig( - name = "tool_search", - summaryArgKey = "query", - icon = Icons.Filled.Search, - ), - "tool_describe" to - ToolDisplayConfig( - name = "tool_describe", - summaryArgKey = "name", - icon = Icons.Filled.AutoStories, - ), - "tool_call" to - ToolDisplayConfig( - name = "tool_call", - summaryArgKey = "name", - icon = Icons.Filled.Build, - ), - // ── Misc ── - "read_terminal" to - ToolDisplayConfig( - name = "read_terminal", - summaryArgKey = "session_id", - icon = Icons.Filled.Computer, - ), - "computer_use" to - ToolDisplayConfig( - name = "computer_use", - summaryArgKey = "action", - icon = Icons.Filled.Computer, - ), - ) - fun getDisplayConfig(toolName: String?): ToolDisplayConfig = - toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool") + ToolDisplayConfig(name = toolName ?: "tool", icon = ToolCatalog.definitionFor(toolName).icon) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt new file mode 100644 index 00000000..c2de9bba --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -0,0 +1,252 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ClipData +import android.text.format.DateFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.theme.DarkOnSurface +import com.m57.hermescontrol.theme.LightOnSurface +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback +import com.m57.hermescontrol.util.BidiUtils +import kotlinx.coroutines.launch + +/** + * The user-message bubble — the universal anchor in the full-bleed chat + * renderer (issue #866). Agent prose renders full-bleed; user messages keep + * this bubble so the conversation stays scannable. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UserBubble( + message: ChatMessage, + searchQuery: String = "", + isCurrentMatch: Boolean = false, + onOpenAttachment: (Attachment) -> Unit = {}, + onSaveAttachment: (Attachment) -> Unit = {}, + savingAttachmentPath: String? = null, + openingAttachmentPath: String? = null, + canSaveAttachment: Boolean = true, + onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, + modifier: Modifier = Modifier, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val maxBubbleWidth = screenWidth * 0.80f + + AnimatedVisibility( + visible = true, + enter = + fadeIn() + + expandVertically( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ), + ) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + // Copy feedback: briefly show ✓ then revert + var copied by rememberCopyFeedback() + + val statusColors = LocalHermesStatusColors.current + + val highlightedText = + remember(message.content, searchQuery, isCurrentMatch, statusColors) { + if (searchQuery.isNotBlank()) { + buildHighlightedString( + message.content, + searchQuery, + isCurrentMatch, + statusColors, + ) + } else { + AnnotatedString(message.content) + } + } + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.CenterEnd, + ) { + val primary = MaterialTheme.colorScheme.primary + val userBubbleTextColor = + if (primary.luminance() > 0.5f) { + if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { + MaterialTheme.colorScheme.onPrimary + } else { + LightOnSurface + } + } else { + if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { + MaterialTheme.colorScheme.onPrimary + } else { + DarkOnSurface + } + } + Box { + Surface( + modifier = + Modifier + .widthIn(max = maxBubbleWidth) + .clip( + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 16.dp, + bottomEnd = 4.dp, + ), + ).background(color = primary) + .testTag("chat_bubble_user"), + color = Color.Transparent, + tonalElevation = 0.dp, + ) { + val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } + val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + SelectionContainer { + Text( + text = highlightedText, + color = userBubbleTextColor, + style = + MaterialTheme.typography.bodyMedium.copy( + textDirection = + if (isRtl) { + TextDirection.Rtl + } else { + TextDirection.Ltr + }, + ), + ) + } + // Render inline attachments + InlineAttachmentList( + attachments = message.attachments, + textColor = userBubbleTextColor, + onOpen = onOpenAttachment, + onSave = onSaveAttachment, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) + if (!message.isStreaming) { + FlowRow( + modifier = + Modifier + .align(Alignment.End) + .padding(top = 4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.content)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = userBubbleTextColor.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = userBubbleTextColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelSmall, + ) + if (messageStatsEnabled && showUserMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } + } + } + } + } + } + } + } + } + } +} 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 2a49d067..16a91ddf 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 @@ -52,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -124,6 +125,7 @@ fun ChatInputBar( onMicLock: () -> Unit = {}, isVoiceNoteLocked: Boolean = false, isRecordingVoice: Boolean = false, + amplitudeProvider: () -> Float = { 0f }, onStopGeneration: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the @@ -137,20 +139,31 @@ fun ChatInputBar( // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } - // The voice-note strip replaces the input field, so the IME closes the - // moment recording starts. When the strip goes away (send, delete, or - // cancel), focus returns to the input and the keyboard re-opens if it was - // up before — the next message must not need an extra tap (device - // follow-up, #1247). + // The voice-note strip renders inside the input field's decoration box, so + // the field stays composed and focused through the whole hold and the + // keyboard no longer collapses when recording starts (device follow-up, + // #1247). The restore below is only a safety net for a focus that still + // got lost (IME quirk); when the field kept focus, its IME state is left + // exactly as the user left it. val inputFocusRequester = remember { FocusRequester() } val softwareKeyboard = LocalSoftwareKeyboardController.current - var inputWasFocused by remember { mutableStateOf(false) } + var inputFocused by remember { mutableStateOf(false) } var restoreInputFocus by remember { mutableStateOf(false) } - // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the - // recomposition swaps the field out, focus is already on its way to the - // root, so the effect alone cannot read it reliably. + // While a voice note records, the composer's trailing slots must not + // morph: the send/queue AnimatedVisibility content would be disposed + // under the holding finger, dropping the pointer loop (and with it the + // release). The gesture loop already ignores these slots, so hold-start + // freezes the slot layout synchronously (an effect would land a frame + // late, after the slots already collapsed) and recording-end thaws them. + val holdShowSend = remember { mutableStateOf(false) } + val holdCanInterrupt = remember { mutableStateOf(false) } + // Snapshot at hold start: whether the keyboard was up (for the restore + // safety net) and the trailing slot layout (frozen while recording, see + // above). val handleMicHoldStart = { - restoreInputFocus = inputWasFocused + restoreInputFocus = inputFocused + holdShowSend.value = hasDraft + holdCanInterrupt.value = canInterrupt onMicHoldStart() } // A hold can arm and still fail to start the recorder (permission denied, @@ -194,6 +207,10 @@ fun ChatInputBar( .apply() } } + val recording = isRecordingVoice + val composerShowSend = if (recording) holdShowSend.value else hasDraft + val composerCanInterrupt = if (recording) holdCanInterrupt.value else canInterrupt + val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } @@ -347,70 +364,85 @@ fun ChatInputBar( } val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - if (isRecordingVoice) { - VoiceNoteRecordingPanel( - slideProgress = voiceSlideProgress, - locked = isVoiceNoteLocked, - onCancel = onMicHoldCancel, - 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) - .focusRequester(inputFocusRequester) - .onFocusChanged { focusState -> - if (!isRecordingVoice) { - inputWasFocused = focusState.isFocused - } - }.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() - } + // The field stays composed while a voice note records — + // drawn transparent, with the strip overlaid on top — so + // focus and the keyboard survive the hold (device + // follow-up, #1247). The overlay is a sibling, not the + // field's decoration content: decorationBox content does + // not size the field, so the strip would be clipped to + // the empty field's line height and vanish (CI, PR + // #1351). Keystrokes mid-hold are swallowed, and the + // composer's send/stop slots stay frozen — flipping them + // under the finger would drop the hold. + Box( + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp), + ) { + BasicTextField( + value = inputFieldValue, + onValueChange = { if (!isRecordingVoice) onInputChange(it) }, + modifier = + Modifier + .fillMaxWidth() + .alpha(if (isRecordingVoice) 0f else 1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + inputFocused = focusState.isFocused + }.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 -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty() && !isRecordingVoice) { + 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() } - }, + } + }, + ) + if (isRecordingVoice) { + // On top of the transparent field: keeps the + // strip's real 42 dp+ geometry instead of being + // clipped to the empty field's line height. + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + amplitude = amplitudeProvider, + modifier = Modifier.matchParentSize(), ) } } @@ -423,9 +455,9 @@ fun ChatInputBar( reasoningLevel = reasoningLevel, isListening = isListening, canSend = canSend, - showSend = hasDraft, + showSend = composerShowSend, onSend = onSend, - canInterrupt = canInterrupt, + canInterrupt = composerCanInterrupt, onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, @@ -460,20 +492,21 @@ fun ChatInputBar( } } } - // Zero-height anchor so the tooltip and lock icon float above the - // card, over the message list, like Telegram's lock control. It sits - // outside the animated card because AnimatedVisibility clips its - // children to the animation bounds. + // Anchor at the BOTTOM-end — directly above the mic button the thumb + // is holding. The earlier top-of-card anchor sat ~130 dp away from the + // thumb and read as unrelated decoration, so the lock affordance went + // unnoticed. The content grows upward (wrapContentHeight(Bottom, + // unbounded)) and the offset lifts it clear of the button top. Box(modifier = Modifier.fillMaxWidth().height(0.dp)) { VoiceNoteLockHintOverlay( visible = isRecordingVoice && !isVoiceNoteLocked, showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f, modifier = Modifier - .wrapContentHeight(align = Alignment.Top, unbounded = true) - .align(Alignment.TopEnd) - .offset(y = (-42).dp) - .padding(end = 12.dp), + .wrapContentHeight(align = Alignment.Bottom, unbounded = true) + .align(Alignment.BottomEnd) + .offset(y = (-54).dp) + .padding(end = 6.dp), ) } } @@ -513,18 +546,30 @@ private fun VoiceNoteLockHintOverlay( ) Spacer(modifier = Modifier.width(6.dp)) } - Box( - modifier = - Modifier - .size(36.dp) - .background(palette.control, RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = palette.placeholder, - modifier = Modifier.size(18.dp), + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(36.dp) + .background(palette.control, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = palette.placeholder, + modifier = Modifier.size(18.dp), + ) + } + // The track: a short line from the lock circle down toward the mic + // button — the affordance reads as "the thing above your thumb", + // and the direction of the gesture is implied by where it sits. + Box( + modifier = + Modifier + .width(1.5.dp) + .height(12.dp) + .background(palette.placeholder.copy(alpha = 0.6f)), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index e8cfd3bc..da4353e4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -1,24 +1,32 @@ package com.m57.hermescontrol.ui.chat.components import android.Manifest +import android.content.ClipData import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.correlationScopeId @@ -28,6 +36,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.SecretPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @Composable fun ChatLifecycleEffects( @@ -137,10 +146,24 @@ fun ChatLifecycleEffects( } // Show error as snackbar + val clipboard = LocalClipboard.current + val copyLabel = stringResource(R.string.action_copy_error) + // Own scope + immediate clear: a later `errorMessage = null` elsewhere must not cancel a visible popup. + val snackbarScope = rememberCoroutineScope() LaunchedEffect(errorMessage) { errorMessage?.let { error -> - snackbarHostState.showSnackbar(error) viewModel.clearError() + snackbarScope.launch { + val result = + snackbarHostState.showSnackbar( + error, + actionLabel = copyLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, error))) + } + } } } 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 1d8aa22f..fb013b82 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 @@ -35,6 +35,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.sqrt class ChatMediaLaunchers( val isListening: Boolean, @@ -48,6 +49,8 @@ class ChatMediaLaunchers( val onMicHoldEnd: () -> Unit = {}, val onMicHoldCancel: () -> Unit = {}, val onMicLock: () -> Unit = {}, + /** Normalized 0–1 mic level while recording; read fresh at draw time. */ + val amplitudeProvider: () -> Float = { 0f }, ) @Composable @@ -334,6 +337,23 @@ fun rememberChatMediaLaunchers( } } + // Mic-level feed for the recording blob (VoiceMicBlob). MediaRecorder's + // maxAmplitude is the PEAK since the last read, so this poller must stay + // the ONLY caller while recording. sqrt compression spreads speech + // dynamics the way Telegram's RMS/1800 normalization does. + val recordingAmplitude = remember { mutableStateOf(0f) } + LaunchedEffect(isRecordingVoice) { + if (isRecordingVoice) { + while (isRecordingVoice) { + recordingAmplitude.value = + sqrt(voiceNoteRecorder.currentAmplitude().coerceAtLeast(0) / 32767f) + delay(AMPLITUDE_POLL_MS) + } + } else { + recordingAmplitude.value = 0f + } + } + val onCameraTap: () -> Unit = { try { val timeStamp = @@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers( onMicHoldEnd = onMicHoldEnd, onMicHoldCancel = onMicHoldCancel, onMicLock = onMicLock, + amplitudeProvider = { recordingAmplitude.value }, ) } } + +/** Telegram samples their amplitude pipeline roughly this fast. */ +private const val AMPLITUDE_POLL_MS = 60L diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index 60a6c506..1875ad62 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -10,10 +10,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield /** @@ -60,14 +59,8 @@ class ChatScrollController( private var lastTailKey: Any? = null private var lastMessageCount: Int = 0 - /** - * Guard flag: true while a programmatic scroll-to-bottom is in flight. - * Prevents [observeUserScrollPosition] from flipping [isFollowingBottom] - * to false during the layout race — the scroll targets the old last item, - * Compose lays out the new item below, and [isAtBottom] momentarily - * returns false before the second scroll pass catches it. - */ - private var isProgrammaticScroll by mutableStateOf(false) + /** The sole active scroll command. Replaced by newer tail, search or explicit jumps. */ + private var scrollJob: Job? = null /** * Pixel tolerance for "at bottom" detection. Covers the LazyColumn's @@ -77,35 +70,31 @@ class ChatScrollController( */ var bottomPixelTolerance by mutableStateOf(48) - /** Serialized scroll scope — all scroll jobs funnel through here. */ - fun launchScroll(block: suspend CoroutineScope.() -> Unit) { - scope.launch(block = block) + /** Replace the previous scroll command; user gestures can cancel the same job. */ + private fun launchScroll(block: suspend CoroutineScope.() -> Unit) { + scrollJob?.cancel() + scrollJob = scope.launch(block = block) } - /** - * Read the live bottom-follow state from [LazyListState] and react to - * user-driven scroll position changes. Should be called inside a - * `LaunchedEffect(Unit)` so it observes the list for the screen's lifetime. - */ + /** Observe only arrival at the bottom; departures are owned by actual user input. */ fun observeUserScrollPosition() { scope.launch { + var previouslyAtBottom = listState.isAtBottom(bottomPixelTolerance) snapshotFlow { listState.isAtBottom(bottomPixelTolerance) } .distinctUntilChanged() .collect { atBottom -> - if (atBottom) { + if (atBottom && !previouslyAtBottom) { isFollowingBottom = true - // Reaching the bottom means the reader caught up. pendingCount = 0 - } else if (!isProgrammaticScroll) { - // Only break follow on USER-initiated scrolls. - // During programmatic scrolls the viewport is - // transiently not-at-bottom while new items lay out. - isFollowingBottom = false } + previouslyAtBottom = atBottom } } } + /** An upward user gesture takes ownership from auto-follow immediately. */ + fun onUserScrollUp() = pauseFollowing() + /** * Call on every tail-content change (new message, streaming token, thinking * toggle, subagent card, clarify prompt). [tailKey] must be a stable value @@ -132,14 +121,7 @@ class ChatScrollController( // Pin instantly so rapid streamed tokens don't queue competing // animations; the reader stays glued to the growing tail. // Use layout-aware scrolling to survive the Compose layout race. - scope.launch { - isProgrammaticScroll = true - try { - scrollToBottomAwaitingLayout() - } finally { - isProgrammaticScroll = false - } - } + launchScroll { scrollToBottomAwaitingLayout() } } else { pendingCount += newMessages } @@ -163,14 +145,10 @@ class ChatScrollController( yield() yield() listState.scrollToBottom(animated = animated) - // If we're still not at the bottom after the first scroll (e.g. a - // new item was laid out between the scroll and now), do one more pass. + // Yield once more if layout changed during the first pass, then align + // the current last row; newer tails replace this job instead of racing it. if (!listState.isAtBottom(bottomPixelTolerance)) { - // Wait up to 150ms for the layout to settle with the new items. - withTimeoutOrNull(150L) { - snapshotFlow { listState.layoutInfo.totalItemsCount } - .first { it > 0 } - } + yield() listState.scrollToBottom(animated = animated) } } @@ -179,19 +157,19 @@ class ChatScrollController( fun jumpToBottom(animated: Boolean = false) { pendingCount = 0 isFollowingBottom = true - scope.launch { - isProgrammaticScroll = true - try { - scrollToBottomAwaitingLayout(animated = animated) - } finally { - isProgrammaticScroll = false - } - } + launchScroll { scrollToBottomAwaitingLayout(animated = animated) } } /** An explicit history gesture must not be undone by a short list's bottom-follow. */ fun pauseFollowing() { isFollowingBottom = false + scrollJob?.cancel() + } + + /** Historical timeline navigation shares the scroll command with follow and search. */ + fun jumpToHistoryStart() { + pauseFollowing() + launchScroll { listState.scrollToItem(0) } } /** FAB tap: resume following + clear unread. */ @@ -218,10 +196,11 @@ class ChatScrollController( contentOffset: Int, contentLength: Int, ) { - scope.launch { + pauseFollowing() + launchScroll { listState.animateScrollToItem(index) val info = listState.layoutInfo - val item = info.visibleItemsInfo.firstOrNull { it.index == index } ?: return@launch + val item = info.visibleItemsInfo.firstOrNull { it.index == index } ?: return@launchScroll val viewportHeight = info.viewportEndOffset - info.viewportStartOffset val fraction = if (contentLength > 0) contentOffset.toFloat() / contentLength else 0f val targetTop = (viewportHeight / 3) - (item.size * fraction).toInt() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt new file mode 100644 index 00000000..d2c42b1b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ClarifyBubble.kt @@ -0,0 +1,301 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ClarifyUi + +// ── ClarifyBubble ───────────────────────────────────────────────────────── + +/** + * Centered dashed-border bubble showing a clarify question (or batch of questions) with + * selectable option chips, multi-select toggles, open-ended input, and a dismiss button. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ClarifyBubble( + clarifyRequest: ClarifyUi, + onRespondSingle: (String) -> Unit, + onRespondBatch: (Map) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val questions = clarifyRequest.resolvedQuestions + val isBatch = questions.size > 1 + + var selectedChoicesByQid by remember(clarifyRequest) { + mutableStateOf>>(emptyMap()) + } + var customTextByQid by remember(clarifyRequest) { + mutableStateOf>(emptyMap()) + } + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + .testTag("clarify_bubble"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + questions.forEachIndexed { index, q -> + val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + + if (isBatch) { + Text( + text = "${index + 1}. ${q.question}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = q.question, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (lockedAnswer != null) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Answered: $lockedAnswer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } else { + if (q.multiSelect) { + Spacer(Modifier.height(2.dp)) + Text( + text = "Select all that apply", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (q.choices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.choices.forEach { choice -> + val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() + val isSelected = selectedSet.contains(choice) + FilterChip( + selected = isSelected, + onClick = { + if (q.multiSelect) { + val updated = if (isSelected) selectedSet - choice else selectedSet + choice + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } else { + if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { + // Fast 1-tap respond for lone single-select question when no custom text is entered + onRespondSingle(choice) + } else { + val updated = if (isSelected) emptySet() else setOf(choice) + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } + } + }, + label = { Text(choice) }, + leadingIcon = + if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + val typed = customTextByQid[q.qid].orEmpty() + OutlinedTextField( + value = typed, + onValueChange = { newText -> + customTextByQid = customTextByQid + (q.qid to newText) + }, + label = { + Text( + if (q.choices.isEmpty()) { + stringResource(R.string.message_your_response) + } else { + "Other (optional)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + val hasAnyInput = + questions.any { q -> + (selectedChoicesByQid[q.qid]?.isNotEmpty() == true) || + (!customTextByQid[q.qid].isNullOrBlank()) + } + + FilledTonalButton( + onClick = { + if (isBatch) { + val answers = + questions.associate { q -> + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + q.qid to finalAns + } + onRespondBatch(answers) + } else { + val q = questions.first() + val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() + val custom = customTextByQid[q.qid]?.trim().orEmpty() + val finalAns = + when { + selectedList.isNotEmpty() && custom.isNotEmpty() -> { + (selectedList + custom).joinToString(", ") + } + + selectedList.isNotEmpty() -> { + selectedList.joinToString(", ") + } + + else -> { + custom + } + } + if (finalAns.isNotBlank()) { + onRespondSingle(finalAns) + } + } + }, + enabled = hasAnyInput, + ) { + Text(if (isBatch) "Submit All" else "Send") + } + TextButton(onClick = onDismiss) { + Text("Dismiss") + } + } + } + } +} + +/** + * Backward-compatible overload for legacy callers and unit tests. + */ +@Composable +fun ClarifyBubble( + text: String, + options: List, + onOptionSelected: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ClarifyBubble( + clarifyRequest = ClarifyUi(text = text, options = options), + onRespondSingle = onOptionSelected, + onRespondBatch = { answers -> + answers.values.firstOrNull()?.let(onOptionSelected) + }, + onDismiss = onDismiss, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt new file mode 100644 index 00000000..649228b2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.theme.searchHighlightColors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Terminal-style card for a fenced code block with syntax highlighting, + * a language badge, and a copy button. + */ +@Composable +fun CodeBlockCard( + code: String, + language: String?, + onCopy: (String) -> Unit, + modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, +) { + val highlights = searchHighlightColors(LocalHermesStatusColors.current) + val highlighted by produceState( + initialValue = remember(code) { AnnotatedString(code) }, + key1 = code, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(code) + } + } + + CodeTerminalCard( + textToCopy = code, + modifier = modifier, + testTag = "code_block", + title = language?.takeIf { it.isNotBlank() }?.uppercase(), + onCopy = onCopy, + copyContentDescription = "Copy code", + ) { + Text( + text = + remember(highlighted, searchQuery, isCurrentMatch, highlights) { + highlighted.withSearchHighlights(searchQuery, isCurrentMatch, highlights) + }, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = CodeTerminalText, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} + +/** Overlay search-hit spans on an already syntax-highlighted string, keeping its existing styles. */ +private fun AnnotatedString.withSearchHighlights( + query: String, + isCurrent: Boolean, + highlights: SearchHighlightColors, +): AnnotatedString { + if (query.isEmpty()) return this + val (bg, fg) = + if (isCurrent) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + return buildAnnotatedString { + append(this@withSearchHighlights) + var from = 0 + while (true) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + addStyle(SpanStyle(background = bg, color = fg), hit, hit + query.length) + from = hit + query.length + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt new file mode 100644 index 00000000..34721d6d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeSyntaxHighlighter.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import com.m57.hermescontrol.theme.CodeComment +import com.m57.hermescontrol.theme.CodeKeyword +import com.m57.hermescontrol.theme.CodeNumber +import com.m57.hermescontrol.theme.CodePunctuation +import com.m57.hermescontrol.theme.CodeString + +// Hoisted token patterns for syntax highlighting — compiled once. +private val HIGHLIGHT_TOKENS = + listOf( + TokenPattern(Regex("""//[^\n]*"""), CodeComment), + TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), + TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword), + TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), + TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), + TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), + TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber), + TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber), + TokenPattern( + Regex( + """\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" + + """override|private|protected|public|internal|import|package|""" + + """if|else|when|for|while|do|return|throw|try|catch|finally|""" + + """true|false|null|this|super|is|in|as|typealias|companion|""" + + """init|constructor|by|get|set|field|value|suspend|inline|""" + + """infix|operator|tailrec|external|annotation)\b""", + ), + CodeKeyword, + ), + TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation), + ) + +/** + * Builds an [AnnotatedString] from [code] with syntax highlighting colours + * applied via token regexes. Covers keywords, strings, comments, numbers, + * and punctuation — everything else remains the default light-grey. + */ +internal fun highlightSyntax(code: String): AnnotatedString = + buildAnnotatedString { + val tokens = HIGHLIGHT_TOKENS + var lastIndex = 0 + val matches = mutableListOf>() + + for (pattern in tokens) { + for (match in pattern.regex.findAll(code)) { + matches.add(match.range to pattern.color) + } + } + + matches.sortBy { it.first.first } + // Resolve overlaps: later-in-text wins for same-pos, else first-match + val resolved = mutableListOf>() + for (m in matches) { + if (resolved.isEmpty()) { + resolved.add(m) + } else { + val last = resolved.last() + if (m.first.first >= last.first.last + 1) { + // Non-overlapping — safe add + resolved.add(m) + } else if (m.first.first > last.first.first) { + // Overlap, this match starts later — it wins the overlapping portion + // Keep the last match's pre-overlap, then replace + resolved.removeAt(resolved.lastIndex) + // Split: keep text before overlap from last match + if (last.first.first < m.first.first) { + resolved.add(last.first.first.. m.first.last) { + resolved.add(m.first.last + 1..last.first.last to last.second) + } + } + // else same start position — first match wins, skip this one + } + } + + var pos = 0 + for ((range, color) in resolved) { + if (range.first > pos) { + append(code.substring(pos, range.first)) + } + withStyle(SpanStyle(color = color)) { + append(code.substring(range.first, range.last + 1)) + } + pos = range.last + 1 + } + if (pos < code.length) { + append(code.substring(pos)) + } + } + +private data class TokenPattern( + val regex: Regex, + val color: Color, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt index 531ba3f5..a1f2752f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import com.m57.hermescontrol.theme.GlyphWhite /** * Resolved colors for the chat composer card and its controls. @@ -15,7 +17,8 @@ import androidx.compose.ui.graphics.compositeOver * (Nord's High and Highest are the same color), which made the flat controls * vanish into the card. A fixed onSurface tint keeps them visible in every * preset and under dynamic color. Text and icon contrast is gated per preset - * in ComposerPaletteTest. + * in ComposerPaletteTest. The send / stop / mic action button is the one + * themed control: it takes the preset's primary so it follows the theme tint. */ internal data class ComposerPalette( val card: Color, @@ -30,10 +33,30 @@ internal data class ComposerPalette( ) private const val CONTROL_TINT_ALPHA = 0.12f +private const val ACTION_MIN_CONTRAST = 3f + +private fun contrastRatio( + a: Color, + b: Color, +): Float { + val l1 = a.luminance() + val l2 = b.luminance() + return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f) +} internal fun composerPalette(scheme: ColorScheme): ComposerPalette { val card = scheme.surfaceContainer val tint = scheme.onSurface.copy(alpha = CONTROL_TINT_ALPHA) + // Pastel primaries (Nord light) vanish on the card; keep the neutral fill there. + val tinted = contrastRatio(scheme.primary, card) >= ACTION_MIN_CONTRAST + val action = if (tinted) scheme.primary else scheme.onSurface + // White glyph wherever it clears 3:1 on the primary; pastel primaries keep their own dark onPrimary. + val onAction = + when { + !tinted -> scheme.surface + contrastRatio(GlyphWhite, scheme.primary) >= ACTION_MIN_CONTRAST -> GlyphWhite + else -> scheme.onPrimary + } return ComposerPalette( card = card, cardBorder = tint.compositeOver(scheme.background), @@ -42,8 +65,8 @@ internal fun composerPalette(scheme: ColorScheme): ComposerPalette { control = tint.compositeOver(card), onControl = scheme.onSurface, onControlVariant = scheme.onSurfaceVariant, - action = scheme.onSurface, - onAction = scheme.surface, + action = action, + onAction = onAction, ) } 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 6b47a49c..a2d03993 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 @@ -501,11 +501,12 @@ fun ComposerToolbar( } } - // 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). + // Flat slot — mic while idle. While a generation can be interrupted and a + // draft exists, Stop moves here so the rightmost action button stays + // Send (desktop parity: a payload keeps Send primary mid-turn; an empty + // draft makes Stop primary). 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), @@ -513,17 +514,16 @@ fun ComposerToolbar( ) { if (canInterrupt) { FilledIconButton( - onClick = onSend, - enabled = canSend, + onClick = onStopGeneration, colors = flatIconButtonColors(palette), modifier = Modifier .size(ControlSize) - .testTag("send_button"), + .testTag("stop_button"), ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + imageVector = Icons.Default.Stop, + contentDescription = stringResource(R.string.chat_voice_stop_generating), ) } } else { @@ -560,6 +560,9 @@ fun ComposerToolbar( // While a recording is locked this button finishes the voice note, so // it must stay enabled even when the draft alone would not send // (review, PR #1280). + // Stop owns the action button only when there is no draft to send; + // with a draft it lives in the flat slot above. + val actionStops = canInterrupt && !showSend val actionEnabled = if (isVoiceNoteLocked) { true @@ -588,7 +591,7 @@ fun ComposerToolbar( // (review, PR #1280). isVoiceNoteLocked -> onMicTap() - canInterrupt -> onStopGeneration() + actionStops -> onStopGeneration() showSend -> onSend() @@ -598,7 +601,7 @@ fun ComposerToolbar( ).testTag( when { isVoiceNoteLocked -> "voice_note_send_button" - canInterrupt -> "stop_button" + actionStops -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" @@ -610,7 +613,7 @@ fun ComposerToolbar( targetState = when { isVoiceNoteLocked -> ActionGlyph.SEND - canInterrupt -> ActionGlyph.STOP + actionStops -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP else -> ActionGlyph.VOICE diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt new file mode 100644 index 00000000..2381d0ea --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CopyFeedback.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.delay + +/** + * Remembers a copy feedback state and automatically resets it to `false` + * after 1500ms when set to `true`. + * + * If `copied.value` is already `true`, setting it to `true` again does not + * restart the active timer. + */ +@Composable +internal fun rememberCopyFeedback(): MutableState { + val copied = remember { mutableStateOf(false) } + + LaunchedEffect(copied.value) { + if (copied.value) { + delay(1500) + copied.value = false + } + } + + return copied +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt new file mode 100644 index 00000000..9ffbe313 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ImageViewerContent.kt @@ -0,0 +1,155 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.mediaNameFromPath + +/** + * Image surface of [MediaViewerDialog]: pinch-zoom + pan, downswipe-to-dismiss, + * and the shared streaming Save/Share actions. No ExoPlayer is created here. + */ +@Composable +internal fun ImageViewerContent( + mediaUri: String, + onDismissRequest: () -> Unit, + title: String?, + mimeType: String?, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + val displayName = + remember(mediaUri, title) { + title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri) + } + val export = rememberMediaExportActions(mediaUri, mimeType, displayName) + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxSize().testTag("media_viewer_dialog"), + color = MaterialTheme.colorScheme.scrim, + ) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = mediaUri, + contentDescription = + title?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.image_viewer_content_desc), + modifier = + Modifier + .fillMaxSize() + .testTag("media_image") + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY, + ).pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 5f) + // While zoomed in, allow free panning; at min scale, + // only vertical drags are permitted (for dismiss). + scale = newScale + offsetX = if (newScale > 1f) offsetX + pan.x else 0f + offsetY += pan.y + // Downswipe-to-dismiss when at min zoom. + if (newScale <= 1f && offsetY > 120f) { + onDismissRequest() + } + }, + ) + }, + contentScale = ContentScale.Fit, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onDismissRequest, + modifier = Modifier.testTag("media_close_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.image_viewer_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Row { + if (export.isBusy) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp).padding(horizontal = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + IconButton( + onClick = export.onSave, + modifier = Modifier.testTag("media_save_button"), + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = stringResource(R.string.image_viewer_save), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = export.onShare, + modifier = Modifier.testTag("media_share_button"), + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.image_viewer_share), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt index 972f4399..702d490a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineMediaPlayer.kt @@ -209,3 +209,18 @@ private fun InlineVideoPreview( } } } + +/** Formats milliseconds into `M:SS` or `H:MM:SS` */ +fun formatMediaDuration(durationMs: Long): String { + if (durationMs <= 0) return "0:00" + val totalSeconds = durationMs / 1000 + val seconds = totalSeconds % 60 + val minutes = (totalSeconds / 60) % 60 + val hours = totalSeconds / 3600 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt deleted file mode 100644 index 15ed878c..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Backwards-compatible wrapper delegating to [InlineMediaPlayer]. - */ -@Composable -fun InlineVideoPlayer( - videoUri: String, - modifier: Modifier = Modifier, - onFullScreenClick: () -> Unit = {}, -) { - InlineMediaPlayer( - uri = videoUri, - modifier = modifier, - onFullScreenClick = onFullScreenClick, - ) -} - -/** Formats milliseconds into `M:SS` or `H:MM:SS` */ -fun formatMediaDuration(durationMs: Long): String { - if (durationMs <= 0) return "0:00" - val totalSeconds = durationMs / 1000 - val seconds = totalSeconds % 60 - val minutes = (totalSeconds / 60) % 60 - val hours = totalSeconds / 3600 - - return if (hours > 0) { - "%d:%02d:%02d".format(hours, minutes, seconds) - } else { - "%d:%02d".format(minutes, seconds) - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt new file mode 100644 index 00000000..654fa690 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaExportActions.kt @@ -0,0 +1,217 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.m57.hermescontrol.ExternalActivityLifecycleGuard +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.notification.NotificationHelper +import com.m57.hermescontrol.ui.chat.MediaExportHelper +import com.m57.hermescontrol.ui.chat.MediaSaveStrategy +import com.m57.hermescontrol.ui.chat.acceptedSaveDestination +import com.m57.hermescontrol.ui.chat.mediaSaveStrategy +import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Save/Share state for the media viewer; [isBusy] is true while either is in flight. */ +internal class MediaExportActions( + val isBusy: Boolean, + val onSave: () -> Unit, + val onShare: () -> Unit, +) + +/** + * The single Save/Share pipeline for every media kind (issue #1328). Streams + * through [MediaExportHelper]: MediaStore Downloads on API 29+, a SAF + * create-document picker on API 26-28, and a FileProvider share cache. + */ +@Composable +internal fun rememberMediaExportActions( + mediaUri: String, + mimeType: String?, + displayName: String, +): MediaExportActions { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isBusy by remember { mutableStateOf(false) } + val saveFailed = stringResource(R.string.media_player_save_failed) + val shareFailed = stringResource(R.string.media_player_share_failed) + val shareTitle = stringResource(R.string.media_player_share_title) + + val launchExternalActivity: (() -> Unit) -> Unit = { launch -> + ExternalActivityLifecycleGuard.launchExternalActivity( + acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, + releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, + prepareForBackground = { NotificationHelper.start(context) }, + cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, + launch = launch, + ) + } + + val saveDocumentLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + try { + ExternalActivityLifecycleGuard.externalActivityReturned() + val destination = acceptedSaveDestination(result.resultCode, result.data?.data) + if (destination != null) { + scope.launch { + try { + val msg = + MediaExportHelper.saveMediaToUri( + context = context, + sourceUri = mediaUri, + targetUri = destination, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } else { + isBusy = false + } + } catch (e: Throwable) { + isBusy = false + if (e is CancellationException) throw e + } + } + + val onSave: () -> Unit = { + if (!isBusy) { + isBusy = true + when (mediaSaveStrategy()) { + MediaSaveStrategy.MEDIA_STORE -> { + scope.launch { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val msg = + MediaExportHelper.saveMediaToDownloads( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + + MediaSaveStrategy.CREATE_DOCUMENT -> { + val suggestedName = + MediaExportHelper.resolveDisplayName( + displayName = displayName, + mimeType = mimeType, + uri = mediaUri, + ) + val rawMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName) + val normalizedMime = normalizeFrameworkMime(rawMime) + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = normalizedMime + putExtra(Intent.EXTRA_TITLE, suggestedName) + } + try { + launchExternalActivity { + saveDocumentLauncher.launch(intent) + } + } catch (_: Exception) { + isBusy = false + scope.launch(Dispatchers.Main) { + Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + val onShare: () -> Unit = { + if (!isBusy) { + isBusy = true + scope.launch { + try { + val intent = + MediaExportHelper.shareMedia( + context = context, + uri = mediaUri, + fallbackMime = + mimeType?.takeUnless { it == "application/octet-stream" } + ?: com.m57.hermescontrol.ui.chat + .mediaMimeForPath(displayName), + displayName = displayName, + ) + withContext(Dispatchers.Main) { + if (intent != null) { + val chooser = Intent.createChooser(intent, shareTitle) + try { + launchExternalActivity { + context.startActivity(chooser) + } + } catch (_: Exception) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() + } + } finally { + isBusy = false + } + } + } + } + + return MediaExportActions(isBusy = isBusy, onSave = onSave, onShare = onShare) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt index b8666b08..41110577 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MediaViewerDialog.kt @@ -1,11 +1,6 @@ package com.m57.hermescontrol.ui.chat.components -import android.content.Intent import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -51,7 +46,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,28 +70,18 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.R import com.m57.hermescontrol.data.remote.MediaDataSourceProvider -import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors -import com.m57.hermescontrol.ui.chat.MediaExportHelper import com.m57.hermescontrol.ui.chat.MediaKind -import com.m57.hermescontrol.ui.chat.MediaSaveStrategy -import com.m57.hermescontrol.ui.chat.acceptedSaveDestination import com.m57.hermescontrol.ui.chat.classifyMedia import com.m57.hermescontrol.ui.chat.mediaNameFromPath -import com.m57.hermescontrol.ui.chat.mediaSaveStrategy -import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** - * Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer. + * The one full-screen media viewer (issue #1328): images get the zoom/pan + * surface, audio and video the ExoPlayer surface. Both share the same streaming + * Save/Share pipeline ([rememberMediaExportActions]). */ @OptIn(UnstableApi::class) @Composable @@ -108,7 +92,12 @@ fun MediaViewerDialog( mimeType: String? = null, ) { key(mediaUri, mimeType) { - MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + val kind = remember { classifyMedia(mimeType = mimeType, name = title, uri = mediaUri) } + if (kind == MediaKind.IMAGE) { + ImageViewerContent(mediaUri, onDismissRequest, title, mimeType) + } else { + MediaViewerContent(mediaUri, onDismissRequest, title, mimeType) + } } } @@ -122,7 +111,6 @@ private fun MediaViewerContent( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() val mediaKind = remember(mediaUri, mimeType, title) { @@ -145,12 +133,8 @@ private fun MediaViewerContent( var dragPositionMs by remember { mutableLongStateOf(0L) } var showControls by remember { mutableStateOf(true) } - var isBusy by remember { mutableStateOf(false) } val statusColors = LocalHermesStatusColors.current - val saveFailed = stringResource(R.string.media_player_save_failed) - val shareFailed = stringResource(R.string.media_player_share_failed) - val shareTitle = stringResource(R.string.media_player_share_title) val player = remember(context, mediaUri) { @@ -260,166 +244,10 @@ private fun MediaViewerContent( } } - val launchExternalActivity: (() -> Unit) -> Unit = { launch -> - ExternalActivityLifecycleGuard.launchExternalActivity( - acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, - releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease, - prepareForBackground = { NotificationHelper.start(context) }, - cleanupAfterLaunchFailure = { NotificationHelper.stop(context) }, - launch = launch, - ) - } - - val saveDocumentLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - try { - ExternalActivityLifecycleGuard.externalActivityReturned() - val destination = acceptedSaveDestination(result.resultCode, result.data?.data) - if (destination != null) { - scope.launch { - try { - val msg = - MediaExportHelper.saveMediaToUri( - context = context, - sourceUri = mediaUri, - targetUri = destination, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } else { - isBusy = false - } - } catch (e: Throwable) { - isBusy = false - if (e is CancellationException) throw e - } - } - - val onSave: () -> Unit = { - if (!isBusy) { - isBusy = true - when (mediaSaveStrategy()) { - MediaSaveStrategy.MEDIA_STORE -> { - scope.launch { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val msg = - MediaExportHelper.saveMediaToDownloads( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - - MediaSaveStrategy.CREATE_DOCUMENT -> { - val suggestedName = - MediaExportHelper.resolveDisplayName( - displayName = displayName, - mimeType = mimeType, - uri = mediaUri, - ) - val rawMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName) - val normalizedMime = normalizeFrameworkMime(rawMime) - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = normalizedMime - putExtra(Intent.EXTRA_TITLE, suggestedName) - } - try { - launchExternalActivity { - saveDocumentLauncher.launch(intent) - } - } catch (_: Exception) { - isBusy = false - scope.launch(Dispatchers.Main) { - Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show() - } - } - } - } - } - } - - val onShare: () -> Unit = { - if (!isBusy) { - isBusy = true - scope.launch { - try { - val intent = - MediaExportHelper.shareMedia( - context = context, - uri = mediaUri, - fallbackMime = - mimeType?.takeUnless { it == "application/octet-stream" } - ?: com.m57.hermescontrol.ui.chat - .mediaMimeForPath(displayName), - displayName = displayName, - ) - withContext(Dispatchers.Main) { - if (intent != null) { - val chooser = Intent.createChooser(intent, shareTitle) - try { - launchExternalActivity { - context.startActivity(chooser) - } - } catch (_: Exception) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show() - } - } finally { - isBusy = false - } - } - } - } + val exportActions = rememberMediaExportActions(mediaUri, mimeType, displayName) + val isBusy = exportActions.isBusy + val onSave = exportActions.onSave + val onShare = exportActions.onShare Dialog( onDismissRequest = onDismissRequest, 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 deleted file mode 100644 index df4038d9..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ /dev/null @@ -1,789 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.InfiniteRepeatableSpec -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.CodeComment -import com.m57.hermescontrol.theme.CodeKeyword -import com.m57.hermescontrol.theme.CodeNumber -import com.m57.hermescontrol.theme.CodePunctuation -import com.m57.hermescontrol.theme.CodeString -import com.m57.hermescontrol.theme.CodeTerminalBg -import com.m57.hermescontrol.theme.CodeTerminalBorder -import com.m57.hermescontrol.theme.CodeTerminalMuted -import com.m57.hermescontrol.theme.CodeTerminalText -import 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 -import kotlinx.coroutines.withContext - -// ── ReasoningCard ───────────────────────────────────────────────────────── - -/** - * Collapsible card showing the assistant's reasoning trace - * (reasoning-model thinking steps) before the final answer. - * - * Collapsed: "🧠 Reasoning · {N} steps" with chevron. - * 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]. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ReasoningCard( - reasoningText: String, - isStreaming: Boolean = false, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - var expanded by remember { mutableStateOf(false) } - var fullHeight by remember { mutableStateOf(false) } - var copied by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } - val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } - - // Copy feedback: briefly show "Copied" then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } - - Card( - modifier = - modifier - .fillMaxWidth() - .then(if (!isStreaming) Modifier.animateContentSize() else Modifier) - .combinedClickable( - onClick = { expanded = !expanded }, - onLongClick = { - val clipboard = - context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - ?: return@combinedClickable - clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText)) - copied = true - }, - ).testTag("reasoning_card"), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ), - shape = RoundedCornerShape(12.dp), - ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "🧠", - fontSize = 14.sp, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.message_reasoning_steps, stepCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - if (copied) { - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(R.string.reasoning_copied), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } - AnimatedVisibility(visible = expanded) { - Column { - 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)) - } - if (fullHeight) { - TextButton(onClick = { fullHeight = false }) { - Text(stringResource(R.string.reasoning_show_less)) - } - } else if (scrollState.maxValue > 0) { - TextButton(onClick = { fullHeight = true }) { - Text(stringResource(R.string.reasoning_show_full)) - } - } - } - } - } - } -} - -/** - * Small pulsing dot shown at the bottom of [ReasoningCard] while streaming. - */ -@Composable -private fun ReasoningPulsingDot(modifier: Modifier = Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse") - val pulseSpec: InfiniteRepeatableSpec = - remember { - infiniteRepeatable( - animation = tween(600, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ) - } - val alpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = pulseSpec, - label = "reasoning_dot_alpha", - ) - Box( - modifier = - modifier - .size(8.dp) - .clip(CircleShape) - .graphicsLayer { this.alpha = alpha } - .testTag("reasoning_pulsing_dot"), - ) { - androidx.compose.material3.Surface( - modifier = Modifier.size(8.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - ) {} - } -} - -// ── CodeBlockCard ───────────────────────────────────────────────────────── - -/** - * Terminal-style card for a fenced code block with syntax highlighting, - * a language badge, and a copy button. - */ -@Composable -fun CodeBlockCard( - code: String, - language: String?, - onCopy: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val highlighted by produceState( - initialValue = remember(code) { AnnotatedString(code) }, - key1 = code, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(code) - } - } - - CodeTerminalCard( - textToCopy = code, - modifier = modifier, - testTag = "code_block", - title = language?.takeIf { it.isNotBlank() }?.uppercase(), - onCopy = onCopy, - copyContentDescription = "Copy code", - ) { - Text( - text = highlighted, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = CodeTerminalText, - softWrap = false, - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} - -// Hoisted token patterns for syntax highlighting — compiled once. -private val HIGHLIGHT_TOKENS = - listOf( - TokenPattern(Regex("""//[^\n]*"""), CodeComment), - TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment), - TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword), - TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString), - TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString), - TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString), - TokenPattern(Regex("""\b0[xX][0-9a-fA-F]+\b"""), CodeNumber), - TokenPattern(Regex("""\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"""), CodeNumber), - TokenPattern( - Regex( - """\b(?:val|var|fun|class|object|interface|enum|data|sealed|open|abstract|""" + - """override|private|protected|public|internal|import|package|""" + - """if|else|when|for|while|do|return|throw|try|catch|finally|""" + - """true|false|null|this|super|is|in|as|typealias|companion|""" + - """init|constructor|by|get|set|field|value|suspend|inline|""" + - """infix|operator|tailrec|external|annotation)\b""", - ), - CodeKeyword, - ), - TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation), - ) - -/** - * Builds an [AnnotatedString] from [code] with syntax highlighting colours - * applied via token regexes. Covers keywords, strings, comments, numbers, - * and punctuation — everything else remains the default light-grey. - */ -internal fun highlightSyntax(code: String): AnnotatedString = - buildAnnotatedString { - val tokens = HIGHLIGHT_TOKENS - var lastIndex = 0 - val matches = mutableListOf>() - - for (pattern in tokens) { - for (match in pattern.regex.findAll(code)) { - matches.add(match.range to pattern.color) - } - } - - matches.sortBy { it.first.first } - // Resolve overlaps: later-in-text wins for same-pos, else first-match - val resolved = mutableListOf>() - for (m in matches) { - if (resolved.isEmpty()) { - resolved.add(m) - } else { - val last = resolved.last() - if (m.first.first >= last.first.last + 1) { - // Non-overlapping — safe add - resolved.add(m) - } else if (m.first.first > last.first.first) { - // Overlap, this match starts later — it wins the overlapping portion - // Keep the last match's pre-overlap, then replace - resolved.removeAt(resolved.lastIndex) - // Split: keep text before overlap from last match - if (last.first.first < m.first.first) { - resolved.add(last.first.first.. m.first.last) { - resolved.add(m.first.last + 1..last.first.last to last.second) - } - } - // else same start position — first match wins, skip this one - } - } - - var pos = 0 - for ((range, color) in resolved) { - if (range.first > pos) { - append(code.substring(pos, range.first)) - } - withStyle(SpanStyle(color = color)) { - append(code.substring(range.first, range.last + 1)) - } - pos = range.last + 1 - } - if (pos < code.length) { - append(code.substring(pos)) - } - } - -private data class TokenPattern( - val regex: Regex, - val color: Color, -) - -private fun copyToClipboard( - context: Context, - text: String, -) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return - clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) -} - -// ── ClarifyBubble ───────────────────────────────────────────────────────── - -/** - * Centered dashed-border bubble showing a clarify question (or batch of questions) with - * selectable option chips, multi-select toggles, open-ended input, and a dismiss button. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ClarifyBubble( - clarifyRequest: ClarifyUi, - onRespondSingle: (String) -> Unit, - onRespondBatch: (Map) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val questions = clarifyRequest.resolvedQuestions - val isBatch = questions.size > 1 - - var selectedChoicesByQid by remember(clarifyRequest) { - mutableStateOf>>(emptyMap()) - } - var customTextByQid by remember(clarifyRequest) { - mutableStateOf>(emptyMap()) - } - - Surface( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 4.dp) - .testTag("clarify_bubble"), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outline, - ), - ) { - Column( - modifier = Modifier.padding(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - questions.forEachIndexed { index, q -> - val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - - if (isBatch) { - Text( - text = "${index + 1}. ${q.question}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - } else { - Text( - text = q.question, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (lockedAnswer != null) { - Surface( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(8.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - text = "Answered: $lockedAnswer", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } else { - if (q.multiSelect) { - Spacer(Modifier.height(2.dp)) - Text( - text = "Select all that apply", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (q.choices.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - q.choices.forEach { choice -> - val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() - val isSelected = selectedSet.contains(choice) - FilterChip( - selected = isSelected, - onClick = { - if (q.multiSelect) { - val updated = if (isSelected) selectedSet - choice else selectedSet + choice - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } else { - if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { - // Fast 1-tap respond for lone single-select question when no custom text is entered - onRespondSingle(choice) - } else { - val updated = if (isSelected) emptySet() else setOf(choice) - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } - } - }, - label = { Text(choice) }, - leadingIcon = - if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } else { - null - }, - ) - } - } - } - - Spacer(Modifier.height(8.dp)) - val typed = customTextByQid[q.qid].orEmpty() - OutlinedTextField( - value = typed, - onValueChange = { newText -> - customTextByQid = customTextByQid + (q.qid to newText) - }, - label = { - Text( - if (q.choices.isEmpty()) { - stringResource(R.string.message_your_response) - } else { - "Other (optional)" - }, - ) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - } - - Spacer(Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - ) { - val hasAnyInput = - questions.any { q -> - (selectedChoicesByQid[q.qid]?.isNotEmpty() == true) || - (!customTextByQid[q.qid].isNullOrBlank()) - } - - FilledTonalButton( - onClick = { - if (isBatch) { - val answers = - questions.associate { q -> - val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() - val custom = customTextByQid[q.qid]?.trim().orEmpty() - val finalAns = - when { - selectedList.isNotEmpty() && custom.isNotEmpty() -> { - (selectedList + custom).joinToString(", ") - } - - selectedList.isNotEmpty() -> { - selectedList.joinToString(", ") - } - - else -> { - custom - } - } - q.qid to finalAns - } - onRespondBatch(answers) - } else { - val q = questions.first() - val selectedList = selectedChoicesByQid[q.qid]?.toList().orEmpty() - val custom = customTextByQid[q.qid]?.trim().orEmpty() - val finalAns = - when { - selectedList.isNotEmpty() && custom.isNotEmpty() -> { - (selectedList + custom).joinToString(", ") - } - - selectedList.isNotEmpty() -> { - selectedList.joinToString(", ") - } - - else -> { - custom - } - } - if (finalAns.isNotBlank()) { - onRespondSingle(finalAns) - } - } - }, - enabled = hasAnyInput, - ) { - Text(if (isBatch) "Submit All" else "Send") - } - TextButton(onClick = onDismiss) { - Text("Dismiss") - } - } - } - } -} - -/** - * Backward-compatible overload for legacy callers and unit tests. - */ -@Composable -fun ClarifyBubble( - text: String, - options: List, - onOptionSelected: (String) -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - ClarifyBubble( - clarifyRequest = ClarifyUi(text = text, options = options), - onRespondSingle = onOptionSelected, - onRespondBatch = { answers -> - answers.values.firstOrNull()?.let(onOptionSelected) - }, - onDismiss = onDismiss, - modifier = modifier, - ) -} - -// ── SubagentCard ────────────────────────────────────────────────────────── - -/** - * Inline card showing subagent task progress. - * - * - While running: [CircularProgressIndicator] + goal text on [tertiaryContainer]. - * - On completion: ✅ checkmark + summary text. - */ -@Composable -fun SubagentCard( - indicator: SubagentIndicator, - modifier: Modifier = Modifier, -) { - val isComplete = indicator.type == "subagent.complete" - Surface( - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp).testTag("subagent_card"), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (isComplete) { - Text(text = "✅", fontSize = 14.sp) - } else { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } - Spacer(Modifier.width(8.dp)) - val displayText = - if (isComplete && !indicator.summary.isNullOrBlank()) { - indicator.summary - } else if (!indicator.goal.isNullOrBlank()) { - indicator.goal - } else { - "Subagent task" - } - Text( - text = displayText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -// ── TypingIndicator ─────────────────────────────────────────────────────── - -/** - * Three subtle dots shown while the assistant is waiting to produce visible - * content. Staggered opacity/scale animation keeps the indicator lightweight - * without the distracting vertical bounce used by the old chat renderer. - */ -@Composable -fun TypingIndicator(modifier: Modifier = Modifier) { - val description = stringResource(R.string.chat_agent_status_typing) - Row( - modifier = - modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .semantics { contentDescription = description } - .testTag("typing_indicator"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - for (i in 0 until 3) { - TypingDot(delayMs = i * 150) - } - } -} - -@Composable -private fun TypingDot(delayMs: Int) { - val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs") - val typingSpec: InfiniteRepeatableSpec = - remember(delayMs) { - infiniteRepeatable( - animation = tween(700, delayMillis = delayMs, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ) - } - val offset by infiniteTransition.animateFloat( - initialValue = 0.85f, - targetValue = 1f, - animationSpec = typingSpec, - label = "typing_dot_scale_$delayMs", - ) - val alpha by infiniteTransition.animateFloat( - initialValue = 0.45f, - targetValue = 1f, - animationSpec = typingSpec, - label = "typing_dot_alpha_$delayMs", - ) - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .graphicsLayer { - this.scaleX = offset - this.scaleY = offset - this.alpha = alpha - }, - ) { - androidx.compose.material3.Surface( - modifier = Modifier.size(8.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) {} - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt new file mode 100644 index 00000000..b39c405e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.MessageReaction + +/** + * Tapback chips shown under a message. One reaction per author, so a short row is enough; + * renders nothing when [reactions] is empty. + */ +@Composable +fun MessageReactionChips( + reactions: List, + modifier: Modifier = Modifier, +) { + if (reactions.isEmpty()) return + Row( + modifier = modifier.testTag("message_reactions"), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + reactions.forEach { reaction -> + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp, + ) { + Text( + text = reaction.emoji, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt new file mode 100644 index 00000000..14879136 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt @@ -0,0 +1,235 @@ +package com.m57.hermescontrol.ui.chat.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.buildHighlightedString + +/** + * Collapsible card showing the assistant's reasoning trace + * (reasoning-model thinking steps) before the final answer. + * + * Collapsed: "🧠 Reasoning · {N} steps" with chevron. + * 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]. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReasoningCard( + reasoningText: String, + isStreaming: Boolean = false, + modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, + searchOffset: Int = 0, +) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + LaunchedEffect(isCurrentMatch, searchQuery) { + if (isCurrentMatch && searchQuery.isNotBlank()) expanded = true + } + var fullHeight by remember { mutableStateOf(false) } + // Copy feedback: briefly show "Copied" then revert + var copied by rememberCopyFeedback() + val scrollState = rememberScrollState() + LaunchedEffect(expanded, isCurrentMatch, searchQuery, searchOffset, scrollState.maxValue) { + if (expanded && isCurrentMatch && searchQuery.isNotBlank() && scrollState.maxValue > 0) { + val fraction = searchOffset.toFloat() / reasoningText.length.coerceAtLeast(1) + scrollState.scrollTo((scrollState.maxValue * fraction).toInt()) + } + } + val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 } + val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } + + Card( + modifier = + modifier + .fillMaxWidth() + .then(if (!isStreaming) Modifier.animateContentSize() else Modifier) + .combinedClickable( + onClick = { expanded = !expanded }, + onLongClick = { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return@combinedClickable + clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText)) + copied = true + }, + ).testTag("reasoning_card"), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "🧠", + fontSize = 14.sp, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.message_reasoning_steps, stepCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (copied) { + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.reasoning_copied), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + AnimatedVisibility(visible = expanded) { + Column { + 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) { + val statusColors = LocalHermesStatusColors.current + Text( + text = buildHighlightedString(reasoningText, searchQuery, isCurrentMatch, statusColors), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = contentModifier, + ) + } else { + MarkdownText( + text = reasoningText, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + modifier = contentModifier, + ) + } + if (isStreaming) { + ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp)) + } + if (fullHeight) { + TextButton(onClick = { fullHeight = false }) { + Text(stringResource(R.string.reasoning_show_less)) + } + } else if (scrollState.maxValue > 0) { + TextButton(onClick = { fullHeight = true }) { + Text(stringResource(R.string.reasoning_show_full)) + } + } + } + } + } + } +} + +/** + * Small pulsing dot shown at the bottom of [ReasoningCard] while streaming. + */ +@Composable +private fun ReasoningPulsingDot(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "reasoning_pulse") + val pulseSpec: InfiniteRepeatableSpec = + remember { + infiniteRepeatable( + animation = tween(600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = pulseSpec, + label = "reasoning_dot_alpha", + ) + Box( + modifier = + modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { this.alpha = alpha } + .testTag("reasoning_pulsing_dot"), + ) { + Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) {} + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt new file mode 100644 index 00000000..be8adc15 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TypingIndicator.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Three subtle dots shown while the assistant is waiting to produce visible + * content. Staggered opacity/scale animation keeps the indicator lightweight + * without the distracting vertical bounce used by the old chat renderer. + */ +@Composable +fun TypingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_agent_status_typing) + Row( + modifier = + modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = description } + .testTag("typing_indicator"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (i in 0 until 3) { + TypingDot(delayMs = i * 150) + } + } +} + +@Composable +private fun TypingDot(delayMs: Int) { + val infiniteTransition = rememberInfiniteTransition(label = "typing_dot_$delayMs") + val typingSpec: InfiniteRepeatableSpec = + remember(delayMs) { + infiniteRepeatable( + animation = tween(700, delayMillis = delayMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ) + } + val offset by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1f, + animationSpec = typingSpec, + label = "typing_dot_scale_$delayMs", + ) + val alpha by infiniteTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = typingSpec, + label = "typing_dot_alpha_$delayMs", + ) + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .graphicsLayer { + this.scaleX = offset + this.scaleY = offset + this.alpha = alpha + }, + ) { + Surface( + modifier = Modifier.size(8.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) {} + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt deleted file mode 100644 index 429b5230..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.m57.hermescontrol.ui.chat.components - -import androidx.compose.runtime.Composable - -/** - * Backwards-compatible wrapper delegating to [MediaViewerDialog]. - */ -@Composable -fun VideoViewerDialog( - videoUri: String, - onDismissRequest: () -> Unit, -) { - MediaViewerDialog( - mediaUri = videoUri, - onDismissRequest = onDismissRequest, - ) -} 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 index a214b443..a9ce4ecb 100644 --- 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 @@ -10,15 +10,21 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredWidth 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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize @@ -26,13 +32,15 @@ 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.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -62,7 +70,9 @@ import kotlin.math.roundToInt * and forth, and slides left and fades as the gesture drags toward cancel * ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to * lock, the hint is replaced by a bold tappable "CANCEL" and the action button - * submits the note. + * submits the note. Behind the hint, a live mic-level meter (bars from the + * original #1250 build, restored) shows the recording level — louder speech + * makes the bars visibly climb; flat bars mean the mic hears silence. */ @Composable internal fun VoiceNoteRecordingPanel( @@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel( modifier: Modifier = Modifier, locked: Boolean = false, onCancel: () -> Unit = {}, + amplitude: () -> Float = { 0f }, ) { val palette = composerPalette() val density = LocalDensity.current @@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel( delay(200) } } + // Level meter: rolling window of the last [VOICE_NOTE_BARS] samples. The + // provider emits at the 60 ms poll cadence with Telegram's smoothing + // already applied; equal values do not re-emit, so silence writes nothing + // (Compose stays idle for instrumented tests). + val levels = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + snapshotFlow { amplitude() }.collect { level -> + levels.add(level) + while (levels.size > VOICE_NOTE_BARS) { + levels.removeAt(0) + } + } + } // Telegram blinks the record dot on a 1200 ms cycle. val blink = rememberInfiniteTransition(label = "voice_dot") val dotAlpha by @@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel( fontWeight = FontWeight.Bold, color = palette.placeholder, ) + Spacer(modifier = Modifier.width(10.dp)) + LevelMeter(levels = levels, tint = palette.placeholder) + Spacer(modifier = Modifier.weight(1f)) BoxWithConstraints( modifier = Modifier - .weight(1f) - .clipToBounds(), + .weight(1f), contentAlignment = Alignment.Center, ) { val containerWidthPx = with(density) { maxWidth.toPx() } @@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel( }.testTag("voice_note_cancel_button"), ) } else { + // requiredWidth(intrinsic): the hint lays out at its natural + // width even when the meter leaves it tight, and the removed + // border clip lets the nudge and the cancel drag overflow the + // box instead of being cut at its edges — the fade carries it. Row( modifier = Modifier + .requiredWidth(IntrinsicSize.Max) .offset { val shift = -containerWidthPx * 0.25f * (1f - progress) val nudge = nudgeDp * density.density * progress IntOffset((shift + nudge).roundToInt(), 0) }.alpha(progress), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { CancelChevron(tint = palette.placeholder) Spacer(modifier = Modifier.width(6.dp)) @@ -172,6 +204,39 @@ internal fun VoiceNoteRecordingPanel( } } +/** + * The live mic-level meter from the original #1250 build: a row of small + * vertical bars, oldest left, newest right; each bar's height tracks its + * sample (4–22 dp). Silences stay flat, loud speech climbs — the immediate + * level feedback the blob could not give. + */ +@Composable +private fun LevelMeter( + levels: SnapshotStateList, + tint: Color, +) { + Row( + modifier = Modifier.height(22.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(tint, RoundedCornerShape(2.dp)), + ) + } + } +} + +/** Number of level-meter bars in the recording panel. */ +private const val VOICE_NOTE_BARS = 14 + /** "Slide to cancel" with the cancel word carrying the visual emphasis. */ @Composable private fun slideToCancelText(palette: ComposerPalette) = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt new file mode 100644 index 00000000..2b460802 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatSearchState +import com.m57.hermescontrol.ui.chat.components.ChatScrollController +import com.m57.hermescontrol.ui.chat.searchTextFor + +/** Resolve a search hit against emitted lazy rows, including hoisted reasoning and tool rows. */ +@Composable +internal fun ChatSearchScrollEffect( + searchState: ChatSearchState, + messages: List, + renderedFirstId: String?, + turns: List, + scrollController: ChatScrollController, +) { + LaunchedEffect( + searchState.isActive, + searchState.currentIndex, + searchState.matchIndices, + searchState.matchOffsets, + searchState.matchTargets, + renderedFirstId, + ) { + val index = searchState.currentIndex + if (!searchState.isActive || index !in searchState.matchIndices.indices) return@LaunchedEffect + val messageIndex = searchState.matchIndices[index] + val message = messages.getOrNull(messageIndex) ?: return@LaunchedEffect + val target = searchState.matchTargets.getOrNull(index) ?: return@LaunchedEffect + val match = + com.m57.hermescontrol.ui.chat.SearchMatch( + messageIndex = messageIndex, + contentOffset = searchState.matchOffsets.getOrElse(index) { 0 }, + target = target, + ) + // A hit in a staged history prefix is retried when the prefix enters layout. + val lazyIndex = searchMatchToLazyIndex(turns, messages, match) ?: return@LaunchedEffect + scrollController.scrollToSearchMatch(lazyIndex, match.contentOffset, searchTextFor(message, target).length) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 8899c7d4..a95890a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -22,9 +22,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -41,15 +39,18 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.MessageSegment import com.m57.hermescontrol.ui.chat.TokenEstimator +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard -import kotlinx.coroutines.delay +import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback +import com.m57.hermescontrol.ui.chat.splitByMedia import kotlinx.coroutines.launch /** * Full-bleed renderer for ONE agent (assistant) message (issue #866). * - * Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders + * Unlike [com.m57.hermescontrol.ui.chat.UserBubble], agent prose renders * directly on the background — no bubble container, no width cap — with a * trailing copy affordance. User messages keep their bubbles; this composable * is only used for ASSISTANT messages. @@ -60,6 +61,9 @@ internal fun FullBleedAgentMessage( message: ChatMessage, searchQuery: String = "", isCurrentMatch: Boolean = false, + reasoningSearchQuery: String = "", + isCurrentReasoningMatch: Boolean = false, + reasoningSearchOffset: Int = 0, showReasoning: Boolean = true, onOpenAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {}, @@ -77,15 +81,8 @@ internal fun FullBleedAgentMessage( val textColor = MaterialTheme.colorScheme.onSurface val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - // Copy feedback: briefly show ✓ then revert - LaunchedEffect(copied) { - if (copied) { - delay(1500) - copied = false - } - } + var copied by rememberCopyFeedback() Column( modifier = @@ -98,6 +95,9 @@ internal fun FullBleedAgentMessage( ReasoningCard( reasoningText = message.reasoningText, isStreaming = message.isStreaming, + searchQuery = reasoningSearchQuery, + isCurrentMatch = isCurrentReasoningMatch, + searchOffset = reasoningSearchOffset, ) Spacer(modifier = Modifier.height(6.dp)) } @@ -106,36 +106,46 @@ internal fun FullBleedAgentMessage( // lone Copy button). Blank rows are tool-call placeholders that slipped // through upstream mapping; the parent list renders the live status // indicator until the first visible delta lands. - if (message.content.isNotBlank()) { - SelectionContainer { - MarkdownText( - text = message.content, - textColor = textColor, - isStreaming = message.isStreaming, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - onImageClick = onImageClick, - ) + // Issue #1367: agent media renders where its `MEDIA:` directive sat in the + // prose; attachments without a recorded position trail the text. + val segments = + remember(message.content, message.attachments) { + splitByMedia(message.content, message.attachments) + } + segments.forEach { segment -> + when (segment) { + is MessageSegment.Text -> { + SelectionContainer { + MarkdownText( + text = segment.text, + textColor = textColor, + isStreaming = message.isStreaming, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } + } + + is MessageSegment.Media -> { + Spacer(modifier = Modifier.height(6.dp)) + InlineAttachment( + attachment = segment.attachment, + textColor = textColor, + onOpen = onOpenAttachment, + onSave = onSaveAttachment, + savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, + canSave = canSaveAttachment, + onImageClick = onImageClick, + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } - // Render inline attachments (mirrors ChatBubble so agent-delivered - // media — images, files — shows in full-bleed mode too). - if (!message.attachments.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - message.attachments.forEach { attachment -> - InlineAttachment( - attachment = attachment, - textColor = textColor, - onOpen = { onOpenAttachment(it) }, - onSave = { onSaveAttachment(it) }, - savingPath = savingAttachmentPath, - openingPath = openingAttachmentPath, - canSave = canSaveAttachment, - onImageClick = onImageClick, - ) - Spacer(modifier = Modifier.height(4.dp)) - } + if (!message.isStreaming) { + MessageReactionChips(message.reactions, Modifier.padding(top = 2.dp)) } if (!message.isStreaming && message.content.isNotBlank()) { 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 0deaff27..bf93897c 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 @@ -32,20 +32,16 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale -import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState -import com.m57.hermescontrol.ui.chat.ChatViewModel -import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel -import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.ToolCallDivider -import com.m57.hermescontrol.ui.chat.VaultCodePromptUi -import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi -import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.VaultCodeCard import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard @@ -53,6 +49,14 @@ import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard import com.m57.hermescontrol.ui.chat.toolCallMilestones import com.m57.hermescontrol.ui.common.EmptyState +private object FullBleedContentType { + const val USER: String = "user" + const val REASONING: String = "reasoning" + const val PROSE: String = "prose" + const val TOOL: String = "tool" + const val SYSTEM_EVENT: String = "system_event" +} + /** * The chat message list for FULL-BLEED style (issue #866) — the single chat * surface since the bubble renderer was removed. User messages keep their @@ -70,50 +74,36 @@ import com.m57.hermescontrol.ui.common.EmptyState */ @Composable fun FullBleedChatList( - messages: List, - streamingState: StreamingState, - isAgentTyping: Boolean, + transcript: TranscriptUiState, + actions: TranscriptActions, searchState: ChatSearchState, - typingEffectEnabled: Boolean, - typingEffectDelayMs: Int, - messageStatsEnabled: Boolean = false, - showUserMessageTokens: Boolean = true, - showAssistantMessageTokens: Boolean = true, - showTokensPerSecond: Boolean = true, - maxToolCallsPerTurn: Int? = null, - isLoading: Boolean, - isLoadingOlder: Boolean, listState: androidx.compose.foundation.lazy.LazyListState, scrollController: ChatScrollController, - lastAnimatedMessageId: String?, - onLastAnimatedMessageIdChange: (String?) -> Unit, - viewModel: ChatViewModel, - clarifyRequest: ClarifyUi? = null, - onRespondClarify: ((String) -> Unit)? = null, - onRespondClarifyBatch: ((Map) -> Unit)? = null, - onDismissClarify: (() -> Unit)? = null, - vaultUnlockPrompt: VaultUnlockPromptUi? = null, - onRespondVaultUnlock: ((String) -> Unit)? = null, - onDismissVaultUnlock: (() -> Unit)? = null, - vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, - onRespondVaultSaveLogin: ((String, String) -> Unit)? = null, - onDismissVaultSaveLogin: (() -> Unit)? = null, - vaultCodePrompt: VaultCodePromptUi? = null, - onRespondVaultCode: ((String) -> Unit)? = null, - onDismissVaultCode: (() -> Unit)? = null, - onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, - savingAttachmentPath: String? = null, - openingAttachmentPath: String? = null, - onImageClick: (ImageViewerModel) -> Unit = {}, - hasOlderMessages: Boolean = false, - pagingSessionId: String? = null, - onLoadOlder: () -> Unit = viewModel::loadOlderMessages, replyErrorContent: (@Composable () -> Unit)? = null, - speakingMessageId: String? = null, - onToggleSpeak: ((ChatMessage) -> Unit)? = null, - isCompressing: Boolean = false, - compressionStatus: String? = null, ) { + val messages = transcript.messages + val streamingState = transcript.streamingState + val isAgentTyping = transcript.isAgentTyping + val typingEffectEnabled = transcript.typingEffectEnabled + val typingEffectDelayMs = transcript.typingEffectDelayMs + val messageStatsEnabled = transcript.messageStatsEnabled + val showUserMessageTokens = transcript.showUserMessageTokens + val showAssistantMessageTokens = transcript.showAssistantMessageTokens + val showTokensPerSecond = transcript.showTokensPerSecond + val maxToolCallsPerTurn = transcript.maxToolCallsPerTurn + val isLoading = transcript.isLoading + val isLoadingOlder = transcript.isLoadingOlder + val hasOlderMessages = transcript.hasOlderMessages + val pagingSessionId = transcript.pagingSessionId + val clarifyRequest = transcript.clarifyRequest + val vaultUnlockPrompt = transcript.vaultUnlockPrompt + val vaultSaveLoginPrompt = transcript.vaultSaveLoginPrompt + val vaultCodePrompt = transcript.vaultCodePrompt + val savingAttachmentPath = transcript.savingAttachmentPath + val openingAttachmentPath = transcript.openingAttachmentPath + val isCompressing = transcript.isCompressing + val compressionStatus = transcript.compressionStatus + val speakingMessageId = transcript.speakingMessageId if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { Box( modifier = Modifier.fillMaxSize(), @@ -183,9 +173,9 @@ fun FullBleedChatList( put("clarify_bubble") { ClarifyBubble( clarifyRequest = clarifyRequest, - onRespondSingle = { option -> onRespondClarify?.invoke(option) }, - onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, - onDismiss = { onDismissClarify?.invoke() }, + onRespondSingle = actions.onRespondClarify, + onRespondBatch = actions.onRespondClarifyBatch, + onDismiss = actions.onDismissClarify, ) } } @@ -194,8 +184,8 @@ fun FullBleedChatList( put("vault_unlock_card") { VaultUnlockCard( prompt = vaultUnlockPrompt, - onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, - onDismiss = { onDismissVaultUnlock?.invoke() }, + onConfirm = actions.onRespondVaultUnlock, + onDismiss = actions.onDismissVaultUnlock, ) } } @@ -204,10 +194,8 @@ fun FullBleedChatList( put("vault_save_login_card") { VaultSaveLoginCard( prompt = vaultSaveLoginPrompt, - onConfirm = { identifier, password -> - onRespondVaultSaveLogin?.invoke(identifier, password) - }, - onDismiss = { onDismissVaultSaveLogin?.invoke() }, + onConfirm = actions.onRespondVaultSaveLogin, + onDismiss = actions.onDismissVaultSaveLogin, ) } } @@ -216,8 +204,8 @@ fun FullBleedChatList( put("vault_code_card") { VaultCodeCard( prompt = vaultCodePrompt, - onConfirm = { code -> onRespondVaultCode?.invoke(code) }, - onDismiss = { onDismissVaultCode?.invoke() }, + onConfirm = actions.onRespondVaultCode, + onDismiss = actions.onDismissVaultCode, ) } } @@ -243,7 +231,7 @@ fun FullBleedChatList( } val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() } val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading) - val loadOlder = rememberUpdatedState(onLoadOlder) + val loadOlder = rememberUpdatedState(actions.onLoadOlder) val prefetchConnection = remember(listState, prefetch, scrollController) { object : NestedScrollConnection { @@ -251,6 +239,9 @@ fun FullBleedChatList( available: Offset, source: NestedScrollSource, ): Offset { + if (source == NestedScrollSource.UserInput && available.y > 0f) { + scrollController.onUserScrollUp() + } if (prefetch.onScroll( firstVisibleIndex = listState.firstVisibleItemIndex, deltaY = available.y, @@ -271,32 +262,13 @@ fun FullBleedChatList( } } - // Scroll the current search match into view, word-focused. Lives here - // (not in ChatLifecycleEffects) because only this composable knows - // the message-id → lazy-item-index mapping. Reads search fields in - // the effect (not the body), so only this effect restarts on change. - LaunchedEffect( - searchState.isActive, - searchState.currentIndex, - searchState.matchIndices, - searchState.matchOffsets, - renderedMessages.firstOrNull()?.id, - ) { - if (searchState.isActive && - searchState.currentIndex >= 0 && - searchState.currentIndex < searchState.matchIndices.size - ) { - val messageIndex = searchState.matchIndices[searchState.currentIndex] - if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect - // Search indices refer to incoming messages; resolve by id into the - // rendered rows. A hit in a staged prefix is retried when it becomes visible. - val lazyIndexById = messageIdToLazyIndex(turns) - val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect - val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 } - val contentLength = messages[messageIndex].content.length - scrollController.scrollToSearchMatch(lazyIndex, contentOffset, contentLength) - } - } + ChatSearchScrollEffect( + searchState = searchState, + messages = messages, + renderedFirstId = renderedMessages.firstOrNull()?.id, + turns = turns, + scrollController = scrollController, + ) val currentDensity = LocalDensity.current val chatFontScale = LocalChatFontScale.current @@ -321,22 +293,34 @@ fun FullBleedChatList( // Eager captures: item lambda reads these at // composition time (lazy), so capture now. val userMessage = turn.message - item(key = "user-${userMessage.id}") { + item( + key = "user-${userMessage.id}", + contentType = FullBleedContentType.USER, + ) { Column(modifier = Modifier.padding(bottom = 12.dp)) { - renderChatBubble( + renderUserBubble( message = userMessage, searchQuery = if (searchState.isActive) searchState.query else "", isCurrentMatch = searchState.currentMatchId != null && searchState.currentMatchId == userMessage.id, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, + onOpenAttachment = actions.onOpenAttachment, + onSaveAttachment = actions.onSaveAttachment, savingAttachmentPath = savingAttachmentPath, openingAttachmentPath = openingAttachmentPath, - onImageClick = onImageClick, + onImageClick = actions.onImageClick, messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, ) + if (userMessage.reactions.isNotEmpty()) { + MessageReactionChips( + reactions = userMessage.reactions, + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 12.dp), + ) + } } } } @@ -352,11 +336,30 @@ fun FullBleedChatList( .firstOrNull { it.message.reasoningText.isNotBlank() } if (turnReasoning != null) { val reasoning = turnReasoning.message - item(key = "reasoning-${reasoning.id}") { + item( + key = "reasoning-${reasoning.id}", + contentType = FullBleedContentType.REASONING, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { ReasoningCard( reasoningText = reasoning.reasoningText, isStreaming = reasoning.isStreaming, + searchQuery = + if (reasoning.id in + searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == reasoning.id && + searchState.matchTargets.getOrNull(searchState.currentIndex) == + SearchTarget.REASONING, + searchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, ) } } @@ -371,15 +374,37 @@ fun FullBleedChatList( if (!proseMessage.hasVisibleAgentContent() && !hoistedReasoning && proseMessage.reasoningText.isNotBlank() ) { - item(key = "reasoning-${proseMessage.id}") { + item( + key = "reasoning-${proseMessage.id}", + contentType = FullBleedContentType.REASONING, + ) { ReasoningCard( reasoningText = proseMessage.reasoningText, isStreaming = proseMessage.isStreaming, + searchQuery = + if (proseMessage.id in searchState.matchedIds) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.REASONING, + searchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, ) } } if (proseMessage.hasVisibleAgentContent()) { - item(key = "prose-${proseMessage.id}") { + item( + key = "prose-${proseMessage.id}", + contentType = FullBleedContentType.PROSE, + ) { Column(modifier = Modifier.padding(bottom = 12.dp)) { if (proseMessage.isStreaming && typingEffectEnabled) { StreamingFullBleedWithTypingEffect( @@ -392,11 +417,7 @@ fun FullBleedChatList( speakingMessageId != null && speakingMessageId == proseMessage.id val toggleSpeakAction = - if (onToggleSpeak != null) { - { onToggleSpeak(proseMessage) } - } else { - null - } + { actions.onToggleSpeak(proseMessage) } FullBleedAgentMessage( message = proseMessage, // Highlight only bubbles that actually contain a match — @@ -410,15 +431,36 @@ fun FullBleedChatList( "" }, isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == proseMessage.id, + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.CONTENT, + reasoningSearchQuery = + if (searchState.isActive && + proseMessage.id in searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentReasoningMatch = + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.REASONING, + reasoningSearchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, showReasoning = !hoistedReasoning, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, + onOpenAttachment = actions.onOpenAttachment, + onSaveAttachment = actions.onSaveAttachment, savingAttachmentPath = savingAttachmentPath, openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, - onImageClick = onImageClick, + onImageClick = actions.onImageClick, isSpeaking = isSpeakingThis, onToggleSpeak = toggleSpeakAction, messageStatsEnabled = messageStatsEnabled, @@ -434,9 +476,26 @@ fun FullBleedChatList( is AgentEntry.ToolRow -> { val toolMessage = entry.message val milestone = toolMilestones[toolMessage.id] - item(key = "tool-${toolMessage.id}") { + item( + key = "tool-${toolMessage.id}", + contentType = FullBleedContentType.TOOL, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { - FullBleedToolRow(toolMessage) + FullBleedToolRow( + message = toolMessage, + searchQuery = + if (toolMessage.id in searchState.matchedIds) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId == toolMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.TOOL, + ) milestone?.let { count -> ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) } @@ -446,7 +505,10 @@ fun FullBleedChatList( is AgentEntry.SystemEvent -> { val sysMessage = entry.message - item(key = "sys-${sysMessage.id}") { + item( + key = "sys-${sysMessage.id}", + contentType = FullBleedContentType.SYSTEM_EVENT, + ) { Column(modifier = Modifier.padding(bottom = 6.dp)) { if (sysMessage.displayKind != null) { // Timeline marker (issue #904): @@ -456,7 +518,7 @@ fun FullBleedChatList( } else { FullBleedSystemEvent( message = sysMessage, - onRespondApproval = viewModel::respondToApproval, + onRespondApproval = actions.onRespondApproval, ) } } @@ -469,7 +531,11 @@ fun FullBleedChatList( } tailItems.forEach { (key, content) -> - item(key = key) { content() } + item( + key = key, + // Tail keys are fixed per kind, so the key doubles as the slot type. + contentType = key, + ) { content() } } } // Loading history must never become the list's first visible anchor. @@ -484,7 +550,7 @@ fun FullBleedChatList( } @Composable -private fun renderChatBubble( +private fun renderUserBubble( message: ChatMessage, searchQuery: String, isCurrentMatch: Boolean, @@ -496,7 +562,7 @@ private fun renderChatBubble( messageStatsEnabled: Boolean, showUserMessageTokens: Boolean, ) { - ChatBubble( + UserBubble( message = message, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt index 5733834b..572ccb89 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt @@ -71,19 +71,31 @@ internal fun StreamingFullBleedWithTypingEffect( } // Derive display text from the latest full content at each recomposition - val words = streaming.content.split(" ") + val words = remember(streaming.content) { streaming.content.split(" ") } val visibleCount = if (visibleWordCount >= Int.MAX_VALUE / 2) { words.size } else { visibleWordCount.coerceIn(0, words.size) } + val isStreaming = currentIsStreaming.value val displayText = - words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") + - if (caretVisible && currentIsStreaming.value) "▍" else "" + remember(words, visibleCount, caretVisible, isStreaming) { + words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") + + if (caretVisible && isStreaming) "▍" else "" + } + + val displayMessage = + remember(streaming, displayText) { + if (streaming.content == displayText) { + streaming + } else { + streaming.copy(content = displayText) + } + } FullBleedAgentMessage( - message = streaming.copy(content = displayText), + message = displayMessage, showReasoning = showReasoning, searchQuery = "", isCurrentMatch = false, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt index 534daafc..3762dc57 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt @@ -24,6 +24,8 @@ import com.m57.hermescontrol.ui.chat.ToolBubble internal fun FullBleedToolRow( message: ChatMessage, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { Box( modifier = @@ -32,7 +34,7 @@ internal fun FullBleedToolRow( .padding(start = 16.dp, end = 16.dp) .testTag("fullbleed_tool_row"), ) { - ToolBubble(message) + ToolBubble(message, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index fafc2ac3..d8841774 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat.fullbleed import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.DisplayKind import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.SearchMatch +import com.m57.hermescontrol.ui.chat.SearchTarget /** * Turn model for the full-bleed chat renderer (issue #866). @@ -52,16 +55,12 @@ internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank( private const val MAX_ITERATIONS_SYSTEM_MARKER = "You've reached the maximum number of tool-calling iterations allowed." -private fun ChatMessage.isSyntheticSystemRow(): Boolean = +internal fun ChatMessage.isSyntheticSystemRow(): Boolean = role == MessageRole.USER && displayKind == null && content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) -internal fun ChatMessage.isTimelineMarker(): Boolean = - displayKind != null && - displayKind != "steer" && - displayKind != "clarify_response" && - displayKind != "local_feedback" +internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds /** * Split a flat message list into turns for the full-bleed renderer. @@ -97,7 +96,7 @@ fun groupIntoTurns(messages: List): List { message.isSyntheticSystemRow() -> { agentEntries += - AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached")) + AgentEntry.SystemEvent(message.copy(displayKind = DisplayKind.MAX_ITERATIONS_REACHED)) } message.role == MessageRole.USER -> { @@ -186,6 +185,28 @@ fun messageIdToLazyIndex( } }.toMap() +fun searchMatchToLazyIndex( + turns: List, + messages: List, + match: SearchMatch, +): Int? { + val message = messages.getOrNull(match.messageIndex) ?: return null + val prefix = + when (match.target) { + SearchTarget.CONTENT -> if (message.role == MessageRole.USER) "user" else "prose" + SearchTarget.REASONING -> "reasoning" + SearchTarget.TOOL -> "tool" + } + val keys = fullBleedItemKeys(turns) + keys.indexOf("$prefix-${message.id}").takeIf { it >= 0 }?.let { return it } + // Non-hoisted reasoning renders inside its message's prose row, not as its own row. + return if (match.target == SearchTarget.REASONING) { + keys.indexOf("prose-${message.id}").takeIf { it >= 0 } + } else { + null + } +} + /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */ internal fun fullBleedItemKeys(turns: List): List = buildList { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt index 88c2d033..f2168a95 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.DisplayKind /** * Friendly label resource for a `display_kind` timeline marker (issue #904). @@ -24,13 +25,13 @@ import com.m57.hermescontrol.ui.chat.ChatMessage */ internal fun timelineMarkerLabelRes(kind: String?): Int? = when (kind) { - "model_switch" -> R.string.timeline_marker_model_switch - "personality_switch" -> R.string.timeline_marker_personality_switch - "auto_continue" -> R.string.timeline_marker_auto_continue - "async_delegation_complete" -> R.string.timeline_marker_delegation_complete - "skill_invocation" -> R.string.timeline_marker_skill_invocation - "internal_notification" -> R.string.timeline_marker_internal_notification - "max_iterations_reached" -> R.string.timeline_marker_max_iterations + DisplayKind.MODEL_SWITCH -> R.string.timeline_marker_model_switch + DisplayKind.PERSONALITY_SWITCH -> R.string.timeline_marker_personality_switch + DisplayKind.AUTO_CONTINUE -> R.string.timeline_marker_auto_continue + DisplayKind.ASYNC_DELEGATION_COMPLETE -> R.string.timeline_marker_delegation_complete + DisplayKind.SKILL_INVOCATION -> R.string.timeline_marker_skill_invocation + DisplayKind.INTERNAL_NOTIFICATION -> R.string.timeline_marker_internal_notification + DisplayKind.MAX_ITERATIONS_REACHED -> R.string.timeline_marker_max_iterations else -> null } @@ -63,7 +64,7 @@ internal fun TimelineMarkerChip( ) { val labelRes = timelineMarkerLabelRes(message.displayKind) ?: return val text = - if (message.displayKind == "model_switch") { + if (message.displayKind == DisplayKind.MODEL_SWITCH) { val model = markerModelFromContent(message.content) if (model != null) { stringResource(R.string.timeline_marker_model_switch_to, model) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt new file mode 100644 index 00000000..f02686fa --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiState.kt @@ -0,0 +1,99 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState +import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.ImageViewerModel +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi + +/** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */ +data class TranscriptUiState( + val messages: List, + val streamingState: StreamingState, + val isAgentTyping: Boolean, + val typingEffectEnabled: Boolean, + val typingEffectDelayMs: Int, + val messageStatsEnabled: Boolean, + val showUserMessageTokens: Boolean, + val showAssistantMessageTokens: Boolean, + val showTokensPerSecond: Boolean, + val maxToolCallsPerTurn: Int?, + val isLoading: Boolean, + val isLoadingOlder: Boolean, + val hasOlderMessages: Boolean, + val pagingSessionId: String?, + val clarifyRequest: ClarifyUi?, + val vaultUnlockPrompt: VaultUnlockPromptUi?, + val vaultSaveLoginPrompt: VaultSaveLoginPromptUi?, + val vaultCodePrompt: VaultCodePromptUi?, + val savingAttachmentPath: String?, + val openingAttachmentPath: String?, + val isCompressing: Boolean, + val compressionStatus: String?, + val speakingMessageId: String?, +) { + companion object { + /** Resolve historical versus live mode at the state boundary, not in the screen call. */ + fun resolve( + chat: ChatUiState, + timeline: ChatTimelineState, + streaming: StreamingState, + savingAttachmentPath: String?, + speakingMessageId: String?, + ): TranscriptUiState { + val historical = timeline.isHistorical + return TranscriptUiState( + messages = timeline.historyMessages ?: chat.messages, + streamingState = if (historical) StreamingState() else streaming, + isAgentTyping = !historical && chat.isAgentTyping, + typingEffectEnabled = !historical && chat.typingEffectEnabled, + typingEffectDelayMs = chat.typingEffectDelayMs, + messageStatsEnabled = chat.messageStatsEnabled, + showUserMessageTokens = chat.showUserMessageTokens, + showAssistantMessageTokens = chat.showAssistantMessageTokens, + showTokensPerSecond = chat.showTokensPerSecond, + maxToolCallsPerTurn = chat.maxToolCallsPerTurn, + isLoading = !historical && chat.isLoading, + isLoadingOlder = !historical && chat.isLoadingOlder, + hasOlderMessages = !historical && chat.hasOlderMessages, + pagingSessionId = + chat.currentSessionId?.let { id -> + if (historical) "$id:history:${timeline.historyAnchorRowId}" else id + }, + clarifyRequest = chat.clarifyRequest.takeUnless { historical }, + vaultUnlockPrompt = chat.vaultUnlockPrompt.takeUnless { historical }, + vaultSaveLoginPrompt = chat.vaultSaveLoginPrompt.takeUnless { historical }, + vaultCodePrompt = chat.vaultCodePrompt.takeUnless { historical }, + savingAttachmentPath = savingAttachmentPath ?: chat.savingAttachmentPath, + openingAttachmentPath = chat.openingAttachmentPath, + isCompressing = !historical && chat.isCompressing, + compressionStatus = chat.compressionStatus.takeUnless { historical }, + speakingMessageId = speakingMessageId, + ) + } + } +} + +/** All effects the transcript can request. The UI never reaches through to [ChatUiState]'s owner. */ +class TranscriptActions( + val onLoadOlder: () -> Unit, + val onOpenAttachment: (Attachment) -> Unit, + val onSaveAttachment: (Attachment) -> Unit, + val onImageClick: (ImageViewerModel) -> Unit, + val onRespondApproval: (String) -> Unit, + val onRespondClarify: (String) -> Unit, + val onRespondClarifyBatch: (Map) -> Unit, + val onDismissClarify: () -> Unit, + val onRespondVaultUnlock: (String) -> Unit, + val onDismissVaultUnlock: () -> Unit, + val onRespondVaultSaveLogin: (String, String) -> Unit, + val onDismissVaultSaveLogin: () -> Unit, + val onRespondVaultCode: (String) -> Unit, + val onDismissVaultCode: () -> Unit, + val onToggleSpeak: (ChatMessage) -> Unit, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt new file mode 100644 index 00000000..3fbe8b6f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt @@ -0,0 +1,121 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +/** + * Render-time optimization, not parsed Markdown: a long run of plain bullets drawn as one text node. + * Produced only by [coalesceBulletRuns]; kept out of the parser's AST file on purpose. + */ +data class BulletRun( + val items: List, +) : MdBlock + +/** Runs shorter than this keep the per-row renderer; the win only matters for very long lists. */ +internal const val MIN_BULLET_RUN = 16 + +/** + * A bullet is eligible for coalescing when it is a plain, LTR, single-line item: no nested content + * and no inline math (math needs per-item inline content that a merged paragraph run can't host). + */ +private fun MdBlock.Bullet.isPlain(): Boolean = + nestedSource.isEmpty() && + !BidiUtils.isRtlText(text) && + splitInlineMath(text).none { it is InlineMathSegment.Math } + +/** + * Replaces each run of at least [minRun] consecutive plain bullets with a single [MdBlock.BulletRun]. + * Everything else is passed through untouched, so complex lists keep the existing renderer. + */ +internal fun coalesceBulletRuns( + blocks: List, + minRun: Int = MIN_BULLET_RUN, +): List { + if (blocks.size < minRun) return blocks + val out = ArrayList(blocks.size) + var i = 0 + while (i < blocks.size) { + val block = blocks[i] + if (block is MdBlock.Bullet && block.isPlain()) { + var end = i + 1 + while (end < blocks.size) { + val next = blocks[end] + if (next is MdBlock.Bullet && next.isPlain()) end++ else break + } + if (end - i >= minRun) { + out.add(BulletRun(blocks.subList(i, end).map { it as MdBlock.Bullet })) + } else { + out.addAll(blocks.subList(i, end)) + } + i = end + } else { + out.add(block) + i++ + } + } + return out +} + +private const val BULLET_LEVEL_INDENT_DP = 16 + +/** The two non-breaking spaces that separate the glyph from the body. */ +internal const val BULLET_PREFIX_PAD = "\u00A0\u00A0" + +internal fun bulletGlyph(level: Int): String = + when (level % 3) { + 0 -> "\u2022" + 1 -> "\u25E6" + else -> "\u25AA" + } + +/** + * Builds the merged text for a [BulletRun]. + * + * The list-level offset is dp-based (like the per-row renderer's `padding(start = (level * 16).dp)`). + * The hanging indent for wrapped lines also includes the rendered bullet prefix, whose width scales with + * the font; [prefixWidth] measures a prefix in the body text style so wrapped lines line up with the body + * at any font scale. + */ +internal fun buildBulletRunText( + run: BulletRun, + density: Density, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + prefixWidth: (String) -> TextUnit, +): AnnotatedString = + buildAnnotatedString { + run.items.forEachIndexed { index, item -> + val start = with(density) { (item.level * BULLET_LEVEL_INDENT_DP).dp.toSp() } + val prefix = bulletGlyph(item.level) + BULLET_PREFIX_PAD + val rest = (start.value + prefixWidth(prefix).value).sp + withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = start, restLine = rest))) { + append(prefix) + append( + MarkdownInlineStyler.parseInlineSource( + text = item.text, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = false, + ), + ) + // ParagraphStyle breaks lines visually but adds no character; the newline keeps copy/select faithful. + if (index < run.items.lastIndex) append('\n') + } + } + } 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 79c83b0c..eb0e6fe5 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 @@ -68,6 +68,9 @@ object MarkdownInlineStyler { val src = text while (i < src.length) { + // Cheap prefix gate: URL_PATTERN only matches at "http(s)://", so skip the regex elsewhere. + val urlMatch = + if (src.startsWith("http", i, ignoreCase = true)) URL_PATTERN.matchAt(src, i) else null when { // Inline code is opaque to emphasis parsing, and delimiters must match by run length. src[i] == '`' -> { @@ -99,7 +102,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.08f), ), ) { - append(content) + appendSearchable(content, searchQuery, searchHighlightColor) } i = matchingEnd + runLength } else { @@ -115,7 +118,7 @@ object MarkdownInlineStyler { 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) + appendSearchable(content, searchQuery, searchHighlightColor) } i = end + 3 } else { @@ -158,7 +161,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -198,7 +201,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -212,7 +215,7 @@ object MarkdownInlineStyler { val end = src.indexOf('^', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -226,7 +229,7 @@ object MarkdownInlineStyler { val end = src.indexOf('~', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -247,7 +250,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.12f), ), ) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 6 } else { @@ -312,9 +315,8 @@ object MarkdownInlineStyler { } // bare URL - URL_PATTERN.matchAt(src, i) != null -> { - val match = URL_PATTERN.matchAt(src, i)!! - val url = match.value + urlMatch != null -> { + val url = urlMatch.value val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url pushLink(LinkAnnotation.Url(url)) withStyle( @@ -326,7 +328,7 @@ object MarkdownInlineStyler { append(urlToAppend) } pop() - i = match.range.last + 1 + i = urlMatch.range.last + 1 } // Plain text / words in RTL @@ -380,4 +382,27 @@ object MarkdownInlineStyler { } private fun isRtr(isRtl: Boolean): Boolean = isRtl + + /** Append [text], highlighting every [query] hit so styled spans (inline code, sub/sup, ...) stay searchable. */ + private fun AnnotatedString.Builder.appendSearchable( + text: String, + query: String, + highlight: Pair, + ) { + if (query.isEmpty()) { + append(text) + return + } + var from = 0 + while (from < text.length) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + append(text.substring(from, hit)) + withStyle(SpanStyle(background = highlight.first, color = highlight.second)) { + append(text.substring(hit, hit + query.length)) + } + from = hit + query.length + } + append(text.substring(from)) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt index 167ca1e1..6a96c1a5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -30,8 +30,9 @@ fun MarkdownInlineText( linkColor: Color, highlights: SearchHighlightColors, modifier: Modifier = Modifier, + isRtlOverride: Boolean? = null, ) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val isRtl = isRtlOverride ?: remember(text) { BidiUtils.isRtlText(text) } val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val resolvedStyle = style.copy( @@ -66,38 +67,56 @@ fun MarkdownInlineText( } val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + remember(style.fontSize, textColor) { + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) } - } - val annotated = - buildAnnotatedString { + val inlineContent = + remember(segments, latexMeasurer, config) { + val map = mutableMapOf() segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { map["latex-$index"] = it } + } + } + map + } + val annotated = + remember( + segments, + inlineContent, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) { + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + val measured = id in inlineContent + if (measured) appendInlineContent(id, segment.latex) else append(segment.latex) + } - is InlineMathSegment.Text -> { - append( - MarkdownInlineStyler.parseInlineSource( - text = segment.value, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ), - ) + is InlineMathSegment.Text -> { + append( + MarkdownInlineStyler.parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt index 87bfed26..e357a206 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt @@ -18,18 +18,21 @@ sealed interface MdBlock { data class Bullet( val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Task( val checked: Boolean, val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Ordered( val index: Int, val text: String, val level: Int = 0, + val nestedSource: String = "", ) : MdBlock data class Image( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt index d7879285..5044775d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt @@ -164,7 +164,7 @@ fun parseBlocks(src: String): List { line.startsWith(">") -> { val quote = mutableListOf() while (i < lines.size && lines[i].startsWith(">")) { - quote.add(lines[i].removePrefix(">").trim()) + quote.add(lines[i].removePrefix(">").removePrefix(" ")) i++ } blocks.add(MdBlock.Quote(quote.joinToString("\n"))) @@ -287,6 +287,11 @@ private fun parseList( } if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { j = lookahead + } else if (items.isNotEmpty() && lookahead < lines.size && + computeIndent(lines[lookahead].takeWhile { it == ' ' || it == '\t' }) >= 2 + ) { + items.last().continuationLines.add("") + j = lookahead } else { break } @@ -334,32 +339,34 @@ private fun parseList( level = indentStack.size - 1 } - val fullText = - if (item.continuationLines.isEmpty()) { - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } + val base = + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + val nestedSource = + if (item.continuationLines.any(::startsNestedBlock)) { + item.continuationLines.joinToString("\n") } else { - val base = - when (item) { - is ParsedItem.Bullet -> item.text - is ParsedItem.Task -> item.text - is ParsedItem.Ordered -> item.text - } - base + " " + item.continuationLines.joinToString(" ") + "" + } + val fullText = + if (nestedSource.isNotEmpty()) { + base + } else { + (listOf(base) + item.continuationLines).joinToString(" ").trim() } when (item) { is ParsedItem.Bullet -> { - blocks.add(MdBlock.Bullet(fullText, level = level)) + blocks.add(MdBlock.Bullet(fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = false lastLevel = level } is ParsedItem.Task -> { - blocks.add(MdBlock.Task(item.checked, fullText, level = level)) + blocks.add(MdBlock.Task(item.checked, fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = false lastLevel = level } @@ -377,7 +384,7 @@ private fun parseList( currentSequenceNumber = item.rawNumber currentSequenceNumber } - blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) + blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level, nestedSource = nestedSource)) lastItemWasOrdered = true lastLevel = level } @@ -387,6 +394,15 @@ private fun parseList( return blocks to j } +/** Preserve block syntax in indented list continuations instead of flattening it into prose. */ +private fun startsNestedBlock(line: String): Boolean = + parseCodeFenceStart(line) != null || + isValidHeading(line) || + line.startsWith(">") || + isHorizontalRule(line) || + line.startsWith("$$") || + line.startsWith("\\[") + private fun isHorizontalRule(line: String): Boolean { val trimmed = line.trim() if (trimmed.length < 3) return false 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 2d2cc26c..5951d53f 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 @@ -16,8 +16,11 @@ 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.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -25,8 +28,6 @@ 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 - @Composable fun MarkdownTable( block: MdBlock.Table, @@ -46,6 +47,25 @@ fun MarkdownTable( val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val headerBg = textColor.copy(alpha = 0.08f) val alignments = block.alignments + val cellStyle = MaterialTheme.typography.bodySmall + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val columnWidths = + remember(block, cellStyle, density, textMeasurer) { + val padding = with(density) { 12.dp.roundToPx() } + val minimum = with(density) { 48.dp.roundToPx() } + val maximum = with(density) { 280.dp.roundToPx() } + val columnCount = maxOf(block.header.size, block.rows.maxOfOrNull { it.size } ?: 0) + List(columnCount) { column -> + val cells = + listOfNotNull(block.header.getOrNull(column)) + block.rows.mapNotNull { it.getOrNull(column) } + val contentWidth = + cells.maxOfOrNull { cell -> + textMeasurer.measure(text = cell.take(128), style = cellStyle, maxLines = 1).size.width + } ?: 0 + with(density) { (contentWidth + padding).coerceIn(minimum, maximum).toDp() } + } + } CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { Column( modifier = @@ -72,14 +92,15 @@ fun MarkdownTable( highlights = highlights, modifier = Modifier - .width(TABLE_COL_WIDTH) + .width(columnWidths[idx]) + .testTag("markdown_table_header_$idx") .padding(6.dp), ) } } HorizontalDivider(color = textColor.copy(alpha = 0.25f)) // Body rows - block.rows.forEach { row -> + block.rows.forEachIndexed { rowIndex, row -> Row(verticalAlignment = Alignment.Top) { row.forEachIndexed { idx, cell -> MarkdownInlineText( @@ -96,7 +117,8 @@ fun MarkdownTable( highlights = highlights, modifier = Modifier - .width(TABLE_COL_WIDTH) + .width(columnWidths[idx]) + .testTag("markdown_table_cell_${rowIndex}_$idx") .padding(6.dp), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt new file mode 100644 index 00000000..2e6e25f0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalog.kt @@ -0,0 +1,144 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.ui.graphics.vector.ImageVector +import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer +import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer + +/** A tool's visual identity (header/status icon) and its [ToolRenderer]. */ +internal data class ToolDefinition( + val icon: ImageVector, + val renderer: ToolRenderer, +) + +/** All-default renderer: defers everything to the generic fallbacks. */ +internal object GenericToolRenderer : ToolRenderer + +/** + * The single tool catalog (issue #1326): every known tool declares its icon + * and renderer together, so the two can no longer drift apart. + * + * Unknown tools get the Build icon and [GenericToolRenderer]: every hook + * returns null, so [ToolViewBuilder] falls back to the humanized tool name + * and the [ToolResultSummary] heuristics — never a raw JSON dump. + */ +internal object ToolCatalog { + val FALLBACK = ToolDefinition(Icons.Filled.Build, GenericToolRenderer) + + val entries: Map = + mapOf( + "browser_back" to ToolDefinition(Icons.Filled.Language, BrowserActionRenderer), + "browser_cdp" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "browser_click" to ToolDefinition(Icons.Filled.TouchApp, BrowserClickRenderer), + "browser_console" to ToolDefinition(Icons.Filled.Terminal, BrowserConsoleRenderer), + "browser_dialog" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "browser_fill" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_get_images" to ToolDefinition(Icons.Filled.Image, BrowserImagesRenderer), + "browser_navigate" to ToolDefinition(Icons.Filled.Language, BrowserNavigateRenderer), + "browser_press" to ToolDefinition(Icons.Filled.Keyboard, BrowserActionRenderer), + "browser_scroll" to ToolDefinition(Icons.Filled.TouchApp, BrowserActionRenderer), + "browser_snapshot" to ToolDefinition(Icons.Filled.Photo, BrowserSnapshotRenderer), + "browser_type" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer), + "browser_vision" to ToolDefinition(Icons.Filled.Visibility, BrowserVisionRenderer), + "clarify" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer), + "computer_use" to ToolDefinition(Icons.Filled.Computer, ComputerUseRenderer), + "cronjob" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "cronjob_manage" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer), + "delegate_task" to ToolDefinition(Icons.Filled.AccountTree, DelegateTaskRenderer), + "edit_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "execute_code" to ToolDefinition(Icons.Filled.PlayArrow, TerminalRenderer), + "fact_feedback" to ToolDefinition(Icons.Filled.ThumbUp, GenericToolRenderer), + "fact_store" to ToolDefinition(Icons.Filled.Psychology, FactStoreRenderer), + "image_generate" to ToolDefinition(Icons.Filled.Image, ImageGenerateRenderer), + "memory" to ToolDefinition(Icons.Filled.Memory, MemoryRenderer), + "patch" to ToolDefinition(Icons.Filled.Build, FileEditRenderer), + "process" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "process_manage" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer), + "project_create" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "project_list" to ToolDefinition(Icons.Filled.AccountTree, ProjectListRenderer), + "project_switch" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer), + "read_file" to ToolDefinition(Icons.Filled.Description, ReadFileRenderer), + "read_terminal" to ToolDefinition(Icons.Filled.Computer, ReadTerminalRenderer), + "search_files" to ToolDefinition(Icons.Filled.Search, SearchFilesRenderer), + "send_message" to ToolDefinition(Icons.AutoMirrored.Filled.Send, GenericToolRenderer), + "session_search" to ToolDefinition(Icons.Filled.Search, SessionSearchRenderer), + "skill_manage" to ToolDefinition(Icons.Filled.Build, SkillManageRenderer), + "skill_view" to ToolDefinition(Icons.Filled.AutoStories, SkillViewRenderer), + "skills_list" to ToolDefinition(Icons.AutoMirrored.Filled.MenuBook, SkillsListRenderer), + "terminal" to ToolDefinition(Icons.Filled.Terminal, TerminalRenderer), + "text_to_speech" to ToolDefinition(Icons.AutoMirrored.Filled.VolumeUp, TextToSpeechRenderer), + "todo" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "todo_list" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer), + "tool_call" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer), + "tool_describe" to ToolDefinition(Icons.Filled.AutoStories, ToolDescribeRenderer), + "tool_search" to ToolDefinition(Icons.Filled.Search, ToolSearchRenderer), + "video_generate" to ToolDefinition(Icons.Filled.Movie, GenericToolRenderer), + "vision_analyze" to ToolDefinition(Icons.Filled.Visibility, VisionAnalyzeRenderer), + "web_extract" to ToolDefinition(Icons.Filled.Language, WebExtractRenderer), + "web_search" to ToolDefinition(Icons.Filled.Public, WebSearchRenderer), + "write_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer), + "x_search" to ToolDefinition(Icons.Filled.Forum, XSearchRenderer), + ) + + fun definitionFor(toolName: String?): ToolDefinition = toolName?.let { entries[it] } ?: FALLBACK +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt index fc0fd5be..dbd48a6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt @@ -84,6 +84,23 @@ internal object ToolCounts { "sources" to "source", ) + private val S_ENDING_NOUN_REGEX = Regex("(xes|zes|ches|shes|sses)$") + private val CONSONANT_Y_NOUN_REGEX = Regex("[aeiou]y$", RegexOption.IGNORE_CASE) + private val SIBILANT_NOUN_REGEX = Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE) + private val COUNT_OR_TOTAL_KEY_REGEX = Regex("_count$|_total$") + private val COUNT_OR_TOTAL_STRIP_REGEX = Regex("_(count|total)$") + private val NUM_PREFIX_STRIP_REGEX = Regex("^num_") + private val UNIT_COUNT_REGEX = + Regex( + """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", + RegexOption.IGNORE_CASE, + ) + private val VERB_COUNT_REGEX = + Regex( + """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", + RegexOption.IGNORE_CASE, + ) + private fun countFromUnknown(value: JsonElement?): Int? { if (value is JsonArray) { return if (value.isNotEmpty()) value.size else null @@ -109,7 +126,7 @@ internal object ToolCounts { return "${normalized.dropLast(3)}y" } - if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { + if (S_ENDING_NOUN_REGEX.containsMatchIn(normalized) && normalized.length > 3) { return normalized.dropLast(2) } @@ -134,12 +151,12 @@ internal object ToolCounts { if (noun.endsWith( "y", - ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) + ) && noun.length > 1 && !CONSONANT_Y_NOUN_REGEX.containsMatchIn(noun) ) { return "${noun.dropLast(1)}ies" } - if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { + if (SIBILANT_NOUN_REGEX.containsMatchIn(noun)) { return "${noun}es" } @@ -171,12 +188,12 @@ internal object ToolCounts { if (key in COUNT_EXCLUDED_KEYS) { continue } - if (!Regex("_count$|_total$").containsMatchIn(key)) { + if (!COUNT_OR_TOTAL_KEY_REGEX.containsMatchIn(key)) { continue } val count = countFromUnknown(value) ?: continue - val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") + val stripped = key.lowercase().replace(COUNT_OR_TOTAL_STRIP_REGEX, "").replace(NUM_PREFIX_STRIP_REGEX, "") return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) } @@ -194,14 +211,8 @@ internal object ToolCounts { } val unitMatch = - Regex( - """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - ?: Regex( - """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", - RegexOption.IGNORE_CASE, - ).find(t) + UNIT_COUNT_REGEX.find(t) + ?: VERB_COUNT_REGEX.find(t) val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index 1d06f7f1..9e443595 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -1,109 +1,6 @@ package com.m57.hermescontrol.ui.chat.tool -import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.BrowserVisionRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer -import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer -import com.m57.hermescontrol.ui.chat.tool.render.DelegateTaskRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer -import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SearchFilesRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer -import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TextToSpeechRenderer -import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ToolDescribeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer -import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer -import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer -import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer - -/** - * Maps tool names to their display strategy. - * - * Unknown tools get [GenericToolRenderer]: every hook returns null, so - * [ToolViewBuilder] falls back to the humanized tool name and the - * [ToolResultSummary] heuristics — never a raw JSON dump. - */ +/** Renderer lookup backed by the single [ToolCatalog]. */ internal object ToolRendererRegistry { - /** All-default renderer: defers everything to the generic fallbacks. */ - private object GenericToolRenderer : ToolRenderer - - private val renderers: Map = - mapOf( - // Shell - "terminal" to TerminalRenderer, - "execute_code" to TerminalRenderer, - "read_terminal" to ReadTerminalRenderer, - // Delegation - "delegate_task" to DelegateTaskRenderer, - // Files - "read_file" to ReadFileRenderer, - "edit_file" to FileEditRenderer, - "patch" to FileEditRenderer, - "write_file" to FileEditRenderer, - "search_files" to SearchFilesRenderer, - // Web - "web_search" to WebSearchRenderer, - "web_extract" to WebExtractRenderer, - "x_search" to XSearchRenderer, - // Browser - "browser_navigate" to BrowserNavigateRenderer, - "browser_snapshot" to BrowserSnapshotRenderer, - "browser_click" to BrowserClickRenderer, - "browser_fill" to BrowserTypeRenderer, - "browser_type" to BrowserTypeRenderer, - "browser_scroll" to BrowserActionRenderer, - "browser_back" to BrowserActionRenderer, - "browser_press" to BrowserActionRenderer, - "browser_get_images" to BrowserImagesRenderer, - "browser_vision" to BrowserVisionRenderer, - "browser_console" to BrowserConsoleRenderer, - // Memory / facts - "memory" to MemoryRenderer, - "fact_store" to FactStoreRenderer, - // Task management - "cronjob" to CronjobRenderer, - "cronjob_manage" to CronjobRenderer, - "todo" to TodoRenderer, - "todo_list" to TodoRenderer, - "session_search" to SessionSearchRenderer, - "process" to ProcessRenderer, - "process_manage" to ProcessRenderer, - // Skills / tool discovery - "skills_list" to SkillsListRenderer, - "skill_view" to SkillViewRenderer, - "skill_manage" to SkillManageRenderer, - "tool_search" to ToolSearchRenderer, - "tool_describe" to ToolDescribeRenderer, - // Media - "image_generate" to ImageGenerateRenderer, - "vision_analyze" to VisionAnalyzeRenderer, - "text_to_speech" to TextToSpeechRenderer, - // Projects - "project_list" to ProjectListRenderer, - "project_create" to ProjectMutateRenderer, - "project_switch" to ProjectMutateRenderer, - // Desktop automation - "computer_use" to ComputerUseRenderer, - ) - - fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer + fun rendererFor(toolName: String): ToolRenderer = ToolCatalog.definitionFor(toolName).renderer } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt index 24f5ee9f..2535397a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt @@ -49,6 +49,14 @@ object ToolResultSummary { private val ERROR_MSG_KEYS = listOf("message", "reason", "detail") private val NON_ERROR_TEXT = setOf("", "0", "false", "none", "null", "nil", "ok", "success", "n/a", "na") + private val KEY_SPLIT_REGEX = Regex("[_\\-.]+") + private val WHITESPACE_REGEX = Regex("\\s+") + private val ERROR_SIGNAL_STATUS_REGEX = + Regex( + "\\b(error|failed|failure|fatal|exception)\\b", + RegexOption.IGNORE_CASE, + ) + // ── helpers ────────────────────────────────────────────────────────── private fun normalize(value: String): String = value.lowercase().trim() @@ -58,7 +66,7 @@ object ToolResultSummary { private fun titleCase(key: String): String = key - .split(Regex("[_\\-.]+")) + .split(KEY_SPLIT_REGEX) .filter { it.isNotEmpty() } .joinToString(" ") { capitalize(it) } @@ -72,7 +80,7 @@ object ToolResultSummary { value: String, max: Int = 180, ): String { - val c = value.replace(Regex("\\s+"), " ").trim() + val c = value.replace(WHITESPACE_REGEX, " ").trim() return if (c.length > max) "${c.take(max - 1)}…" else c } @@ -456,10 +464,7 @@ object ToolResultSummary { return ( (record["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || (record["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || - Regex( - "\\b(error|failed|failure|fatal|exception)\\b", - RegexOption.IGNORE_CASE, - ).containsMatchIn(status) || + ERROR_SIGNAL_STATUS_REGEX.containsMatchIn(status) || ERROR_KEYS.any { k -> hasMeaningfulErrorValue(record[k]) } ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt new file mode 100644 index 00000000..4b000a9d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCache.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput + +/** + * Bounded memo of parsed tool payloads (issue #1327). + * + * The ViewModel [prewarm]s tool rows on a background dispatcher whenever the + * transcript changes, so [ToolBubble] normally gets a cache hit during + * composition instead of parsing JSON on the main thread. A miss (e.g. a live + * event that lands before prewarm finishes) parses synchronously exactly like + * before, so the rendered result — and its height — never changes. + * + * Keyed by the payload itself (not its hash), so a hit is always the view the + * same inputs would produce. + */ +internal object ToolViewCache { + private const val MAX_ENTRIES = 512 + + private data class Key( + val content: String, + val toolName: String?, + val isRunning: Boolean, + ) + + /** Boxes the result so a null parse (non-JSON content) is cached too. */ + private class Entry( + val view: ToolView?, + ) + + private val entries = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX_ENTRIES + } + + fun getOrParse( + content: String, + toolName: String?, + isRunning: Boolean, + ): ToolView? { + val key = Key(content, toolName, isRunning) + synchronized(entries) { entries[key] }?.let { return it.view } + val view = parseToolOutput(content, toolName, isRunning) + synchronized(entries) { entries[key] = Entry(view) } + return view + } + + fun prewarm( + content: String, + toolName: String?, + isRunning: Boolean, + ) { + getOrParse(content, toolName, isRunning) + } + + internal fun size(): Int = synchronized(entries) { entries.size } + + internal fun clear() { + synchronized(entries) { entries.clear() } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt index 5648a40a..0d18f6c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing @@ -81,28 +82,8 @@ fun StatusBadge( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current val (bgColor, fgColor) = - when (status) { - StatusBadgeType.SUCCESS -> { - statusColors.successContainer to statusColors.success - } - - StatusBadgeType.WARNING -> { - statusColors.warningContainer to statusColors.warning - } - - StatusBadgeType.ERROR -> { - statusColors.errorContainer to statusColors.error - } - - StatusBadgeType.INFO -> { - statusColors.infoContainer to statusColors.info - } - - StatusBadgeType.NEUTRAL -> { - MaterialTheme.colorScheme.surfaceContainerHigh to - MaterialTheme.colorScheme.onSurfaceVariant - } - } + statusBadgeColors(status, statusColors) + ?: (MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant) Surface( modifier = modifier, shape = RoundedCornerShape(8.dp), @@ -135,6 +116,20 @@ fun StatusBadge( enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL } +/** Exact fill/text pair shared by status badges and live-session indicators. */ +internal fun statusBadgeColors( + status: StatusBadgeType, + colors: HermesStatusColors, +): Pair? = + // PR #1417: on-status tokens belong on the status fill, not its container. + when (status) { + StatusBadgeType.SUCCESS -> colors.success to colors.onSuccess + StatusBadgeType.WARNING -> colors.warning to colors.onWarning + StatusBadgeType.ERROR -> colors.error to colors.onError + StatusBadgeType.INFO -> colors.info to colors.onInfo + StatusBadgeType.NEUTRAL -> null // Neutral badges use the active Material scheme. + } + // ── ToggleRow — switch row with label + description ──────────────────── @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index 4f9bd413..027bce0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.common +import android.content.ClipData import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -22,6 +23,7 @@ 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.filled.ContentCopy import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -30,8 +32,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,6 +43,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -48,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalSpacing +import kotlinx.coroutines.launch // Unified loading / error / empty / skeleton placeholders so every screen // shares the same visual language. @@ -96,6 +103,8 @@ fun ErrorState( modifier: Modifier = Modifier, ) { val spacing = LocalSpacing.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() Column( modifier = modifier @@ -117,6 +126,20 @@ fun ErrorState( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) + TextButton( + onClick = { + scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message))) } + }, + modifier = Modifier.testTag("error_copy_button"), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.action_copy_error)) + } if (onRetry != null) { Spacer(modifier = Modifier.height(spacing.md)) Button( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt index aacfe702..aab702ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.ui.chat.ChatConnectionOperationDelegate +import com.m57.hermescontrol.ui.chat.ConnectionOperationRequest import com.m57.hermescontrol.ui.chat.ConnectionOperationRequester import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -51,7 +52,13 @@ class AccountConnectorsViewModel( private val scopedJobs = mutableListOf() private val operation = ChatConnectionOperationDelegate( - requester = ConnectionOperationRequester(repository::operationRequest), + requester = + ConnectionOperationRequester { action -> + when (action) { + is ConnectionOperationRequest.Respond -> repository.operationRespond(action.params) + is ConnectionOperationRequest.Wake -> repository.operationWake(action.params) + } + }, accountOwned = true, ) val connectionState = operation.state diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index cc79bb43..ede67f7c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -1,98 +1,59 @@ package com.m57.hermescontrol.ui.mcp -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Science -import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Tab +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.data.local.AuthManager -import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.theme.LocalSpacing -import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState -import com.m57.hermescontrol.ui.common.StatusBadge -import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.mcp.components.AddServerSection -import com.m57.hermescontrol.ui.mcp.components.CatalogSection +import com.m57.hermescontrol.ui.mcp.components.CatalogView import com.m57.hermescontrol.ui.mcp.components.McpDialogs import com.m57.hermescontrol.ui.mcp.components.ServerCard +@OptIn(ExperimentalMaterial3Api::class) @Composable fun McpServersScreen( modifier: Modifier = Modifier, @@ -138,120 +99,158 @@ fun McpServersScreen( HermesScaffold( title = { Text(stringResource(R.string.screen_mcp_servers)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadServers(forceRefresh = true) }, - actions = { - IconButton( - onClick = { viewModel.testAllServers() }, - enabled = !state.isTestingAll && state.servers.any { it.enabled }, - ) { - if (state.isTestingAll) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Filled.Science, - contentDescription = stringResource(R.string.mcp_servers_action_test_all), - ) - } - } - IconButton( - onClick = { viewModel.toggleImportDialog() }, - ) { - Icon( - imageVector = Icons.Filled.UploadFile, - contentDescription = stringResource(R.string.mcp_servers_action_import_json), - ) + isRefreshing = if (state.selectedTab == McpTab.INSTALLED) state.isLoading else state.catalogLoading, + onRefresh = { + if (state.selectedTab == McpTab.INSTALLED) { + viewModel.loadServers(forceRefresh = true) + } else { + viewModel.loadCatalog() } }, - ) { paddingValues -> - when { - state.isLoading && state.servers.isEmpty() -> { - SkeletonListState(modifier = Modifier.padding(paddingValues)) - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadServers() }, - modifier = Modifier.padding(paddingValues), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, + actions = { + if (state.selectedTab == McpTab.INSTALLED) { + IconButton(onClick = { viewModel.toggleAddForm() }) { + Icon( + imageVector = if (state.showAddForm) Icons.Filled.Close else Icons.Filled.Add, + contentDescription = stringResource(R.string.mcp_servers_add_server), + ) + } + IconButton(onClick = { viewModel.toggleImportDialog() }) { + Icon( + imageVector = Icons.Filled.UploadFile, + contentDescription = stringResource(R.string.mcp_servers_action_import_json), + ) + } + IconButton( + onClick = { viewModel.testAllServers() }, + enabled = !state.isTestingAll && state.servers.any { it.enabled }, ) { - // ── 1. Add server ──────────────────────────────────── - item(key = "add-header") { - AddServerSection(state, viewModel, spacing) - } - - // ── 2. Search ──────────────────────────────────────── - item(key = "search") { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = stringResource(R.string.mcp_search_placeholder), + if (state.isTestingAll) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Science, + contentDescription = stringResource(R.string.mcp_servers_action_test_all), ) } + } + } + }, + ) { _ -> + Column(modifier = Modifier.fillMaxSize()) { + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SegmentedButton( + selected = state.selectedTab == McpTab.INSTALLED, + onClick = { viewModel.setTab(McpTab.INSTALLED) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { + Text(stringResource(R.string.mcp_tab_installed)) + } + SegmentedButton( + selected = state.selectedTab == McpTab.CATALOG, + onClick = { viewModel.setTab(McpTab.CATALOG) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text(stringResource(R.string.mcp_tab_catalog)) + } + } - // ── 3. Server list ─────────────────────────────────── - if (filteredServers.isEmpty() && query.isEmpty()) { - item(key = "empty") { - EmptyState( - title = stringResource(R.string.mcp_servers_empty_title), - subtitle = stringResource(R.string.mcp_servers_empty_desc), - actionLabel = stringResource(R.string.empty_action_add_server), - onAction = { viewModel.toggleAddForm() }, + when (state.selectedTab) { + McpTab.INSTALLED -> { + when { + state.isLoading && state.servers.isEmpty() -> { + SkeletonListState() + } + + state.errorMessage != null && state.servers.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadServers() }, ) } - } else if (filteredServers.isEmpty()) { - item(key = "no-match") { - Text( - text = stringResource(R.string.mcp_no_match, query), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(spacing.md), - ) - } - } - items(filteredServers, key = { "server:${it.name}" }) { server -> - ServerCard( - server = server, - state = state, - viewModel = viewModel, - spacing = spacing, - onClick = { showDetail = server }, - onOpenBrowser = { url -> - try { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ), - ) - } catch (_: Exception) { + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + item(key = "add-form") { + AddServerSection(state, viewModel, spacing) } - }, - ) - } - // ── 4. Catalog ────────────────────────────────────── - item(key = "catalog-header") { - CatalogSection( - state = state, - viewModel = viewModel, - spacing = spacing, - filteredCatalog = filteredCatalog, - ) + if (state.servers.isNotEmpty()) { + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.mcp_search_placeholder), + ) + } + } + + if (state.servers.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.mcp_servers_empty_title), + subtitle = stringResource(R.string.mcp_servers_empty_desc), + actionLabel = stringResource(R.string.mcp_tab_catalog), + onAction = { viewModel.setTab(McpTab.CATALOG) }, + modifier = Modifier.height(320.dp), + ) + } + } else if (filteredServers.isEmpty()) { + item(key = "no-match") { + Text( + text = stringResource(R.string.mcp_no_match, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(spacing.md), + ) + } + } + + items(filteredServers, key = { "server:${it.name}" }) { server -> + ServerCard( + server = server, + state = state, + viewModel = viewModel, + spacing = spacing, + onClick = { showDetail = server }, + onOpenBrowser = { url -> + try { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ), + ) + } catch (_: Exception) { + } + }, + ) + } + } + } } } + + McpTab.CATALOG -> { + CatalogView( + state = state, + viewModel = viewModel, + spacing = spacing, + filteredCatalog = filteredCatalog, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 97ac71bf..fbbb910e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse import com.m57.hermescontrol.data.model.McpServerToggleRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkError @@ -31,7 +32,10 @@ import kotlinx.coroutines.withContext enum class AddServerMode { HTTP, Stdio } +enum class McpTab { INSTALLED, CATALOG } + data class McpServersUiState( + val selectedTab: McpTab = McpTab.INSTALLED, val isLoading: Boolean = false, val servers: List = emptyList(), val errorMessage: String? = null, @@ -458,7 +462,12 @@ class McpServersViewModel( val updatedEnv = existingEnv + (key to value) val result = withContext(ioDispatcher) { - safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + safeApiCall { + ApiClient.hermesApi.updateMcpServer( + serverName, + McpServerUpdateRequest(env = updatedEnv), + ) + } } when (result) { is NetworkResult.Success -> { @@ -490,7 +499,12 @@ class McpServersViewModel( val updatedEnv = existingEnv - key val result = withContext(ioDispatcher) { - safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + safeApiCall { + ApiClient.hermesApi.updateMcpServer( + serverName, + McpServerUpdateRequest(env = updatedEnv), + ) + } } when (result) { is NetworkResult.Success -> { @@ -536,6 +550,14 @@ class McpServersViewModel( } } + fun setTab(tab: McpTab) { + _uiState.update { it.copy(selectedTab = tab) } + val s = _uiState.value + if (tab == McpTab.CATALOG && s.catalogEntries.isEmpty() && !s.catalogLoading) { + loadCatalog() + } + } + fun updateCatalogQuery(v: String) { _uiState.update { it.copy(catalogQuery = v) } } @@ -563,6 +585,7 @@ class McpServersViewModel( ) } loadServers(forceRefresh = true) + loadCatalog() } is NetworkResult.Failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt index 4bae7bf1..3bc37626 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/AddServerSection.kt @@ -5,29 +5,20 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -50,37 +41,6 @@ fun AddServerSection( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - McpSectionHeader( - icon = Icons.Filled.Add, - title = stringResource(R.string.mcp_servers_add_server), - trailing = { - Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) { - TextButton( - onClick = { viewModel.toggleImportDialog() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp)) - Spacer(modifier = Modifier.width(spacing.xs)) - Text(stringResource(R.string.mcp_servers_action_import_json)) - } - TextButton( - onClick = { viewModel.toggleAddForm() }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (state.showAddForm) "Hide" else "New") - } - } - }, - ) - AnimatedVisibility(visible = state.showAddForm) { Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt index 533123d2..5aaf7abf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -2,25 +2,27 @@ package com.m57.hermescontrol.ui.mcp.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField 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 @@ -34,91 +36,68 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.McpCatalogEntry import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.mcp.McpServersUiState import com.m57.hermescontrol.ui.mcp.McpServersViewModel @Composable -fun CatalogSection( +fun CatalogView( state: McpServersUiState, viewModel: McpServersViewModel, spacing: Spacing, filteredCatalog: List, modifier: Modifier = Modifier, ) { - val catalogExpanded = remember { mutableStateOf(false) } - - Column(modifier = modifier) { - McpSectionHeader( - icon = Icons.Filled.Storage, - title = stringResource(R.string.mcp_servers_section_catalog), - trailing = { - TextButton( - onClick = { - catalogExpanded.value = !catalogExpanded.value - if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { - viewModel.loadCatalog() - } - }, - colors = - ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), - ) { - Text(if (catalogExpanded.value) "Hide" else "Browse") - } - }, + val installedNames = remember(state.servers) { state.servers.map { it.name }.toSet() } + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = state.catalogQuery, + onQueryChange = viewModel::updateCatalogQuery, + placeholder = stringResource(R.string.mcp_servers_catalog_search), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) - AnimatedVisibility(visible = catalogExpanded.value) { - Column(modifier = Modifier.fillMaxWidth()) { - // Search - OutlinedTextField( - value = state.catalogQuery, - onValueChange = viewModel::updateCatalogQuery, - label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), + when { + state.catalogLoading && state.catalogEntries.isEmpty() -> { + SkeletonListState() + } + + state.catalogError != null && state.catalogEntries.isEmpty() -> { + ErrorState( + message = state.catalogError, + onRetry = { viewModel.loadCatalog() }, ) + } - // Loading / error / content - when { - state.catalogLoading -> { - Spacer(modifier = Modifier.height(spacing.sm)) - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.height(spacing.sm)) - } + filteredCatalog.isEmpty() -> { + EmptyState( + title = stringResource(R.string.mcp_servers_catalog_empty_title), + subtitle = stringResource(R.string.mcp_servers_catalog_empty), + actionLabel = stringResource(R.string.content_desc_refresh), + onAction = { viewModel.loadCatalog() }, + ) + } - state.catalogError != null -> { - ErrorState( - message = state.catalogError, - onRetry = { viewModel.loadCatalog() }, + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + items(filteredCatalog, key = { "catalog:${it.name}" }) { entry -> + CatalogEntryCard( + entry = entry, + state = state, + viewModel = viewModel, + spacing = spacing, + isInstalled = entry.installed || entry.name in installedNames, ) } - - filteredCatalog.isEmpty() -> { - Text( - text = stringResource(R.string.mcp_servers_catalog_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = spacing.md), - ) - } - - else -> { - filteredCatalog.forEach { entry -> - CatalogEntryCard( - entry = entry, - state = state, - viewModel = viewModel, - spacing = spacing, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - } - } } } } @@ -132,6 +111,7 @@ fun CatalogEntryCard( viewModel: McpServersViewModel, spacing: Spacing, modifier: Modifier = Modifier, + isInstalled: Boolean = false, ) { var showInstallForm by remember { mutableStateOf(false) } val isInstalling = state.installingCatalogEntry == entry.name @@ -160,23 +140,31 @@ fun CatalogEntryCard( ) } } - Button( - onClick = { - if (entry.env?.isNotEmpty() == true) { - showInstallForm = !showInstallForm - } else { - viewModel.installCatalogEntry(entry) - } - }, - enabled = !isInstalling, - ) { - Text( - if (isInstalling) { - stringResource(R.string.mcp_servers_catalog_installing) - } else { - stringResource(R.string.mcp_servers_catalog_install) + if (isInstalled) { + FilledTonalButton(onClick = {}, enabled = false) { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_catalog_installed)) + } + } else { + Button( + onClick = { + if (entry.env?.isNotEmpty() == true) { + showInstallForm = !showInstallForm + } else { + viewModel.installCatalogEntry(entry) + } }, - ) + enabled = !isInstalling, + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } } } @@ -190,7 +178,7 @@ fun CatalogEntryCard( } // Install form with env vars - AnimatedVisibility(visible = showInstallForm) { + AnimatedVisibility(visible = showInstallForm && !isInstalled) { Column(modifier = Modifier.padding(top = spacing.sm)) { entry.env?.let { envVars -> if (envVars.isNotEmpty()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt deleted file mode 100644 index 9dd01b89..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpSectionHeader.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.m57.hermescontrol.ui.mcp.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.theme.LocalSpacing - -@Composable -fun McpSectionHeader( - icon: ImageVector, - title: String, - modifier: Modifier = Modifier, - trailing: (@Composable () -> Unit)? = null, -) { - val spacing = LocalSpacing.current - Row( - modifier = - modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(12.dp), - ).padding(horizontal = spacing.md, vertical = spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(spacing.sm)) - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - ) - } - trailing?.invoke() - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt index b5b22347..da6ca944 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -151,7 +151,7 @@ fun ServerCard( // Tool count & schema token overhead badge (issue #1029) val toolInfos = testResult?.tools - val toolCount = toolInfos?.size ?: server.tools?.size + val toolCount = toolInfos?.size ?: server.toolCount if (toolCount != null && toolCount > 0) { val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt index 6a67378b..c9047f35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/process/ProcessesViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ProcessKillParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.toAny import com.m57.hermescontrol.ui.common.ToastHost import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,13 +70,8 @@ class ProcessesViewModel : _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { try { - val result = - HermesWsClient - .request( - WsMethods.PROCESS_LIST, - mapOf("session_id" to sessionId), - ).await() - val processes = parseProcessList(result) + val result = HermesWsClient.call(RpcMethods.PROCESS_LIST, SessionIdParams(sessionId)) + val processes = parseProcessList(result.toAny()) _uiState.update { it.copy( isLoading = false, @@ -105,14 +103,7 @@ class ProcessesViewModel : _uiState.update { it.copy(killingId = processId) } viewModelScope.launch { try { - HermesWsClient - .request( - WsMethods.PROCESS_KILL, - mapOf( - "process_id" to processId, - "session_id" to sessionId, - ), - ).await() + HermesWsClient.call(RpcMethods.PROCESS_KILL, ProcessKillParams(sessionId, processId)) _uiState.update { it.copy(killingId = null, toastMessage = "Process killed") } load() } catch (e: Exception) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 5ea87d77..2fec303c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions @@ -33,22 +34,18 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog @@ -66,6 +63,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -81,10 +79,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState 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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -126,7 +124,9 @@ import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow import com.m57.hermescontrol.ui.sessions.components.automationGroups +import com.m57.hermescontrol.ui.sessions.components.sourceLabel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first /** * Auto-load the next history page when the user scrolls to within this many @@ -135,30 +135,6 @@ import kotlinx.coroutines.delay private const val AUTO_LOAD_THRESHOLD = 6 private const val AGE_TICK_MS = 60_000L -/** - * Maps a session source string to a Material icon for visual identification. - */ -private fun sourceIcon(source: String?): ImageVector? = - when (source?.lowercase()) { - "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code - "cli", "terminal" -> Icons.Filled.Terminal - else -> null - } - -/** - * Maps a source string to a label for tooltip / accessibility. - */ -private fun sourceLabel(source: String?): String = - when (source?.lowercase()) { - "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" - "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" - } - /** * Builds an annotated string with search term highlighting. */ @@ -200,19 +176,22 @@ private fun highlightText( internal fun displayedSessions(state: SessionsUiState): List = if (state.isSearchMode) { - state.searchResults.map { result -> - val session = result.toSessionInfo() - SessionTreeItem( - session = session, - depth = 0, - branchStem = null, - displayTitle = - session.title?.takeIf(String::isNotBlank) - ?: session.display_name?.takeIf(String::isNotBlank) - ?: session.preview?.takeIf(String::isNotBlank)?.take(80) - ?: "Untitled", - ) - } + state.searchResults + .map { result -> result.toSessionInfo() } + .filter { session -> + state.activeSourceFilter?.let { session.source?.lowercase() == it } ?: true + }.map { session -> + SessionTreeItem( + session = session, + depth = 0, + branchStem = null, + displayTitle = + session.title?.takeIf(String::isNotBlank) + ?: session.display_name?.takeIf(String::isNotBlank) + ?: session.preview?.takeIf(String::isNotBlank)?.take(80) + ?: "Untitled", + ) + } } else { flattenSessionsWithBranches(state.displaySessions) } @@ -240,6 +219,7 @@ fun SessionsScreen( state.sessions, state.showHidden, state.searchResults, + state.sourceFilter, ) { displayedSessions(state) } @@ -507,6 +487,24 @@ fun SessionsScreen( // (The scaffold already applies top-bar padding via its inner Box, so we // must NOT re-apply paddingValues here.) Column(modifier = Modifier.fillMaxSize()) { + // Prune can take a while on large histories — show it's working. + if (state.isPruning) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm) + .testTag("sessions_pruning_indicator"), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Text( + text = stringResource(R.string.sessions_pruning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } Row( modifier = Modifier @@ -528,6 +526,25 @@ fun SessionsScreen( ) } + // Source chips: hidden until a loaded row actually carries that source. + val sources = state.availableSources + if (sources.size > 1) { + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("history_source_chips"), + contentPadding = PaddingValues(horizontal = spacing.md), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + items(sources, key = { it }) { source -> + FilterChip( + selected = state.activeSourceFilter == source, + onClick = { viewModel.selectSourceFilter(source) }, + label = { Text(sourceLabel(source)) }, + modifier = Modifier.testTag("history_source_chip_$source"), + ) + } + } + } + // ── Search + bulk toggle (always visible) ───────────── Row( modifier = @@ -787,6 +804,17 @@ fun SessionsScreen( // ── Session list ──────────────────────────────────── val listState = rememberLazyListState() + // Opening (or switching section) always starts at the top. Keyed items otherwise + // keep the old anchor row when fresher sessions land above it after the refresh. + var userScrolled by remember(state.section) { mutableStateOf(false) } + LaunchedEffect(state.section) { + snapshotFlow { listState.isScrollInProgress }.first { it } + userScrolled = true + } + val firstSessionId = sessionsToDisplay.firstOrNull()?.session?.id + LaunchedEffect(state.section, firstSessionId, userScrolled) { + if (!userScrolled && firstSessionId != null) listState.scrollToItem(0) + } // Fluid infinite scroll: once the user reaches within // AUTO_LOAD_THRESHOLD items of the end, pull the next // page automatically. Driven by real scroll position diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 87363b65..421f9f40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -110,6 +110,7 @@ data class SessionsUiState( val searchNextOffset: Int? = null, val searchLoadMoreError: String? = null, val showHidden: Boolean = false, + val sourceFilter: String? = null, val pinnedExpanded: Boolean = true, val liveStatuses: Map = emptyMap(), // Named projects used to label each row with the workspace it belongs to. @@ -120,9 +121,30 @@ data class SessionsUiState( val hasHiddenSessions: Boolean get() = sessions.any { it.hidden == true } - val displaySessions: List + // Source chips only exist for sources present in the rows currently loaded. + val availableSources: List + get() { + val rows = if (isSearchMode) searchResults.map { it.source } else visibleBySource.map { it.source } + return rows + .mapNotNull { it?.lowercase()?.takeIf(String::isNotBlank) } + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { it.key } + } + + val activeSourceFilter: String? + get() = sourceFilter?.takeIf { it in availableSources } + + private val visibleBySource: List get() = if (showHidden) sessions else sessions.filter { it.hidden != true } + val displaySessions: List + get() = + activeSourceFilter?.let { f -> visibleBySource.filter { it.source?.lowercase() == f } } + ?: visibleBySource + val pinnedSessions: List get() = displaySessions.filter { it.pinned == true } } @@ -339,6 +361,7 @@ class SessionsViewModel( _uiState.update { it.copy( section = section, + sourceFilter = null, isLoading = false, isLoadingMore = false, sessions = emptyList(), @@ -701,6 +724,10 @@ class SessionsViewModel( // ── Bulk selection ─────────────────────────────────────────────────── + fun selectSourceFilter(source: String?) { + _uiState.update { it.copy(sourceFilter = if (it.sourceFilter == source) null else source) } + } + fun toggleShowHidden() { _uiState.update { it.copy(showHidden = !it.showHidden) } } @@ -963,7 +990,13 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.bulkDeleteSessions( - body = BulkDeleteRequest(ids = ids), + body = + BulkDeleteRequest( + ids = ids, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { @@ -1021,7 +1054,16 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.pruneSessions( - body = PruneRequest(days = days), + body = + PruneRequest( + // The server model is `older_than_days`; the + // old `days` key was silently ignored and the + // backend pruned its own 90-day default. + olderThanDays = days, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index fb1b34b4..cd31a0d9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -98,19 +98,25 @@ fun BranchRow( fun sourceIcon(source: String?): ImageVector? = when (source?.lowercase()) { "telegram", "tg" -> Icons.AutoMirrored.Filled.Send - "web", "dashboard" -> Icons.Filled.Language - "api", "rest" -> Icons.Filled.Code + "web", "dashboard", "webui" -> Icons.Filled.Language + "api", "rest", "api_server" -> Icons.Filled.Code "cli", "terminal" -> Icons.Filled.Terminal else -> null } fun sourceLabel(source: String?): String = when (source?.lowercase()) { + null, "" -> "Unknown" "telegram", "tg" -> "Telegram" - "web", "dashboard" -> "Web" - "api", "rest" -> "API" + "web", "dashboard", "webui" -> "Web" + "api", "rest", "api_server" -> "API" "cli", "terminal" -> "CLI" - else -> source ?: "Unknown" + "tui" -> "TUI" + "desktop" -> "Desktop" + "acp" -> "ACP" + "cron" -> "Cron" + "bot_room" -> "Bot room" + else -> source.replace('_', ' ').replaceFirstChar { it.uppercase() } } fun highlightText( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt index 112493f2..7fad6475 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt @@ -25,6 +25,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors @Composable fun SessionLiveStatusIndicator( @@ -35,27 +37,26 @@ fun SessionLiveStatusIndicator( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current - val (bgColor, fgColor, textRes, descRes) = + val (badgeType, textRes, descRes) = when (liveStatus) { SessionLiveStatus.WORKING -> { - Tuple4( - statusColors.successContainer, - statusColors.success, + Triple( + StatusBadgeType.SUCCESS, R.string.sessions_live_status_running, R.string.sessions_live_status_running_desc, ) } SessionLiveStatus.WAITING -> { - Tuple4( - statusColors.warningContainer, - statusColors.warning, + Triple( + StatusBadgeType.WARNING, R.string.sessions_live_status_waiting, R.string.sessions_live_status_waiting_desc, ) } } + val (bgColor, fgColor) = requireNotNull(statusBadgeColors(badgeType, statusColors)) val text = stringResource(textRes) val description = stringResource(descRes) @@ -103,10 +104,3 @@ fun SessionLiveStatusIndicator( } } } - -private data class Tuple4( - val first: A, - val second: B, - val third: C, - val fourth: D, -) 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 174605b6..84352316 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 @@ -241,6 +241,8 @@ internal fun SettingsBehaviorPage( onAutoReconnectChange = viewModel::onAutoReconnectChange, keepConnectedInBackground = state.keepConnectedInBackground, onKeepConnectedInBackgroundChange = viewModel::onKeepConnectedInBackgroundChange, + notifySessionCompletions = state.notifySessionCompletions, + onNotifySessionCompletionsChange = viewModel::onNotifySessionCompletionsChange, restoreLastSession = state.restoreLastSession, onRestoreLastSessionChange = viewModel::onRestoreLastSessionChange, ) 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 e2ebd227..1c1f3ef5 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 @@ -47,6 +47,7 @@ data class SettingsUiState( val showTokensPerSecond: Boolean = true, val showModelProvider: Boolean = false, val keepConnectedInBackground: Boolean = false, + val notifySessionCompletions: Boolean = false, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -96,6 +97,7 @@ class SettingsViewModel( val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled() val showModelProvider = AuthManager.isModelProviderShown() val keepConnectedInBackground = AuthManager.isKeepConnectedInBackground() + val notifySessionCompletions = AuthManager.isNotifySessionCompletions() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -124,6 +126,7 @@ class SettingsViewModel( showTokensPerSecond = showTokensPerSecond, showModelProvider = showModelProvider, keepConnectedInBackground = keepConnectedInBackground, + notifySessionCompletions = notifySessionCompletions, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -394,6 +397,11 @@ class SettingsViewModel( AuthManager.setKeepConnectedInBackground(enabled) } + fun onNotifySessionCompletionsChange(enabled: Boolean) { + _uiState.update { it.copy(notifySessionCompletions = enabled) } + AuthManager.setNotifySessionCompletions(enabled) + } + /** Reconcile the chat shortcut with changes made through Settings or another screen. */ fun refreshKeepConnectedInBackground() { val enabled = AuthManager.isKeepConnectedInBackground() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 1a8207f6..d17689db 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -270,11 +269,6 @@ private fun ReleaseCandidateRow( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } 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 e59f37a6..5b5ae0fa 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.settings.components +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,7 +22,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -41,6 +41,7 @@ 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.theme.ThemeRegistry import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.settings.SectionCard import kotlin.math.abs @@ -55,6 +56,7 @@ internal fun AppearanceSection( themePreset: ThemePreset, onThemePresetChange: (ThemePreset) -> Unit, ) { + val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S SectionCard { Text( text = stringResource(R.string.settings_item_theme), @@ -106,11 +108,6 @@ internal fun AppearanceSection( Switch( checked = useDynamicColors, onCheckedChange = onUseDynamicColorsChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -122,72 +119,30 @@ internal fun AppearanceSection( ) Spacer(modifier = Modifier.height(8.dp)) + if (dynamicAvailable) { + Text( + text = stringResource(R.string.settings_desc_theme_preset_dynamic), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + var presetsExpanded by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { OutlinedButton( onClick = { presetsExpanded = true }, - enabled = !useDynamicColors, modifier = Modifier.fillMaxWidth(), ) { - Text( - when (themePreset) { - ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) - ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) - ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox) - ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin) - ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled) - ThemePreset.NORD -> stringResource(R.string.theme_preset_nord) - }, - ) + Text(themePreset.label()) } DropdownMenu( expanded = presetsExpanded, onDismissRequest = { presetsExpanded = false }, modifier = Modifier.fillMaxWidth(0.85f), ) { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> DropdownMenuItem( - text = { - Text( - when (preset) { - ThemePreset.DEFAULT -> { - stringResource( - R.string.theme_preset_default, - ) - } - - ThemePreset.MONOCHROME -> { - stringResource( - R.string.theme_preset_monochrome, - ) - } - - ThemePreset.GRUVBOX -> { - stringResource( - R.string.theme_preset_gruvbox, - ) - } - - ThemePreset.CATPPUCCIN -> { - stringResource( - R.string.theme_preset_catppuccin, - ) - } - - ThemePreset.AMOLED -> { - stringResource( - R.string.theme_preset_amoled, - ) - } - - ThemePreset.NORD -> { - stringResource( - R.string.theme_preset_nord, - ) - } - }, - ) - }, + text = { Text(preset.label()) }, onClick = { onThemePresetChange(preset) presetsExpanded = false @@ -199,6 +154,20 @@ internal fun AppearanceSection( } } +@Composable +private fun ThemePreset.label(): String = + stringResource( + when (this) { + ThemePreset.DEFAULT -> R.string.theme_preset_default + ThemePreset.MONOCHROME -> R.string.theme_preset_monochrome + ThemePreset.GRUVBOX -> R.string.theme_preset_gruvbox + ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin + ThemePreset.AMOLED -> R.string.theme_preset_amoled + ThemePreset.NORD -> R.string.theme_preset_nord + ThemePreset.GARNET -> R.string.theme_preset_garnet + }, + ) + @Composable internal fun ChatSection( typingEffectEnabled: Boolean, @@ -485,11 +454,6 @@ internal fun ChatSection( checked = showModelProvider, onCheckedChange = onShowModelProviderChange, modifier = Modifier.testTag("settings_show_model_provider"), - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -516,11 +480,6 @@ internal fun ChatSection( Switch( checked = typingEffectEnabled, onCheckedChange = onTypingEffectEnabledChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index 7eca2657..00c8e92a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -1,5 +1,10 @@ package com.m57.hermescontrol.ui.settings.components +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,13 +14,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.settings.SectionCard @@ -25,9 +31,20 @@ internal fun BehaviorSection( onAutoReconnectChange: (Boolean) -> Unit, keepConnectedInBackground: Boolean, onKeepConnectedInBackgroundChange: (Boolean) -> Unit, + notifySessionCompletions: Boolean, + onNotifySessionCompletionsChange: (Boolean) -> Unit, restoreLastSession: Boolean, onRestoreLastSessionChange: (Boolean) -> Unit, ) { + val context = LocalContext.current + // Turning session-complete alerts on needs POST_NOTIFICATIONS (Android + // 13+). The chat screen requests it on open, but the toggle lives in + // Settings — gate here so enabling never lands in a state where the OS + // silently blocks what the user just asked for. Denial reverts the toggle. + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) onNotifySessionCompletionsChange(true) + } SectionCard { Row( modifier = Modifier.fillMaxWidth(), @@ -50,11 +67,6 @@ internal fun BehaviorSection( Switch( checked = autoReconnect, onCheckedChange = onAutoReconnectChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -83,11 +95,49 @@ internal fun BehaviorSection( Switch( checked = keepConnectedInBackground, onCheckedChange = onKeepConnectedInBackgroundChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_notify_session_completions_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_notify_session_completions_desc), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Switch( + checked = notifySessionCompletions, + onCheckedChange = { enabled -> + if (!enabled) { + onNotifySessionCompletionsChange(false) + } else { + val permission = Manifest.permission.POST_NOTIFICATIONS + val granted = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + if (granted) { + onNotifySessionCompletionsChange(true) + } else { + permissionLauncher.launch(permission) + } + } + }, ) } @@ -116,11 +166,6 @@ internal fun BehaviorSection( Switch( checked = restoreLastSession, onCheckedChange = onRestoreLastSessionChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 88b8b69e..2de21c08 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -11,11 +11,15 @@ import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.CheckpointsResponse +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CuratorResponse +import com.m57.hermescontrol.data.model.DebugShareRequest import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.StatusResponse @@ -531,11 +535,11 @@ class SystemViewModel( _uiState.update { it.copy(addingCred = true) } viewModelScope.launch { val body = - buildMap { - put("provider", state.credProvider) - put("token", state.credKey) - if (state.credLabel.isNotBlank()) put("label", state.credLabel) - } + CredentialPoolAddRequest( + provider = state.credProvider, + apiKey = state.credKey, + label = state.credLabel.ifBlank { null }, + ) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.addCredentialPoolEntry(body) } @@ -780,7 +784,7 @@ class SystemViewModel( viewModelScope.launch { val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.runDebugShare(mapOf("redact" to shareRedact)) } + safeApiCall { ApiClient.hermesApi.runDebugShare(DebugShareRequest(redact = shareRedact)) } } when (result) { is NetworkResult.Success -> { @@ -849,13 +853,13 @@ class SystemViewModel( _uiState.update { it.copy(creatingHook = true) } viewModelScope.launch { val body = - buildMap { - put("event", state.hookEvent) - put("command", state.hookCommand) - if (state.hookMatcher.isNotBlank()) put("matcher", state.hookMatcher) - if (state.hookTimeout.isNotBlank()) put("timeout", state.hookTimeout.toIntOrNull() ?: 30) - put("allowed", state.hookApprove) - } + HookCreateRequest( + event = state.hookEvent, + command = state.hookCommand, + matcher = state.hookMatcher.ifBlank { null }, + timeout = if (state.hookTimeout.isBlank()) null else state.hookTimeout.toIntOrNull() ?: 30, + approve = state.hookApprove, + ) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.createHook(body) } @@ -894,7 +898,7 @@ class SystemViewModel( viewModelScope.launch { val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.deleteHook(mapOf("event" to event, "command" to command)) } + safeApiCall { ApiClient.hermesApi.deleteHook(HookDeleteRequest(event, command)) } } when (result) { is NetworkResult.Success -> { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 15e5b90e..8e923ad0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -19,6 +19,7 @@ النتيجة التالية إغلاق البحث إعادة المحاولة +نسخ الخطأ إلغاء تأكيد مسح @@ -129,6 +130,7 @@ Hermes بانتظار ردود Hermes متصل في الخلفية +إيقاف جارٍ الاتصال… في انتظار الشبكة جارٍ إعادة الاتصال @@ -759,6 +761,7 @@ حذف الجلسات الأقدم من عدد الأيام المحدد. حد الأيام تنقيح +جارٍ تنقيح الجلسات القديمة… فارغة هل تريد حذف الجلسات الفارغة؟ حذف %1$d من الجلسات الفارغة. سيتم الاحتفاظ بالجلسات النشطة والمؤرشفة. @@ -1261,7 +1264,6 @@ إغلاق حفظ على الجهاز مشاركة -مشاركة الصورة إدارة المزود… لا توجد إعدادات للمزود لا يحتوي هذا المزود على خيارات قابلة للتهيئة. @@ -1277,10 +1279,6 @@ مفتاح API (اتركه فارغاً للإبقاء عليه) مفعل معطل -تم حفظ الصورة في التنزيلات -تعذر حفظ الصورة -تعذر تحميل الصورة: %1$s -تعذر مشاركة الصورة المكونات الأكثر استخداماً لا تتوفر بيانات للنماذج لا تتوفر بيانات للمهارات diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c659080f..ee02ab7a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -22,6 +22,7 @@ 재시도 + 오류 복사 취소 지우기 확인 @@ -126,6 +127,7 @@ 헤르메스 헤르메스 응답 대기 중 백그라운드에서 연결됨 + 끄기 연결 중… 네트워크 대기 중 재연결 중 @@ -646,6 +648,7 @@ 지정된 일수보다 오래된 세션을 삭제합니다. 일수 기준 정리 + 오래된 세션 정리 중… 분기된 세션 세션 삭제? 정말 \"%1$s\"을(를) 삭제하시겠습니까? 되돌릴 수 없습니다. @@ -1082,11 +1085,6 @@ 닫기 기기에 저장 공유 - 이미지 공유 - 다운로드에 이미지 저장됨 - 이미지를 저장할 수 없음 - 이미지를 불러올 수 없음: %1$s - 이미지를 공유할 수 없음 스킬 호출됨 시스템 알림 도구 호출 한도 도달 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index eac2130b..6944c427 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -19,6 +19,7 @@ 下一个匹配 关闭搜索 重试 + 复制错误 取消 确认 清除 @@ -129,6 +130,7 @@ Hermes 等待 Hermes 回复 在后台保持连接 + 关闭 正在连接… 等待网络连接 正在重新连接 @@ -730,6 +732,7 @@ 删除早于指定天数的会话。 天数阈值 清理 + 正在清理旧会话… 空会话 删除空会话? 删除 %1$d 个空会话?活动与归档会话会被保留。 @@ -1223,7 +1226,6 @@ 关闭 保存到设备 分享 - 分享图片 管理提供商… 没有提供商配置 此提供商没有可配置的界面。 @@ -1239,10 +1241,6 @@ API 密钥(留空保持不变) 开 关 - 图片已保存到下载目录 - 无法保存图片 - 无法加载图片:%1$s - 无法分享图片 %1$d 处发现 %1$d 处发现 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05a133d..213cfc57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ Retry + Copy error Cancel Confirm Clear @@ -152,6 +153,7 @@ Hermes Waiting for Hermes replies Connected in background + Turn off Connecting… Waiting for network Reconnecting @@ -163,6 +165,8 @@ Keeps connection alive in the background Hermes Chat Replies Alerts for new replies from Hermes + Session finished: %1$s + Session failed: %1$s Reply @@ -268,6 +272,8 @@ Auth Token Stay connected in background Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery. + Notify when a session completes + Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you. Behavior Auto-Reconnect Automatically reconnect on disconnect @@ -364,6 +370,7 @@ Catppuccin AMOLED Black (Dark only) Nord + Garnet Language @@ -877,6 +884,7 @@ Delete sessions older than the specified number of days. Days threshold Prune + Pruning old sessions… Empty Delete empty sessions? Delete %1$d empty session(s). Active and archived sessions are kept. @@ -1469,7 +1477,6 @@ Close Save to device Share - Share image Manage provider… No provider config This provider has no configurable surface. @@ -1485,10 +1492,6 @@ API key (leave blank to keep) On Off - Image saved to Downloads - Could not save image - Could not load image: %1$s - Could not share image Most Used Components No model data available @@ -1844,4 +1847,9 @@ Reconnect No tools available Read-only policy restrictions + Installed + Catalog + No catalog entries + Installed + Used for status colors while Material You supplies the app colors. diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 9579944e..774dc7d5 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -23,6 +23,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } @After @@ -30,6 +31,7 @@ class NavigationControllerTest { NavigationController.backStack = null NavigationController.consumePendingSessionId() NavigationController.consumePendingNewChatNavigation() + NavigationController.consumeChatReturnScreen() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -171,6 +173,92 @@ class NavigationControllerTest { assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true) } + // ── Chat return screen: back from a list-opened chat returns to that list ── + + @Test + fun `openChatSession from a list arms the list as the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSession("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + assertEquals(HistoryScreen, NavigationController.consumeChatReturnScreen()) + assertNull("consume must disarm", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from Bots arms Bots`() { + NavigationController.backStack = NavBackStack(BotsScreen) + + NavigationController.openChatSession("bot-session") + + assertEquals(BotsScreen, NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openChatSession from chat arms nothing`() { + NavigationController.backStack = NavBackStack(ChatScreen) + + NavigationController.openChatSession("stored-session") + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `notification open never arms a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openChatSessionFromNotification("stored-session") + + assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull()) + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `drawer navigation disarms a stale return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // User opens the drawer and picks Skills instead of pressing back. + NavigationController.navigateTo(SkillsScreen) + + assertNull("explicit navigation must invalidate the return", NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `re-tapping chat in the drawer also disarms the return`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + // Same-screen dedup would skip the body — the clear must still run. + NavigationController.navigateTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `openNewChat does not arm a return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + + NavigationController.openNewChat() + + assertNull(NavigationController.consumeChatReturnScreen()) + } + + @Test + fun `resetTo disarms the return screen`() { + NavigationController.backStack = NavBackStack(HistoryScreen) + NavigationController.openChatSession("stored-session") + assertEquals(HistoryScreen, NavigationController.chatReturnScreen) + + NavigationController.resetTo(ChatScreen) + + assertNull(NavigationController.consumeChatReturnScreen()) + } + // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt new file mode 100644 index 00000000..0d633ca4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class McpServerParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `servers decode tools as null, list, or filter object`() { + val body = + """{"servers":[ + {"name":"a","enabled":true,"tools":null}, + {"name":"b","enabled":true,"tools":["x","y"]}, + {"name":"c","enabled":true,"tools":{"exclude":["docs","*_radar*"]}}, + {"name":"d","enabled":true,"tools":{"include":["one","two","three"]}} + ]}""" + val servers = json.decodeFromString(body).servers + assertEquals(listOf(null, 2, null, 3), servers.map { it.toolCount }) + } + + @Test + fun `catalog entry decodes backend required_env and installed flags`() { + val body = + """{"entries":[{"name":"gh","description":"d","source":"nous","transport":"stdio", + "required_env":[{"name":"GH_TOKEN","prompt":"Token","required":true}], + "installed":true,"enabled":false,"needs_install":false}],"diagnostics":[]}""" + val entry = json.decodeFromString(body).entries.single() + assertTrue(entry.installed) + assertEquals("GH_TOKEN", entry.env?.single()?.key) + assertEquals("Token", entry.env?.single()?.label) + assertNull(entry.url) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt index 2fda9252..898e1dd4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ModelSerializationTest.kt @@ -622,4 +622,33 @@ class ModelSerializationTest { json.decodeFromString(jsonInput) } } + + @Test + fun moaConfigDecodesNullTemperaturesAndNewFields() { + val payload = + """ + {"default_preset":"default","active_preset":"","privacy_filter":"", + "presets":{"default":{"enabled":true, + "reference_models":[{"provider":"openrouter","model":"x/y","enabled":false,"reasoning_effort":"high"}], + "aggregator":{"provider":"openrouter","model":"a/b"}, + "reference_temperature":null,"aggregator_temperature":null, + "reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3"}}, + "reference_models":[{"provider":"openrouter","model":"x/y","enabled":true}], + "aggregator":{"provider":"openrouter","model":"a/b"}, + "reference_temperature":null,"aggregator_temperature":null, + "reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3","enabled":true} + """.trimIndent() + val cfg = json.decodeFromString(payload) + assertNull(cfg.reference_temperature) + assertNull(cfg.aggregator_temperature) + assertEquals("every_n:3", cfg.fanout) + val preset = cfg.presets.getValue("default") + assertEquals("silent", preset.degraded_reference_policy) + assertEquals(false, preset.reference_models.first().enabled) + assertEquals("high", preset.reference_models.first().reasoning_effort) + // Round-trip keeps the non-default knobs so a save doesn't reset them. + val back = json.encodeToString(cfg) + assertTrue(back.contains("every_n:3")) + assertTrue(back.contains("silent")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt new file mode 100644 index 00000000..a84f616a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Wire-shape pins for the destructive session ops. The backend reads the + * profile off these request BODIES (`SessionPrune` / `BulkDeleteSessions`) — + * the `?profile=` query rewrite never reaches them — and its prune model is + * `older_than_days` (the old `days` key was silently ignored, leaving the + * server to prune its own 90-day default). + */ +class SessionDestructiveOpsSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun pruneRequest_sendsServerFieldNames() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 7, profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("7", body["older_than_days"]!!.jsonPrimitive.content) + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + // The old key must be gone: the backend ignores it. + assertFalse(body.containsKey("days")) + } + + @Test + fun pruneRequest_omitsProfileWhenUnnamed() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 30)) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("30", body["older_than_days"]!!.jsonPrimitive.content) + assertFalse(body.containsKey("profile")) + } + + @Test + fun bulkDeleteRequest_carriesProfile() { + val encoded = json.encodeToString(BulkDeleteRequest(ids = listOf("a", "b"), profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + assertEquals(listOf("a", "b"), body["ids"]!!.jsonArray.map { it.jsonPrimitive.content }) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt new file mode 100644 index 00000000..0cb63108 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceConverterTest.kt @@ -0,0 +1,114 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.model.CredentialPoolAddRequest +import com.m57.hermescontrol.data.model.DebugShareRequest +import com.m57.hermescontrol.data.model.HookCreateRequest +import com.m57.hermescontrol.data.model.HookDeleteRequest +import com.m57.hermescontrol.data.model.McpCatalogInstallRequest +import com.m57.hermescontrol.data.model.McpServerUpdateRequest +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * Regression for #1402: `Map` bodies/results have no kotlinx serializer, so Retrofit failed to + * create the converter before any request was sent. These call the real service methods so converter creation + * (which JSON round-trip tests never exercise) is covered. + */ +class HermesApiServiceConverterTest { + private lateinit var server: MockWebServer + private lateinit var api: HermesApiService + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = + Retrofit + .Builder() + .baseUrl(server.url("/")) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .create(HermesApiService::class.java) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun ok(body: String = """{"ok":true}""") = + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body)) + + @Test + fun catalogInstall_readsResponse() = + runBlocking { + ok("""{"ok":true,"name":"example","background":false}""") + val r = api.installMcpCatalogEntry(McpCatalogInstallRequest(name = "example")) + assertTrue(r.isSuccessful) + assertEquals("example", r.body()?.name) + assertEquals("/api/mcp/catalog/install", server.takeRequest().path) + } + + @Test + fun updateMcpServer_sendsEnvBody() = + runBlocking { + ok("""{"name":"s","enabled":true}""") + api.updateMcpServer("s", McpServerUpdateRequest(env = mapOf("K" to "v"))) + val req = server.takeRequest() + assertEquals("PUT", req.method) + assertEquals("""{"env":{"K":"v"}}""", req.body.readUtf8()) + } + + @Test + fun curatorPaused_readsResponse() = + runBlocking { + ok("""{"ok":true,"paused":true}""") + assertTrue(api.setCuratorPaused(mapOf("paused" to true)).isSuccessful) + assertEquals("""{"paused":true}""", server.takeRequest().body.readUtf8()) + } + + @Test + fun credentialPool_addSendsApiKeyField_removeReadsResponse() = + runBlocking { + ok("""{"ok":true,"provider":"openrouter","count":1}""") + assertTrue(api.addCredentialPoolEntry(CredentialPoolAddRequest("openrouter", "sk", "lbl")).isSuccessful) + assertEquals( + """{"provider":"openrouter","api_key":"sk","label":"lbl"}""", + server.takeRequest().body.readUtf8(), + ) + ok("""{"ok":true,"provider":"openrouter","count":0,"cleaned":[],"hints":[]}""") + assertTrue(api.removeCredentialPoolEntry("openrouter", 1).isSuccessful) + } + + @Test + fun hooks_createSendsApprove_deleteSendsBody() = + runBlocking { + ok("""{"ok":true,"event":"e","command":"c","approved":true}""") + assertTrue(api.createHook(HookCreateRequest("e", "c", timeout = 5, approve = true)).isSuccessful) + assertEquals( + """{"event":"e","command":"c","timeout":5,"approve":true}""", + server.takeRequest().body.readUtf8(), + ) + ok() + assertTrue(api.deleteHook(HookDeleteRequest("e", "c")).isSuccessful) + val del = server.takeRequest() + assertEquals("DELETE", del.method) + assertEquals("""{"event":"e","command":"c"}""", del.body.readUtf8()) + } + + @Test + fun debugShare_sendsRedact() = + runBlocking { + ok("""{"ok":true,"urls":{"report":"u"}}""") + assertTrue(api.runDebugShare(DebugShareRequest(redact = false)).isSuccessful) + assertEquals("""{"redact":false}""", server.takeRequest().body.readUtf8()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index a221a389..8e7aaacf 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest { assertEquals(404, response.code()) } + @Test + fun getOAuthProviders_toleratesNumericExpiresAt() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + {"providers":[{"id":"codex","name":"Codex","flow":"external", + "status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]} + """.trimIndent(), + ), + ) + + val response = api.getOAuthProviders() + assertTrue(response.isSuccessful) + val status = response.body()!!.providers[0].status + assertEquals("1790766533634", status.expiresAt) + assertEquals("1790766000000", status.lastRefresh) + } + @Test fun getOAuthProviders_parsesList() = runBlocking { diff --git a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt index e149cfcc..93687a75 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/update/AppUpdateCheckerNetworkTest.kt @@ -107,6 +107,24 @@ class AppUpdateCheckerNetworkTest { assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path) } + @Test + fun fetchLatestRelease_rcScanAcceptsRepositoriesPaginationFormat() = + runTest { + enqueue(STABLE_BODY) + val nextPage = server.url("/repositories/1270568813/releases?per_page=100&page=2") + enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\"")) + enqueue( + """[{"tag_name":"v2.0.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""", + ) + + val info = checker().fetchLatestRelease(includeReleaseCandidates = true) + + assertEquals("v2.0.0-rc.1", info?.tagName) + assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path) + assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path) + assertEquals("/repositories/1270568813/releases?per_page=100&page=2", server.takeRequest().path) + } + @Test fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt index c13f06b0..79654fc6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/AccountConnectorRepositoryTest.kt @@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.AccountConnectorResult import com.m57.hermescontrol.data.model.ConnectorTool +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkAll @@ -28,7 +32,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } @@ -57,19 +61,24 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params operationFixture() } repo.operationStatus("op-a") - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), + ), + ) + repo.operationWake( + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-a", ), ) - repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a")) assertEquals( listOf( WsMethods.CONNECTORS_OPERATION_STATUS, @@ -86,13 +95,30 @@ class AccountConnectorRepositoryTest { assertEquals("op-a", params["op_id"]) assertFalse("session_id" in params) } + assertEquals( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "result" to mapOf("settled_by" to "continue"), + "profile" to "work", + ), + calls[1].second, + ) + assertEquals( + mapOf( + "owner" to mapOf("type" to "account"), + "op_id" to "op-a", + "profile" to "work", + ), + calls[2].second, + ) } @Test fun `catalog accounts tools and removal use native account endpoints`() = runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params when (method) { WsMethods.CONNECTORS_CATALOG -> { @@ -165,7 +191,7 @@ class AccountConnectorRepositoryTest { @Test fun `policy keeps member revision separate from effective and respects inherited rules`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() } + val repo = accountRepo { _, _ -> policyFixture() } val policy = (repo.policy() as AccountConnectorResult.Success).value assertEquals("effective-revision", policy.revision) assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision) @@ -180,7 +206,7 @@ class AccountConnectorRepositoryTest { runTest { val calls = mutableListOf>>() val repo = - HermesAccountConnectorRepository { method, params -> + accountRepo { method, params -> calls += method to params if (method == WsMethods.CONNECTORS_POLICY_GET) { policyFixture() @@ -208,10 +234,13 @@ class AccountConnectorRepositoryTest { @Test fun `malformed authorization does not look like a successful connect`() = runTest { - val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") } + val repo = accountRepo { _, _ -> mapOf("status" to "initiated") } assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure) } + private fun accountRepo(handler: suspend (String, Map) -> Any?) = + HermesAccountConnectorRepository(caller = fakeCaller(handler)) + private fun operationFixture() = mapOf( "op_id" to "op-a", diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt new file mode 100644 index 00000000..19481076 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionHealthTest.kt @@ -0,0 +1,518 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionHealthTest { + private class Fixture( + val scope: CoroutineScope, + var connected: Boolean = true, + var pingDelayMs: Long = 100L, + var pingShouldFail: Boolean = false, + var pendingReplyState: Boolean = false, + var initialTimeMs: Long = 100_000L, + ) { + var currentTimeMs: Long = initialTimeMs + val pingCalls = mutableListOf() + val cancelCalls = AtomicInteger(0) + val logs = mutableListOf() + val debugLogs = mutableListOf() + + val health: ConnectionHealth = + ConnectionHealth( + scope = scope, + isConnected = { connected }, + pingRequest = { timeoutMs -> + pingCalls.add(timeoutMs) + if (pingShouldFail) { + throw IllegalStateException("Timed out after ${timeoutMs}ms") + } + if (pingDelayMs > 0) { + delay(pingDelayMs) + currentTimeMs += pingDelayMs + } + }, + cancelSocket = { + cancelCalls.incrementAndGet() + }, + pendingReply = { pendingReplyState }, + nowMs = { currentTimeMs }, + log = { logs.add(it) }, + debugLog = { debugLogs.add(it) }, + ) + } + + @Test + fun pingClockElapsedAndFlowUpdates() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 120L, + initialTimeMs = 1_000L, + ) + try { + assertEquals(1_000L, fixture.currentTimeMs) + val latency = fixture.health.ping(timeoutMs = 5000L) + assertEquals(120L, latency) + assertEquals(120L, fixture.health.lastLatencyMs.value) + assertEquals(1_120L, fixture.health.lastPongTimestamp) + assertEquals(listOf(5000L), fixture.pingCalls) + } finally { + fixture.health.stop() + } + } + + @Test + fun pingFailureLeavesPreviousLatencyUntouched() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 50L, + initialTimeMs = 5_000L, + ) + try { + val firstLatency = fixture.health.ping(timeoutMs = 5000L) + assertEquals(50L, firstLatency) + assertEquals(50L, fixture.health.lastLatencyMs.value) + + fixture.pingShouldFail = true + try { + fixture.health.ping(timeoutMs = 2000L) + fail("Expected ping failure") + } catch (expected: IllegalStateException) { + // Expected + } + + // Previous latency state is preserved + assertEquals(50L, fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun stopResetsLatencyToNull() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 40L, + ) + try { + fixture.health.ping() + assertEquals(40L, fixture.health.lastLatencyMs.value) + + fixture.health.stop() + assertNull(fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun onInboundFrameUpdatesPongTimestamp() = + runTest { + val fixture = + Fixture( + scope = this, + initialTimeMs = 10_000L, + ) + try { + assertEquals(0L, fixture.health.lastPongTimestamp) + + fixture.health.onInboundFrame() + assertEquals(10_000L, fixture.health.lastPongTimestamp) + + fixture.currentTimeMs = 25_000L + fixture.health.onInboundFrame() + assertEquals(25_000L, fixture.health.lastPongTimestamp) + } finally { + fixture.health.stop() + } + } + + @Test + fun isHealthyStrictlyEnforcesStaleThresholdOf30000() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 50_000L, + ) + try { + fixture.health.onInboundFrame() + assertEquals(50_000L, fixture.health.lastPongTimestamp) + + // now - lastPong < 30000 is healthy + fixture.currentTimeMs = 50_000L + 29_999L + assertTrue(fixture.health.isHealthy) + + // now - lastPong == 30000 is NOT healthy (< 30000 required) + fixture.currentTimeMs = 50_000L + 30_000L + assertFalse(fixture.health.isHealthy) + + // now - lastPong > 30000 is NOT healthy + fixture.currentTimeMs = 50_000L + 30_001L + assertFalse(fixture.health.isHealthy) + } finally { + fixture.health.stop() + } + } + + @Test + fun isHealthyDisconnectedGuard() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 10_000L, + ) + try { + fixture.health.onInboundFrame() + assertTrue(fixture.health.isHealthy) + + fixture.connected = false + assertFalse(fixture.health.isHealthy) + } finally { + fixture.health.stop() + } + } + + @Test + fun forceHealthCheckForTestWatchdogStrictThresholdCancelsSocket() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + initialTimeMs = 100_000L, + ) + try { + fixture.health.onInboundFrame() + + // Within stale threshold (30000ms), no cancellation + fixture.health.forceHealthCheckForTest(30_000L) + assertEquals(0, fixture.cancelCalls.get()) + + // Exceeding stale threshold (> 30000ms), cancels socket + fixture.health.forceHealthCheckForTest(30_001L) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun periodicHeartbeatCancelsSocketWhenStaleThresholdExceeded() = + runTest { + val fixture = + Fixture( + scope = this, + connected = true, + pingShouldFail = true, + pingDelayMs = 0L, + initialTimeMs = 10_000L, + ) + try { + fixture.health.start() + runCurrent() + fixture.health.onInboundFrame() + + // Advance by heartbeat interval (15000ms), still fresh + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(0, fixture.cancelCalls.get()) + + // Advance another heartbeat interval to 30000ms from last inbound frame + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(0, fixture.cancelCalls.get()) + + // Now time elapsed is > 30000ms (e.g. 30001ms) + fixture.currentTimeMs += 1L + advanceTimeBy(15_000L) + fixture.currentTimeMs += 15_000L + runCurrent() + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun setLastLatencyForTestSetsFlowDirectly() = + runTest { + val fixture = + Fixture( + scope = this, + ) + try { + assertNull(fixture.health.lastLatencyMs.value) + fixture.health.setLastLatencyForTest(75L) + assertEquals(75L, fixture.health.lastLatencyMs.value) + fixture.health.setLastLatencyForTest(null) + assertNull(fixture.health.lastLatencyMs.value) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeSuccess() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 25L, + ) + try { + var customActionCalled = false + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 5000L, + onFailureAction = { customActionCalled = true }, + ) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertFalse(customActionCalled) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureInvokesCustomAction() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + var customActionCalled = false + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 4000L, + onFailureAction = { customActionCalled = true }, + ) + runCurrent() + + assertEquals(listOf(4000L), fixture.pingCalls) + assertTrue(customActionCalled) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureInvokesDefaultCancelSocket() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + fixture.health.probeLivenessOnTransportChange( + timeoutMs = 5000L, + ) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakeIdleCancelsImmediatelyOnFailure() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = false, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakePendingReplyFirstFailureDefers3000MsAndRetriesOnce() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + // First probe fails, but because pendingReply=true, it does not cancel immediately. + // It schedules a deferred retry after 3000ms. + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Advance before 3000ms + advanceTimeBy(2999L) + runCurrent() + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Advance to 3000ms - second ping is executed + advanceTimeBy(1L) + runCurrent() + assertEquals(2, fixture.pingCalls.size) + // Second probe fails, now it cancels + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnWakePendingReplySecondProbeSucceedsLeavesSocketOpen() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + pingDelayMs = 10L, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + // First attempt failed, pending deferred retry in 3000ms + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Before retry fires, recovery occurs: pings now succeed + fixture.pingShouldFail = false + + advanceTimeBy(3000L) + runCurrent() + + assertEquals(2, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun cancelForegroundProbeCancelsPendingProbe() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + pendingReplyState = true, + ) + try { + fixture.health.probeLivenessOnWake(deferredOnce = false) + runCurrent() + + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + + // Cancel the scheduled retry + fixture.health.cancelForegroundProbe() + + advanceTimeBy(5000L) + runCurrent() + + // No second probe should have been made, no cancellation + assertEquals(1, fixture.pingCalls.size) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeSuccessLogsAtDebugLevelWithoutWarning() = + runTest { + val fixture = + Fixture( + scope = this, + pingDelayMs = 25L, + ) + try { + fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L) + runCurrent() + advanceTimeBy(25L) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals( + listOf( + "Network transport changed — probing WebSocket liveness", + "WebSocket liveness probe succeeded on new transport", + ), + fixture.debugLogs, + ) + assertTrue(fixture.logs.isEmpty()) + assertEquals(0, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } + + @Test + fun probeLivenessOnTransportChangeFailureLogsDebugAndWarningAndCancelsSocket() = + runTest { + val fixture = + Fixture( + scope = this, + pingShouldFail = true, + ) + try { + fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L) + runCurrent() + + assertEquals(listOf(5000L), fixture.pingCalls) + assertEquals( + listOf("Network transport changed — probing WebSocket liveness"), + fixture.debugLogs, + ) + assertEquals( + listOf("Transport change liveness probe failed — cancelling socket"), + fixture.logs, + ) + assertEquals(1, fixture.cancelCalls.get()) + } finally { + fixture.health.stop() + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt index 958bff9a..903da505 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectorRepositoryTest.kt @@ -34,11 +34,12 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf("available" to true, "connectors" to emptyList>()) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf("available" to true, "connectors" to emptyList>()) + }, ) val result = repo.listConnectors("sess-abc-123") @@ -61,17 +62,18 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { method, params -> - recordedMethod = method - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "linear", "status" to "initiated"), - ), - "summary" to mapOf("total" to 1, "initiated" to 1), - ) - }, + caller = + fakeCaller { method, params -> + recordedMethod = method + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "linear", "status" to "initiated"), + ), + "summary" to mapOf("total" to 1, "initiated" to 1), + ) + }, ) val result = @@ -103,16 +105,17 @@ class ConnectorRepositoryTest { val repo = HermesConnectorRepository( - rpcRequest = { _, params -> - recordedParams = params - mapOf( - "results" to - listOf( - mapOf("connector" to "slack", "status" to "active"), - ), - "summary" to mapOf("total" to 1, "active" to 1), - ) - }, + caller = + fakeCaller { _, params -> + recordedParams = params + mapOf( + "results" to + listOf( + mapOf("connector" to "slack", "status" to "active"), + ), + "summary" to mapOf("total" to 1, "active" to 1), + ) + }, ) repo.connect(sessionId = "sess-789", connectors = listOf("slack")) @@ -125,10 +128,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) val r1 = repo.listConnectors("") @@ -147,10 +151,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Blank session_id @@ -172,10 +177,11 @@ class ConnectorRepositoryTest { var rpcCalled = false val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - rpcCalled = true - null - }, + caller = + fakeCaller { _, _ -> + rpcCalled = true + null + }, ) // Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols @@ -210,9 +216,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw CancellationException("coroutine cancelled") - }, + caller = + fakeCaller { _, _ -> + throw CancellationException("coroutine cancelled") + }, ) try { @@ -236,13 +243,14 @@ class ConnectorRepositoryTest { // 4031 CONNECTORS_UNAVAILABLE val repo4031 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Connectors unavailable", - code = 4031, - data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Connectors unavailable", + code = 4031, + data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))), + ) + }, ) val r4031 = repo4031.listConnectors("sess-1") assertTrue(r4031 is ConnectorListResult.Error) @@ -251,13 +259,14 @@ class ConnectorRepositoryTest { // 4001 NOT_OWNER val repo4001 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Session not owned", - code = 4001, - data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Session not owned", + code = 4001, + data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))), + ) + }, ) val r4001 = repo4001.connect("sess-1", listOf("linear")) assertTrue(r4001 is ConnectorConnectResult.Error) @@ -266,13 +275,14 @@ class ConnectorRepositoryTest { // 5033 UNSUPPORTED_RUNTIME val repo5033 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Compute host required", - code = 5033, - data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Compute host required", + code = 5033, + data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))), + ) + }, ) val r5033 = repo5033.connect("sess-1", listOf("linear")) assertTrue(r5033 is ConnectorConnectResult.Error) @@ -281,13 +291,14 @@ class ConnectorRepositoryTest { // 5034 INVALID_CONNECTOR_RESPONSE val repo5034 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Invalid connector response", - code = 5034, - data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Invalid connector response", + code = 5034, + data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))), + ) + }, ) val r5034 = repo5034.connect("sess-1", listOf("linear")) assertTrue(r5034 is ConnectorConnectResult.Error) @@ -296,13 +307,14 @@ class ConnectorRepositoryTest { // -32601 Unsupported backend val repo32601 = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw HermesWsClient.HermesRpcException( - message = "Method not found", - code = -32601, - data = null, - ) - }, + caller = + fakeCaller { _, _ -> + throw HermesWsClient.HermesRpcException( + message = "Method not found", + code = -32601, + data = null, + ) + }, ) val r32601 = repo32601.listConnectors("sess-1") assertTrue(r32601 is ConnectorListResult.Error) @@ -314,9 +326,10 @@ class ConnectorRepositoryTest { runBlocking { val repo = HermesConnectorRepository( - rpcRequest = { _, _ -> - throw java.io.IOException("Connection reset by peer") - }, + caller = + fakeCaller { _, _ -> + throw java.io.IOException("Connection reset by peer") + }, ) val rList = repo.listConnectors("sess-1") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 892b9d69..08ad2389 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.MessageReaction import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkAll @@ -928,6 +929,52 @@ class EventParserTest { ) } + @Test + fun testParseMessageReaction_returnsMessageReactionUpdated() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "message.reaction", + "session_id" to "s1", + "payload" to + mapOf( + "row_id" to 42, + "role" to "user", + "reactions" to + listOf( + mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0), + mapOf("emoji" to "", "author" to "user"), + ), + ), + ), + ) + val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated + assertEquals(42L, event.rowId) + assertEquals("user", event.role) + assertEquals("s1", event.sessionId) + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions) + } + + @Test + fun testParseMessageReaction_withoutRowId_isUnknown() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList())), + ) + assertTrue(EventParser.parse(response, "") is WsEvent.Unknown) + } + @Test fun testParseUnknownType_returnsUnknownEvent() { val response = diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt new file mode 100644 index 00000000..a133a003 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/FakeTypedRpcCaller.kt @@ -0,0 +1,22 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller + +/** + * Test double for [TypedRpcCaller]: encodes the typed params exactly as the wire would, hands the + * resulting map to [handler] together with the method name, and returns its value as a JsonElement. + */ +internal fun fakeCaller(handler: suspend (String, Map) -> Any?): TypedRpcCaller = + object : TypedRpcCaller { + @Suppress("UNCHECKED_CAST") + override suspend fun call( + method: RpcMethod, + params: P, + ): R { + val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params) + val map = (encoded.toAny() as? Map).orEmpty() + return ConnectorParser.toJsonElement(handler(method.name, map)) as R + } + } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt new file mode 100644 index 00000000..3abf299e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContract.kt @@ -0,0 +1,170 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Vendored snapshot of the backend's published gateway contract + * (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only. + * Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`. + */ +object GatewayContract { + private val root: JsonObject by lazy { + val stream = + requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) { + "gateway-contract/openrpc.json missing from test resources" + } + Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject + } + + private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject + + private fun names(key: String): Set = + (root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet() + + val methods: Set by lazy { names("methods") } + + /** Server→client request methods (`x-server-requests`). */ + val serverRequests: Set by lazy { names("x-server-requests") } + + private fun paramsSchema(method: String): JsonObject { + val entry = + (root["methods"] as JsonArray) + .map { it.jsonObject } + .first { it["name"]!!.jsonPrimitive.content == method } + val ref = + entry["params"]!! + .jsonArray + .first() + .jsonObject["schema"]!! + .jsonObject["\$ref"]!! + .jsonPrimitive.content + return schemas[ref.substringAfterLast('/')]!!.jsonObject + } + + /** + * Keys the backend would reject for [method]: unknown keys when the params + * schema forbids extras, plus required keys that are absent. Empty means OK. + * Keys only; value types are not checked. + */ + fun paramKeyProblems( + method: String, + keys: Set, + ): List { + if (method !in methods) return listOf("$method is not a contract method") + val schema = paramsSchema(method) + val properties = schema["properties"]?.jsonObject?.keys.orEmpty() + val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty() + val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false + val problems = mutableListOf() + if (strict) { + (keys - properties).sorted().forEach { + problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})" + } + } + required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" } + return problems + } + + /** + * Verifies that a Kotlinx [descriptor] for [method]'s params matches the published contract. + * Empty list means OK. + */ + fun paramsDescriptorProblems( + method: String, + descriptor: SerialDescriptor, + ): List { + if (method !in methods) return listOf("$method is not a contract method") + val schema = paramsSchema(method) + val propertiesObj = schema["properties"]?.jsonObject ?: JsonObject(emptyMap()) + val properties = propertiesObj.keys + val required = + schema["required"] + ?.jsonArray + ?.map { it.jsonPrimitive.content } + ?.toSet() + .orEmpty() + val problems = mutableListOf() + + val elementNames = (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet() + + for (i in 0 until descriptor.elementsCount) { + val name = descriptor.getElementName(i) + if (name !in properties) { + problems += + "$method: unknown param '$name' (backend forbids extra keys; allowed: ${properties.sorted()})" + } + } + + // No exemptions: decorate() skips injection when no profile is active, so a required `profile` + // must be declared like any other required param. + val missingRequired = required.filter { it !in elementNames } + for (missing in missingRequired) { + problems += "$method: missing required param '$missing'" + } + + for (i in 0 until descriptor.elementsCount) { + val name = descriptor.getElementName(i) + if (name in required) { + if (descriptor.isElementOptional(i)) { + problems += "$method: required param '$name' must not be optional" + } + val propSchema = propertiesObj[name]?.jsonObject + val allowsNull = propSchema?.let { propertyAllowsNull(it) } ?: false + if (descriptor.getElementDescriptor(i).isNullable && !allowsNull) { + problems += "$method: required param '$name' must not be nullable unless schema allows null" + } + } + } + + return problems + } + + private fun propertyAllowsNull(propertySchema: JsonObject): Boolean { + val typeElement = propertySchema["type"] + if (typeElement != null) { + if (typeElement is JsonArray && typeElement.any { it.jsonPrimitive.content == "null" }) { + return true + } + if (typeElement is JsonPrimitive && typeElement.content == "null") { + return true + } + } + val anyOf = propertySchema["anyOf"]?.jsonArray + if (anyOf != null && anyOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) { + return true + } + val oneOf = propertySchema["oneOf"]?.jsonArray + if (oneOf != null && oneOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) { + return true + } + return false + } +} + +/** + * WsMethods constants the backend contract does not list. Every entry needs a + * reason; the test fails if an entry appears in the contract or is no longer a + * WsMethods constant, so stale exceptions get removed. + */ +object GatewayContractAllowlist { + private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)" + + val entries: Map = + mapOf( + "gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method", + "clarify.respond" to LEGACY, + "sudo.respond" to LEGACY, + "secret.respond" to LEGACY, + "vault.unlock.respond" to LEGACY, + "vault.code.respond" to LEGACY, + "vault.save_login.respond" to LEGACY, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt new file mode 100644 index 00000000..b92d4208 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractFramesTest.kt @@ -0,0 +1,146 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Sends the client-owned RPCs through the real [HermesWsClient] and checks the + * exact frame on the wire against the vendored gateway contract (#1374). + */ +class GatewayContractFramesTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + } + + @After + fun tearDown() { + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + /** Connect, run [trigger], and return the first frame the server sees for [method]. */ + private fun captureFrame( + method: String, + beforeConnect: () -> Unit = {}, + trigger: () -> Unit = {}, + ): JsonObject { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frame = AtomicReference(null) + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (frame.get() == null && name == method) { + frame.set(json) + captured.countDown() + } + } + }, + ), + ) + beforeConnect() + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + assertTrue(opened.await(5, TimeUnit.SECONDS)) + trigger() + assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS)) + return requireNotNull(frame.get()) + } + + private fun assertMatchesContract(frame: JsonObject) { + val method = (frame["method"] as JsonPrimitive).content + val params = frame["params"] as JsonObject + val problems = GatewayContract.paramKeyProblems(method, params.keys) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + + @Test + fun promptSubmitFrameMatchesContract() { + assertMatchesContract( + captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) }, + ) + } + + @Test + fun eventsSinceReplayFrameMatchesContract() { + assertMatchesContract( + captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt new file mode 100644 index 00000000..be06f9c6 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/GatewayContractTest.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Modifier + +/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */ +class GatewayContractTest { + private val wsMethodConstants: Map = + WsMethods::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .associate { it.name to it.get(null) as String } + + @Test + fun everyWsMethodIsInTheContractOrAllowlisted() { + val unknown = + wsMethodConstants.filter { (_, method) -> + method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries + } + assertTrue( + "WsMethods constants missing from the gateway contract (backend removed or renamed them?): " + + unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } + + ". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.", + unknown.isEmpty(), + ) + } + + @Test + fun profileScopedMethodsAreContractMethods() { + val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods + assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty()) + } + + @Test + fun allowlistEntriesHaveReasonsAndAreNotStale() { + val sent = wsMethodConstants.values.toSet() + GatewayContractAllowlist.entries.forEach { (method, reason) -> + assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank()) + assertTrue( + "Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist", + method !in GatewayContract.methods, + ) + assertTrue( + "Allowlist entry '$method' is no longer a WsMethods constant; remove it", + method in sent, + ) + } + } + + @Test + fun handledServerRequestsExistInTheContract() { + val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests + assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty()) + + // Informational: unsupported requests are already answered with -32601. + val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted() + println("Contract server requests the app does not handle: $unhandled") + } + + @Test + fun paramKeyCheckRejectsTheRegressionsItExistsFor() { + // #1118: the backend forbids extra keys, so since_seq broke replay with error 4000. + val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq")) + assertEquals(1, problems.count { "since_seq" in it }) + assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty()) + assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty()) + } + + @Test + fun literalParamKeysAtCallSitesMatchTheContract() { + CALL_SITE_PARAM_KEYS.forEach { (method, keys) -> + val problems = GatewayContract.paramKeyProblems(method, keys) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + } + + @Test + fun typedParamsMatchTheContract() { + val methods = RpcMethods.all + assertTrue("RpcMethods.all must not be empty", methods.isNotEmpty()) + val names = methods.map { it.name } + assertEquals("RpcMethods.all names must be unique", names.distinct(), names) + + val problems = + methods.flatMap { method -> + GatewayContract.paramsDescriptorProblems(method.name, method.params.descriptor) + } + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + + private companion object { + /** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */ + val APP_HANDLED_SERVER_REQUESTS = + setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt") + + /** + * Union of literal param keys at production call sites (the two client-owned + * ones, prompt.submit and session.events.since, are checked against real frames + * in [GatewayContractFramesTest]). Update together with the call site. + */ + val CALL_SITE_PARAM_KEYS: Map> = + mapOf( + // ChatViewModel, GroupChatViewModel + "session.create" to setOf("profile", "title", "source", "hidden"), + // ChatViewModel.resumeSession, NotificationReplyReceiver + "session.resume" to setOf("session_id", "omit_messages"), + // ChatViewModel, GroupChatViewModel + "session.interrupt" to setOf("session_id"), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt new file mode 100644 index 00000000..8c6fd063 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientApprovalsTest.kt @@ -0,0 +1,237 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientApprovalsTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendApprovalRespondPutsExactParamsOnWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "approval.respond") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.APPROVAL_RESPOND, + params = + ApprovalRespondParams( + sessionId = "sess-appr-wire-1", + choice = "deny", + all = false, + requestId = "req-appr-wire-42", + ), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("approval.respond", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("session_id", "sess-appr-wire-1") + put("choice", "deny") + put("all", false) + put("request_id", "req-appr-wire-42") + } + assertEquals(expectedParams, params) + assertEquals(false, (params?.get("all") as? JsonPrimitive)?.booleanOrNull) + } + + @Test + fun typedCallSessionActiveListReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("active_count", 2) + put( + "sessions", + buildJsonObject { + put("primary", "sess-alpha") + put("secondary", "sess-beta") + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.active_list") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_ACTIVE_LIST, + SessionActiveListParams(), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.active_list", (frame["method"] as? JsonPrimitive)?.content) + + // An empty params object is omitted on wire or empty + val params = frame["params"] as? JsonObject + assertTrue( + "session.active_list with null currentSessionId carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt new file mode 100644 index 00000000..fc804bf2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientBillingVaultTest.kt @@ -0,0 +1,431 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.EmptyParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams +import com.m57.hermescontrol.data.ws.contract.VaultLockParams +import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientBillingVaultTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallSubscriptionChangeEncodesOnlyNonNullFieldsAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("subscription_id", "sub-12345") + put("scheduled_cancellation", false) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.change") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_CHANGE, + SubscriptionChangeParams( + subscriptionTypeId = null, + cancel = false, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("subscription.change", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedParams, params) + assertEquals(false, (params?.get("cancel") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("subscription_type_id") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultUnlockPutsNameAndPasswordOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("unlocked", true) + put("expires_in", 3600) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.unlock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_UNLOCK, + VaultUnlockParams( + name = "op-work", + password = "super-secret-pass", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.unlock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + put("password", "super-secret-pass") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithNamePutsNameOnWireAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked", true) + put("vault", "op-work") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = "op-work"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("name", "op-work") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallVaultLockWithoutNameLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("locked_all", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "vault.lock") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.VAULT_LOCK, + VaultLockParams(name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "vault.lock with null name carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallEmptyParamsMethodLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "active") + put("tier", "standard") + put("valid_until", "2026-12-31T23:59:59Z") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "subscription.state") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SUBSCRIPTION_STATE, + EmptyParams, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("subscription.state", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertTrue( + "subscription.state with EmptyParams carries no params", + params == null || params.isEmpty(), + ) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt new file mode 100644 index 00000000..77ec705d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientCharacterizationTest.kt @@ -0,0 +1,532 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Characterization test suite capturing existing baseline behaviors and edge cases + * of [HermesWsClient] before extracting components in issue #1376. + */ +class HermesWsClientCharacterizationTest { + private lateinit var mockWebServer: MockWebServer + private val serverSockets = Collections.synchronizedList(mutableListOf()) + private lateinit var serverClosed: CountDownLatch + + @Before + fun setUp() { + serverSockets.clear() + serverClosed = CountDownLatch(1) + mockkStatic(Log::class) + every { Log.isLoggable(any(), any()) } returns false + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.connectedForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED + HermesWsClient.messageQueueForTest.clear() + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + HermesWsClient.acceptQueuedMessagesForTest.set(true) + HermesWsClient.clearCredentialWarning() + HermesWsClient.clearSeqWatermarks() + HermesWsClient.setLastLatencyForTest(null) + } + + @After + fun tearDown() { + HermesWsClient.rejectAllPending() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(3000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + try { + if (serverSockets.isNotEmpty()) { + assertTrue("Server endpoint must finish closing", serverClosed.await(3, TimeUnit.SECONDS)) + } + mockWebServer.shutdown() + } finally { + unmockkAll() + } + } + + private fun connectClientToServer(onServerOpen: ((WebSocket) -> Unit)? = null): WebSocket { + val serverOpenLatch = CountDownLatch(1) + var serverWs: WebSocket? = null + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(code, reason) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + serverClosed.countDown() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: okhttp3.Response?, + ) { + serverClosed.countDown() + } + + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSockets.add(webSocket) + serverWs = webSocket + onServerOpen?.invoke(webSocket) + serverOpenLatch.countDown() + } + }, + ), + ) + + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS)) + return requireNotNull(serverWs) + } + + @Test + fun credentialWarning_updatedByGatewayReadyAndSessionInfo_retainedOnBlank_clearedExplicitly() { + assertNull(HermesWsClient.credentialWarning.value) + + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) + val dummySocket = + mockk(relaxed = true) { + every { send(any()) } returns true + } + HermesWsClient.webSocketForTest = dummySocket + val generation = HermesWsClient.connectionGenerationForTest.get() + val listener = HermesWsClient.createListenerForTest(generation) + + // 1. GatewayReady with non-blank credential_warning updates flow + val gatewayReadyFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":"Warning: Auth key expired"}}}""" + listener.onMessage(dummySocket, gatewayReadyFrame) + + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Warning: Auth key expired" } + } + } + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + // 2. Blank or absent credential_warning does NOT clear the existing value. + // Deliver follow-up gateway.ready (absent) after blank and check warning is retained, + // then use session.info barrier to prove processing pipeline is drained. + val blankFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":""}}}""" + listener.onMessage(dummySocket, blankFrame) + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + val absentFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"version":"1.0"}}}""" + listener.onMessage(dummySocket, absentFrame) + assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value) + + // Barrier: a distinct non-blank session.info proves all preceding frames were processed in order + val barrierFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s-barrier","payload":{"credential_warning":"Barrier reached"}}}""" + listener.onMessage(dummySocket, barrierFrame) + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Barrier reached" } + } + } + assertEquals("Barrier reached", HermesWsClient.credentialWarning.value) + + // 3. SessionInfo with non-blank updates warning + val sessionInfoFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s1","payload":{"credential_warning":"Session warning"}}}""" + listener.onMessage(dummySocket, sessionInfoFrame) + + runBlocking { + withTimeout(3000) { + HermesWsClient.credentialWarning.first { it == "Session warning" } + } + } + assertEquals("Session warning", HermesWsClient.credentialWarning.value) + + // 4. Explicit clear resets to null + HermesWsClient.clearCredentialWarning() + assertNull(HermesWsClient.credentialWarning.value) + } + + @Test + fun disconnect_resetsLastLatencyMsToNull() { + HermesWsClient.setLastLatencyForTest(42L) + assertEquals(42L, HermesWsClient.lastLatencyMs.value) + + HermesWsClient.disconnect() + assertNull(HermesWsClient.lastLatencyMs.value) + } + + @Test + fun rpcTimeout_emitsHermesRpcExceptionCodeMinusOneExactMessage() { + val serverWs = connectClientToServer() + assertNotNull(serverWs) + + val shortTimeoutMs = 150L + val deferred = HermesWsClient.request("custom.test_method", emptyMap(), timeoutMs = shortTimeoutMs) + + try { + runBlocking { + withTimeout(3000) { + deferred.await() + } + } + fail("Expected deferred to fail with timeout exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-1, e.code) + assertEquals("Request timed out: custom.test_method", e.message) + } + } + + @Test + fun rejectAllPending_failsRequestsWithCustomException_cancelsTimeout_clearsPending() { + connectClientToServer() + + val deferred = HermesWsClient.request("custom.hold", emptyMap(), timeoutMs = 10_000L) + val pendingIds = HermesWsClient.pendingCallIdsForTest() + assertEquals(1, pendingIds.size) + val reqId = pendingIds.first() + + val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId) + assertNotNull("Timeout job should be scheduled", timeoutJob) + assertTrue("Timeout job should be active", timeoutJob?.isActive == true) + + val customException = HermesWsClient.HermesRpcException("Custom forced rejection", code = -99) + HermesWsClient.rejectAllPending(customException) + + assertTrue( + "Pending map should be empty after rejectAllPending", + HermesWsClient.pendingCallIdsForTest().isEmpty(), + ) + assertTrue("Timeout job should be cancelled", timeoutJob?.isCancelled == true) + assertTrue("Deferred should be completed exceptionally", deferred.isCompleted) + + try { + runBlocking { deferred.await() } + fail("Expected custom exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-99, e.code) + assertEquals("Custom forced rejection", e.message) + } + } + + @Test + fun reconnectForNetworkChange_rejectsPendingCallsImmediately() { + connectClientToServer() + + val deferred = HermesWsClient.request("custom.in_flight", emptyMap(), timeoutMs = 30_000L) + assertEquals(1, HermesWsClient.pendingCallIdsForTest().size) + + // Network change triggers immediate rejectAllPending + HermesWsClient.reconnectForNetworkChange(networkAvailable = false) + + assertTrue("Pending map should be empty after network change", HermesWsClient.pendingCallIdsForTest().isEmpty()) + assertTrue("Deferred must be completed", deferred.isCompleted) + + try { + runBlocking { deferred.await() } + fail("Expected pending call to be rejected on network change") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Connection lost — request cancelled", e.message) + } + } + + @Test + fun disconnectAndListenerClosed_doNotRejectAllPending_deferredRemainsPendingUntilTimeoutOrReject() { + val serverWs = connectClientToServer() + + val shortTimeoutMs = 300L + val deferred = HermesWsClient.request("custom.deferred_call", emptyMap(), timeoutMs = shortTimeoutMs) + val reqId = HermesWsClient.pendingCallIdsForTest().first() + + // Drive onClosed on the CURRENT active generation before disconnect. + // In onClosed: isCurrent() is true (intentionalClose is still false, generation matches). + // This exercises the active listener onClosed branch. + val activeGeneration = HermesWsClient.connectionGenerationForTest.get() + val currentListener = HermesWsClient.createListenerForTest(activeGeneration) + val activeClientSocket = HermesWsClient.webSocketForTest ?: serverWs + + currentListener.onClosed(activeClientSocket, 1000, "Normal closure") + activeClientSocket.cancel() + + // Deferred remains pending after active onClosed + assertFalse("Deferred should still remain pending after onClosed", deferred.isCompleted) + assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId)) + + // Calling disconnect() does NOT invoke rejectAllPending() either + HermesWsClient.disconnect() + + assertFalse("Deferred should NOT be completed immediately by disconnect() alone", deferred.isCompleted) + assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId)) + + // Eventually, the per-request timeout triggers and resolves it + try { + runBlocking { + withTimeout(2000) { + deferred.await() + } + } + fail("Deferred should fail with timeout") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-1, e.code) + assertEquals("Request timed out: custom.deferred_call", e.message) + } + } + + @Test + fun combinedExternalAndBackgroundLeases_releaseOneKeepsOther_releaseBothDisconnectsWhenBackgrounded() { + connectClientToServer() + assertTrue(HermesWsClient.isConnected) + + // Acquire both leases + HermesWsClient.acquireExternalActivityConnectionLease() + HermesWsClient.acquireBackgroundConnectionLease() + assertTrue(HermesWsClient.hasBackgroundConnectionLease()) + + // Move to background - should NOT disconnect because leases are active + HermesWsClient.setAppForeground(false) + assertTrue("Should stay connected while leases are active", HermesWsClient.isConnected) + + // Release external lease - background lease still holds connection open + HermesWsClient.releaseExternalActivityConnectionLease() + assertTrue("Should stay connected with background lease active", HermesWsClient.isConnected) + + // Release background lease - both leases released, app backgrounded, idle -> disconnects + HermesWsClient.releaseBackgroundConnectionLease() + assertFalse("Should disconnect when all leases released in background", HermesWsClient.isConnected) + } + + @Test + fun delayedReplayAndLiveRace_incomingSeqBufferedBeforeReplayResponseAndDeduped() { + val sessionId = "session-characterization-race" + val serverOpenLatch = CountDownLatch(1) + val replayReceivedLatch = CountDownLatch(1) + val allowReplayResponseLatch = CountDownLatch(1) + val capturedReplayId = AtomicReference() + var serverSocketRef: WebSocket? = null + + // Enqueue MockResponse with WebSocketListener on MockWebServer + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(code, reason) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + serverClosed.countDown() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: okhttp3.Response?, + ) { + serverClosed.countDown() + } + + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + serverSockets.add(webSocket) + serverSocketRef = webSocket + serverOpenLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) { + val request = OkHttpProvider.json.parseToJsonElement(text) as JsonObject + val id = (request["id"] as JsonPrimitive).content + capturedReplayId.set(id) + replayReceivedLatch.countDown() + + if (allowReplayResponseLatch.await(5, TimeUnit.SECONDS)) { + val rpcId = requireNotNull(capturedReplayId.get()) + val idJson = JsonPrimitive(rpcId).toString() + val replayResponse = + """{"jsonrpc":"2.0","id":$idJson,"result":{"epoch":"epoch-1","latest_seq":12,"events":[{"type":"message.complete","session_id":"$sessionId","seq":11,"payload":{"text":"Replayed message 11"}},{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Replayed message 12"}}]}}""" + webSocket.send(replayResponse) + } + } + } + }, + ), + ) + + // Set watermark at seq 10 and epoch-1 prior to connect + HermesWsClient.setSeqWatermark(sessionId, 10) + HermesWsClient.setReplayEpochForTest("epoch-1") + + // Prepare subscription with onSubscription latch before stimulus + val collectedEvents = Collections.synchronizedList(mutableListOf()) + val subscriptionLatch = CountDownLatch(1) + val collectLatch = CountDownLatch(2) + val liveProcessedLatch = CountDownLatch(1) + + val collectorJob = + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + HermesWsClient.events + .onSubscription { + subscriptionLatch.countDown() + }.collect { event -> + if (event is WsEvent.SessionInfo && event.data?.get("barrier") == "live-processed") { + liveProcessedLatch.countDown() + } + if (event is WsEvent.MessageComplete && event.sessionId == sessionId) { + collectedEvents.add(event) + collectLatch.countDown() + } + } + } + + try { + assertTrue("Collector subscription should be established", subscriptionLatch.await(3, TimeUnit.SECONDS)) + + // Connect client - real onOpen automatically arms replay + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } + } + } + assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS)) + val serverSocket = requireNotNull(serverSocketRef) + + // Wait until the server receives session.events.since request + assertTrue("Server should receive replay request", replayReceivedLatch.await(5, TimeUnit.SECONDS)) + assertTrue("Replay should be in flight", HermesWsClient.isReplayInFlight()) + + // 1. Live frame arrived while replay is in flight (seq 12) + val liveFrame = + """{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Live message 12"}}}""" + serverSocket.send(liveFrame) + + // A following unsequenced frame proves the live notification reached the client + // before the replay response is released; unchanged watermark alone is not a barrier. + serverSocket.send( + """{"method":"event","params":{"type":"session.info","session_id":"$sessionId","payload":{"barrier":"live-processed"}}}""", + ) + assertTrue( + "Live frame must be processed before replay completes", + liveProcessedLatch.await(3, TimeUnit.SECONDS), + ) + assertEquals(10, HermesWsClient.getSeqWatermarks()[sessionId]) + assertEquals("Live frame should be held in replayHold while replay is in flight", 0, collectedEvents.size) + + // 2. Unblock replay response from server + allowReplayResponseLatch.countDown() + + // Wait for replay to deliver replayed events and drain held live events + assertTrue("Should collect replayed and unheld events", collectLatch.await(5, TimeUnit.SECONDS)) + + // Watermark should advance to 12 + assertEquals(12, HermesWsClient.getSeqWatermarks()[sessionId]) + + // Deduplication: held live frame seq 12 deduped against replay seq 12 + assertEquals(2, collectedEvents.size) + assertEquals("Replayed message 11", (collectedEvents[0] as WsEvent.MessageComplete).text) + assertEquals("Replayed message 12", (collectedEvents[1] as WsEvent.MessageComplete).text) + } finally { + allowReplayResponseLatch.countDown() + collectorJob.cancel() + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt new file mode 100644 index 00000000..a3817d97 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectionOperationTest.kt @@ -0,0 +1,562 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientConnectionOperationTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallConnectionRespondWithApprovedEnvAndAccountOwnerEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-approved-123", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "approved", + detail = "OAuth granted", + env = mapOf("TOKEN" to "dummy_secret_token_abc"), + ), + ), + settledBy = null, + ), + profile = "prod", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-approved-123") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "approved") + put("detail", "OAuth granted") + put( + "env", + buildJsonObject { + put("TOKEN", "dummy_secret_token_abc") + }, + ) + }, + ) + }, + ) + }, + ) + put("profile", "prod") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectionRespondWithSkippedOmittedEnvAndSessionOwnerEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", false) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-skip-456"), + opId = "op-skip-456", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "slack", + status = "skipped", + detail = null, + env = null, + ), + ), + settledBy = null, + ), + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-skip-456") + }, + ) + put("op_id", "op-skip-456") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "slack") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectionRespondWithContinueAndOmittedTargetsEncodesFrameAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("status", "ok") + put("settled", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connection.respond") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTION_RESPOND, + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-continue-789"), + opId = "op-continue-789", + result = + ConnectionAnswer( + targets = null, + settledBy = "continue", + ), + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-continue-789") + }, + ) + put("op_id", "op-continue-789") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectorsOperationWakeEncodesOwnerSessionOpIdAndProfileAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-wake-777") + put("status", "running") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.operation.wake") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-wake-99"), + opId = "op-wake-777", + profile = "custom-profile", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-wake-99") + }, + ) + put("op_id", "op-wake-777") + put("profile", "custom-profile") + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } + + @Test + fun hermesRpcCallerCallDelegatesForWakeWithAccountOwnerAndNullProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-wake-888") + put("status", "done") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.operation.wake") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesRpcCaller.call( + RpcMethods.CONNECTORS_OPERATION_WAKE, + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-wake-888", + profile = null, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-wake-888") + } + assertEquals(expectedParams, params) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt new file mode 100644 index 00000000..0e4009dc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientConnectorsTest.kt @@ -0,0 +1,358 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams +import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams +import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientConnectorsTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeoutOrNull(5000) { + HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } + } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("op_id", "op-connect-123") + put("status", "running") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.connect") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_CONNECT, + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("connectors.connect", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + } + assertEquals(expectedParams, params) + assertEquals(true, (params?.get("reconnect") as? JsonPrimitive)?.booleanOrNull) + assertFalse(params?.containsKey("profile") ?: true) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallConnectorsPolicySetEncodesNestedChangeWithArrayAndReturnsRawServerJsonUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("revision", 5) + put("applied", true) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.policy.set") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val nestedChange = + buildJsonObject { + put("policy", "whitelist") + put( + "allowed_connectors", + buildJsonArray { + add(JsonPrimitive("notion")) + add(JsonPrimitive("linear")) + }, + ) + put( + "rules", + buildJsonObject { + put("require_auth", true) + }, + ) + } + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.CONNECTORS_POLICY_SET, + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "team-profile", + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.policy.set", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "team-profile") + } + assertEquals(expectedParams, params) + assertEquals(nestedChange, params?.get("change")) + assertEquals(serverResultJson, result) + } + + @Test + fun hermesRpcCallerCallDelegatesToHermesWsClientCallAndEncodesFrameParamsUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put( + "connectors", + buildJsonArray { + add( + buildJsonObject { + put("id", "c1") + put("status", "connected") + }, + ) + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "connectors.list") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesRpcCaller.call( + RpcMethods.CONNECTORS_LIST, + ConnectorsListParams( + owner = ConnectorOwner.session("s1"), + ), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals("connectors.list", (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + val expectedParams = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expectedParams, params) + assertEquals(serverResultJson, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt index 29bfaa84..e4fcd588 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientLoggingSecurityTest.kt @@ -11,7 +11,6 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch @@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.Collections -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger class HermesWsClientLoggingSecurityTest { private val capturedDebugLogs = Collections.synchronizedList(mutableListOf()) @@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest { CookieManager.setJarForTest(buildFakePersistentCookieJar()) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as AtomicBoolean).set(false) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as AtomicInteger).set(1) + HermesWsClient.connectedForTest.set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) HermesWsClient.rejectAllPending() HermesWsClient.disconnect(clearPendingMessages = true) @@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest { unmockkAll() } - private fun createWsListener(generation: Int? = null): WebSocketListener { - val targetGen = - generation ?: run { - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as AtomicInteger).get() - } - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - return constructor.newInstance(targetGen) as WebSocketListener - } + private fun createWsListener(generation: Int? = null): WebSocketListener = + HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get()) private fun attachMockSocket(): WebSocket { val socket = mockk(relaxed = true) every { socket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) return socket } - @Suppress("UNCHECKED_CAST") - private fun getPendingCalls(): ConcurrentHashMap { - val field = HermesWsClient::class.java.getDeclaredField("pendingCalls") - field.isAccessible = true - return field.get(HermesWsClient) as ConcurrentHashMap - } - - private fun getTimeoutJob(pendingCall: Any): Job? { - val field = pendingCall.javaClass.getDeclaredField("timeoutJob") - field.isAccessible = true - return field.get(pendingCall) as? Job - } - // ── Formatter unit tests ────────────────────────────────────────────────── @Test @@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest { val listener = createWsListener() val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider")) - val pendingCalls = getPendingCalls() - val reqId = pendingCalls.keys.firstOrNull() + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() assertNotNull("Pending call ID must exist", reqId) val sensitiveAuthUrl = @@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest { assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS)) val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001")) - val pendingCalls = getPendingCalls() - val reqId = pendingCalls.keys.firstOrNull() + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() assertNotNull("Pending call ID must exist", reqId) val errorJson = @@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest { fun `cancelling pending deferred cleans pending requests and cancels timeout job`() { attachMockSocket() val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture")) - val pendingCalls = getPendingCalls() - val entry = - pendingCalls.entries.firstOrNull { - it.value.javaClass.name - .contains("PendingCall") - } - assertNotNull("Pending call must be registered in pendingCalls map", entry) - val reqId = entry!!.key - val timeoutJob = getTimeoutJob(entry.value) + val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull() + assertNotNull("Pending call must be registered in pendingCalls map", reqId) + val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!) assertNotNull("Timeout job must be initialized", timeoutJob) assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive) @@ -451,7 +400,7 @@ class HermesWsClientLoggingSecurityTest { assertTrue("Deferred must be cancelled", deferred.isCancelled) assertFalse( "Pending call must be removed from pendingCalls map on cancellation", - pendingCalls.containsKey(reqId), + HermesWsClient.pendingCallIdsForTest().contains(reqId), ) assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt new file mode 100644 index 00000000..4d8aeec8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionControlTest.kt @@ -0,0 +1,281 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams +import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientSessionControlTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun sendRedirectPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.redirect") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.sendRedirect( + sessionId = "sess-red-1", + text = "change topic to testing", + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.redirect", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-red-1") + put("text", "change topic to testing") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id", "text"), params!!.keys) + } + + @Test + fun typedSendSessionInterruptPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.interrupt") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_INTERRUPT, + params = SessionInterruptParams(sessionId = "s1"), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.interrupt", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id"), params!!.keys) + } + + @Test + fun typedSendSessionSteerPutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.steer") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_STEER, + params = SessionCorrectionParams(sessionId = "s1", text = "steer towards plan B"), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.steer", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + put("text", "steer towards plan B") + } + assertEquals(expectedParams, params) + assertEquals(setOf("session_id", "text"), params!!.keys) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt new file mode 100644 index 00000000..edd2a00e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientSessionMiscTest.kt @@ -0,0 +1,584 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.PromptBtwParams +import com.m57.hermescontrol.data.ws.contract.PromptBtwResult +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionBranchParams +import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams +import com.m57.hermescontrol.data.ws.contract.SessionCompressParams +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SessionListParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientSessionMiscTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.list", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + // An empty params object is omitted on the wire (encodeDefaults=false), same as before the migration. + val params = frame["params"] as? JsonObject + assertTrue("session.list carries no params", params == null || params.isEmpty()) + } + + @Test + fun typedSendSessionListWithActiveProfileInjectsProfileOnWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.list") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-sessions") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_LIST, + params = SessionListParams, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("profile", "active-profile-sessions") + } + assertEquals(expectedParams, params) + } + + @Test + fun typedCallPromptBtwRoundTripsTaskIdAndReturnsPromptBtwResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.btw") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"task_id":"task-async-123","status":"queued"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: PromptBtwResult = + runBlocking { + HermesWsClient.call( + RpcMethods.PROMPT_BTW, + PromptBtwParams(sessionId = "sess-btw-call", text = "remember this detail"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("prompt.btw", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-btw-call") + put("text", "remember this detail") + } + assertEquals(expectedParams, frame["params"]) + assertEquals("task-async-123", result.taskId) + } + + @Test + fun typedCallSessionUsageReturnsRawResultJsonElementUntouched() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResultJson = + buildJsonObject { + put("prompt_tokens", 1024) + put("completion_tokens", 256) + put("total_tokens", 1280) + put("cost_usd", 0.015) + put( + "nested_stats", + buildJsonObject { + put("cache_read_tokens", 512) + put("reasoning_tokens", 64) + }, + ) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.usage") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_USAGE, + SessionIdParams(sessionId = "s1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.usage", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResultJson, result) + } + + @Test + fun typedCallSessionCompressWithCustomTimeoutPutsExactWirePayload() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.compress") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"status":"compressed"}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + method = RpcMethods.SESSION_COMPRESS, + params = SessionCompressParams(sessionId = "sess-long-comp", focusTopic = "deep-dive"), + timeoutMs = 300_000L, + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.compress", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-long-comp") + put("focus_topic", "deep-dive") + } + assertEquals(expectedParams, frame["params"]) + + val expectedResult = + buildJsonObject { + put("status", "compressed") + } + assertEquals(expectedResult, result) + } + + @Test + fun typedCallSessionBranchPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("new_session_id", "sess-branched-99") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.branch") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH, + SessionBranchParams(sessionId = "sess-orig-1", name = "branch-name"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-orig-1") + put("name", "branch-name") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionBranchWholePutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("whole_branch_session_id", "sess-whole-branched-101") + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.branch_whole") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_BRANCH_WHOLE, + SessionBranchWholeParams(sessionId = "sess-whole-orig-1", name = null), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.branch_whole", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-whole-orig-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } + + @Test + fun typedCallSessionContextBreakdownPutsExactWirePayloadAndDecodesPassthroughResult() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + val serverResult = + buildJsonObject { + put("system_prompt", 450) + put("messages", 2300) + put("tools", 800) + } + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.context_breakdown") { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":$serverResult}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result: JsonElement = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_CONTEXT_BREAKDOWN, + SessionIdParams(sessionId = "sess-bd-1"), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + assertEquals("session.context_breakdown", (frame["method"] as? JsonPrimitive)?.content) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-bd-1") + } + assertEquals(expectedParams, frame["params"]) + assertEquals(serverResult, result) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index e3df3221..7b5c1331 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -91,27 +91,15 @@ class HermesWsClientTest { every { DashboardSessionTokenRefresher.refresh() } returns null // Reset state - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (statusField.get(HermesWsClient) as MutableStateFlow).value = ConnectionStatus.DISCONNECTED - - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue).clear() + HermesWsClient.connectedForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED + HermesWsClient.messageQueueForTest.clear() HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.setAppForeground(true) - val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") - acceptQueuedMessagesField.isAccessible = true - (acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.acceptQueuedMessagesForTest.set(true) } @After @@ -350,10 +338,8 @@ class HermesWsClientTest { val socket = mockk() every { socket.send(any()) } returns true every { socket.close(any(), any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } - val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } - socketField.set(HermesWsClient, socket) - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) val sent = HermesWsClient.respondToServerRequest( @@ -384,10 +370,8 @@ class HermesWsClientTest { val socket = mockk() every { socket.send(any()) } returns true every { socket.close(any(), any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } - val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } - socketField.set(HermesWsClient, socket) - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported")) verify { @@ -416,29 +400,14 @@ class HermesWsClientTest { val staleSocket = mockk(relaxed = true) every { staleSocket.send(any()) } returns false - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - (statusField.get(HermesWsClient) as MutableStateFlow).value = ConnectionStatus.CONNECTED + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertFalse(HermesWsClient.isConnected) assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) assertEquals(1, queue.size) @@ -446,8 +415,7 @@ class HermesWsClientTest { @Test fun testSendRegistersRequestIdWhileOutboundLockHeld() { - val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true } - val outboundLock = lockField.get(HermesWsClient) + val outboundLock = HermesWsClient.outboundLockForTest var callbackHeldLock = false HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) }) @@ -458,25 +426,13 @@ class HermesWsClientTest { @Test fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) val socket = mockk(relaxed = true) every { socket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val oldGeneration = generation.get() - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val staleListener = constructor.newInstance(oldGeneration) as WebSocketListener + HermesWsClient.webSocketForTest = socket + val oldGeneration = HermesWsClient.connectionGenerationForTest.get() + val staleListener = HermesWsClient.createListenerForTest(oldGeneration) var replacementOpens = 0 val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1")) @@ -486,10 +442,10 @@ class HermesWsClientTest { verify(exactly = 1) { socket.cancel() } assertEquals(1, replacementOpens) - assertEquals(oldGeneration + 1, generation.get()) + assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get()) assertFalse(HermesWsClient.isConnected) assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value) - assertEquals(null, socketField.get(HermesWsClient)) + assertEquals(null, HermesWsClient.webSocketForTest) assertTrue(deferred.isCompleted) } @@ -497,18 +453,10 @@ class HermesWsClientTest { fun testNetworkLossPreservesAuthExpiredStatus() = runBlocking { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED val socket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket HermesWsClient.reconnectForNetworkChange(false) HermesWsClient.reconnectForNetworkChange(true) @@ -520,22 +468,13 @@ class HermesWsClientTest { @Test fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(true) val oldSocket = mockk(relaxed = true) every { oldSocket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, oldSocket) + HermesWsClient.webSocketForTest = oldSocket HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true)) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) HermesWsClient.reconnectForNetworkChange(false) @@ -545,13 +484,7 @@ class HermesWsClientTest { HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ } val replacementSocket = mockk(relaxed = true) every { replacementSocket.send(any()) } returns true - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val replacementListener = constructor.newInstance(generation.get()) as WebSocketListener + val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get()) replacementListener.onOpen(replacementSocket, mockk(relaxed = true)) assertEquals(1, replacementOpens) @@ -563,18 +496,10 @@ class HermesWsClientTest { @Test fun testNetworkChangePreservesAuthExpiredSocket() { every { AuthManager.isAutoReconnect() } returns true - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED val socket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket HermesWsClient.reconnectForNetworkChange() @@ -588,20 +513,13 @@ class HermesWsClientTest { every { AuthManager.isAutoReconnect() } returns true every { NetworkMonitor.isConnected } returns MutableStateFlow(true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.currentBackoffForTest = 1_000L - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 1_000L) + HermesWsClient.scheduleReconnectForTest() + HermesWsClient.scheduleReconnectForTest() - val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect") - reconnectMethod.isAccessible = true - reconnectMethod.invoke(HermesWsClient) - reconnectMethod.invoke(HermesWsClient) - - assertEquals(2_000L, backoffField.getLong(HermesWsClient)) + assertEquals(2_000L, HermesWsClient.currentBackoffForTest) } @Test @@ -610,35 +528,21 @@ class HermesWsClientTest { every { AuthManager.isAutoReconnect() } returns true every { NetworkMonitor.isConnected } returns MutableStateFlow(true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectedForTest.set(false) + HermesWsClient.currentBackoffForTest = 0L - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 0L) - - val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob") - reconnectJobField.isAccessible = true val replacementJob = mockk(relaxed = true) - val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock") - lockField.isAccessible = true - val lock = lockField.get(HermesWsClient) - val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect") - reconnectMethod.isAccessible = true + val lock = HermesWsClient.outboundLockForTest synchronized(lock) { - reconnectMethod.invoke(HermesWsClient) - reconnectJobField.set(HermesWsClient, replacementJob) + HermesWsClient.scheduleReconnectForTest() + HermesWsClient.reconnectJobForTest = replacementJob } Thread.sleep(100) - assertSame(replacementJob, reconnectJobField.get(HermesWsClient)) + assertSame(replacementJob, HermesWsClient.reconnectJobForTest) } @Test @@ -646,23 +550,15 @@ class HermesWsClientTest { val staleSocket = mockk(relaxed = true) every { staleSocket.send(any()) } returns false - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) HermesWsClient.send( WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)), ) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) io.mockk.verify(exactly = 0) { staleSocket.cancel() } } @@ -674,10 +570,7 @@ class HermesWsClientTest { mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)), ) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) } @@ -687,24 +580,14 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returns false every { pressuredSocket.queueSize() } returns 1024L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = pressuredSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) io.mockk.verify(exactly = 0) { pressuredSocket.cancel() } - val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob") - drainJobField.isAccessible = true - assertNotNull(drainJobField.get(HermesWsClient)) + assertNotNull(HermesWsClient.outboundDrainJobForTest) } @Test @@ -717,35 +600,18 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returns false every { pressuredSocket.queueSize() } returns 1024L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true - backoffField.setLong(HermesWsClient, 1_000L) + HermesWsClient.webSocketForTest = pressuredSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.currentBackoffForTest = 1_000L HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onFailure(pressuredSocket, IOException("connection lost"), null) - assertEquals(2_000L, backoffField.getLong(HermesWsClient)) + assertEquals(2_000L, HermesWsClient.currentBackoffForTest) } @Test @@ -754,38 +620,22 @@ class HermesWsClientTest { every { pressuredSocket.send(any()) } returnsMany listOf(true, false) every { pressuredSocket.queueSize() } returns 1024L - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}") queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}") - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.webSocketForTest = pressuredSocket - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, pressuredSocket) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onOpen(pressuredSocket, mockk(relaxed = true)) assertFalse(HermesWsClient.isConnected) assertEquals(1, queue.size) assertTrue(queue.peek()?.contains("\"2\"") == true) io.mockk.verify(exactly = 0) { pressuredSocket.cancel() } - val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob") - drainJobField.isAccessible = true - assertNotNull(drainJobField.get(HermesWsClient)) + assertNotNull(HermesWsClient.outboundDrainJobForTest) } @Test @@ -794,29 +644,15 @@ class HermesWsClientTest { val currentSocket = mockk(relaxed = true) every { currentSocket.send(any()) } returns true - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, currentSocket) + HermesWsClient.webSocketForTest = currentSocket + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(2) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2) - - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}") - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val staleListener = constructor.newInstance(1) as WebSocketListener - val currentListener = constructor.newInstance(2) as WebSocketListener + val staleListener = HermesWsClient.createListenerForTest(1) + val currentListener = HermesWsClient.createListenerForTest(2) val response = mockk(relaxed = true) staleListener.onOpen(staleSocket, response) @@ -835,40 +671,24 @@ class HermesWsClientTest { false } - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, staleSocket) - - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.webSocketForTest = staleSocket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertTrue(queue.isEmpty()) assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) } @Test fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() { - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertEquals(1, queue.size) HermesWsClient.disconnect() @@ -886,16 +706,11 @@ class HermesWsClientTest { @Test fun testRejectAllPendingRemovesQueuedAwaitedRpc() { - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1")) - val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue") - queueField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue + val queue = HermesWsClient.messageQueueForTest assertEquals(1, queue.size) HermesWsClient.rejectAllPending() @@ -910,26 +725,12 @@ class HermesWsClientTest { every { socket.send(any()) } returns false every { socket.queueSize() } returns 0L - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket + HermesWsClient.connectedForTest.set(true) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) - - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onClosing(socket, 4401, "expired") HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello")) @@ -941,21 +742,10 @@ class HermesWsClientTest { @Test fun testSocketFailureDoesNotOverwriteAuthExpired() { val socket = mockk(relaxed = true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED + val listener = HermesWsClient.createListenerForTest(1) listener.onFailure(socket, IOException("network changed"), null) @@ -965,18 +755,10 @@ class HermesWsClientTest { @Test fun testExplicitConnectRetriesAfterAuthExpired() = runBlocking { - val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus") - statusField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val status = statusField.get(HermesWsClient) as MutableStateFlow - status.value = ConnectionStatus.AUTH_EXPIRED - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED + HermesWsClient.connectedForTest.set(true) val oldSocket = mockk(relaxed = true) - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, oldSocket) + HermesWsClient.webSocketForTest = oldSocket mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) HermesWsClient.connect() @@ -1008,17 +790,16 @@ class HermesWsClientTest { HermesWsClient.disconnect(clearPendingMessages = true) mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger - val disconnectedGeneration = generation.get() + val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get() val replacementConnect = thread { HermesWsClient.connect() } val generationDeadline = System.currentTimeMillis() + 5_000 - while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) { + while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration && + System.currentTimeMillis() < generationDeadline + ) { Thread.sleep(10) } - assertTrue(generation.get() > disconnectedGeneration) - val currentGeneration = generation.get() + assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration) + val currentGeneration = HermesWsClient.connectionGenerationForTest.get() releaseRefresh.countDown() staleConnect.join(5_000) @@ -1029,7 +810,7 @@ class HermesWsClientTest { assertFalse(staleConnect.isAlive) assertFalse(replacementConnect.isAlive) - assertEquals(currentGeneration, generation.get()) + assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get()) assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value) verify(exactly = 0) { AuthManager.setToken("stale-token") } } @@ -1441,12 +1222,10 @@ class HermesWsClientTest { runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } // After connect, backoff should be back to initial - val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff") - backoffField.isAccessible = true assertEquals( "Backoff should reset to initial after successful connect", 1000L, - backoffField.getLong(HermesWsClient), + HermesWsClient.currentBackoffForTest, ) } @@ -1999,26 +1778,11 @@ class HermesWsClientTest { fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() { // Issue #1163: exercise the real listener, including the early duplicate return. val socket = mockk(relaxed = true) - val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalClose.isAccessible = true - (intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) - val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generation.isAccessible = true - val constructor = - Class - .forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - .declaredConstructors - .single() - constructor.isAccessible = true - val listener = - constructor.newInstance( - (generation.get(HermesWsClient) as AtomicInteger).get(), - ) as WebSocketListener + HermesWsClient.intentionalCloseForTest.set(false) + val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get()) // Directly install the reflection-built listener so onMessage runs even // though this test never performed a real OkHttp connect. - val socketField = HermesWsClient::class.java.getDeclaredField("webSocket") - socketField.isAccessible = true - socketField.set(HermesWsClient, socket) + HermesWsClient.webSocketForTest = socket mockkObject(EventParser) val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]") @@ -2039,18 +1803,10 @@ class HermesWsClientTest { @Test fun testTerminalCloseCode4403SetsAuthExpired() { val socket = mockk(relaxed = true) - val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose") - intentionalCloseField.isAccessible = true - (intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.intentionalCloseForTest.set(false) + HermesWsClient.connectionGenerationForTest.set(1) - val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration") - generationField.isAccessible = true - (generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1) - - val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl") - val constructor = listenerClass.declaredConstructors.single() - constructor.isAccessible = true - val listener = constructor.newInstance(1) as WebSocketListener + val listener = HermesWsClient.createListenerForTest(1) listener.onClosing(socket, 4403, "forbidden origin") assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value) @@ -2182,25 +1938,22 @@ class HermesWsClientTest { @Test fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() { - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + HermesWsClient.connectedForTest.set(true) val failureLatch = CountDownLatch(1) HermesWsClient.probeLivenessOnTransportChange( + timeoutMs = 50L, onFailureAction = { failureLatch.countDown() }, ) - assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS)) + assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(3, TimeUnit.SECONDS)) } @Test fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() { - val connectedField = HermesWsClient::class.java.getDeclaredField("connected") - connectedField.isAccessible = true - (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false) + HermesWsClient.connectedForTest.set(false) var failureInvoked = false HermesWsClient.probeLivenessOnTransportChange( diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt new file mode 100644 index 00000000..b8c7d01b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedCallTest.kt @@ -0,0 +1,532 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientTypedCallTest { + private lateinit var mockWebServer: MockWebServer + + @Serializable + private data class ScopedDummyParams( + val query: String, + val profile: String? = null, + ) + + @Serializable + private data class ScopedDummyResult( + val status: String? = null, + ) + + private val sessionListMethod = + RpcMethod( + name = WsMethods.SESSION_LIST, + params = ScopedDummyParams.serializer(), + result = ScopedDummyResult.serializer(), + ) + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun callPutsExactFrameOnTheWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_EVENTS_SINCE) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"latest_seq":7,"truncated":false}}""", + ) + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 7), + ) + } + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertEquals(WsMethods.SESSION_EVENTS_SINCE, (frame["method"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "s1") + put("last_seen", 7) + } + assertEquals(expectedParams, params) + + val lastSeenElement = params!!["last_seen"] as? JsonPrimitive + assertNotNull(lastSeenElement) + assertTrue(lastSeenElement!!.isString.not()) + assertEquals(7, lastSeenElement.content.toInt()) + } + + @Test + fun callDecodesTypedResultOnSuccess() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep-42","latest_seq":100,"truncated":true,"events":[{"type":"test"}]}}""", + ) + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val result = + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 5), + ) + } + + assertEquals("ep-42", result.epoch) + assertEquals(100, result.latestSeq) + assertEquals(true, result.truncated) + assertEquals(1, result.events?.size) + } + + @Test + fun callThrowsHermesRpcExceptionOnErrorResponse() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send( + """{"jsonrpc":"2.0","id":"$id","error":{"code":4001,"message":"Access denied: session ownership required","data":{"reason":"NOT_OWNER"}}}""", + ) + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + try { + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + ) + } + fail("Expected HermesRpcException to be thrown") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(4001, e.code) + assertEquals("Access denied: session ownership required", e.message) + val dataObj = e.data as? JsonObject + assertNotNull(dataObj) + assertEquals("NOT_OWNER", (dataObj!!["reason"] as? JsonPrimitive)?.content) + } + } + + @Test + fun profileScopingInjectsActiveProfileWhenSet() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("work-profile") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertEquals("work-profile", (params!!["profile"] as? JsonPrimitive)?.content) + assertEquals("active", (params["query"] as? JsonPrimitive)?.content) + } + + @Test + fun profileScopingPreservesExplicitProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("work-profile") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active", profile = "explicit-override"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertEquals("explicit-override", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun profileScopingDoesNotInjectWhenNoActiveProfile() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == WsMethods.SESSION_LIST) { + frameRef.set(json) + captured.countDown() + val id = (json["id"] as? JsonPrimitive)?.content ?: "1" + webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""") + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + HermesWsClient.call( + sessionListMethod, + ScopedDummyParams(query = "active"), + ) + } + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + assertFalse("Profile key must not be present when no active profile", params!!.containsKey("profile")) + } + + @Test + fun callFailsOnTimeoutWhenServerDoesNotReply() { + val opened = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + // Do not reply + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + val thrown = + runCatching { + runBlocking { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + timeoutMs = 50L, + ) + } + }.exceptionOrNull() + + assertTrue( + "Timeout must surface as HermesRpcException, got: $thrown", + thrown is HermesWsClient.HermesRpcException, + ) + val rpc = thrown as HermesWsClient.HermesRpcException + assertEquals(-1, rpc.code) + assertTrue(rpc.message.orEmpty().contains("timed out")) + } + + @Test + fun cancellingCallingCoroutineCleansPendingCall() { + val opened = CountDownLatch(1) + val requestReceived = CountDownLatch(1) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + requestReceived.countDown() + // Do not reply, keeping the call pending + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + runBlocking { + val callJob = + async(Dispatchers.IO) { + HermesWsClient.call( + RpcMethods.SESSION_EVENTS_SINCE, + SessionEventsSinceParams(sessionId = "s1", lastSeen = 1), + timeoutMs = 10_000L, + ) + } + + assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS)) + + assertEquals( + "Should have exactly 1 pending call while awaiting", + 1, + HermesWsClient.pendingCallIdsForTest().size, + ) + + callJob.cancel() + + // Wait briefly for cancellation cleanup to execute + withTimeout(2000) { + while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) { + kotlinx.coroutines.delay(20) + } + } + + assertTrue( + "Pending calls should be empty after caller coroutine is cancelled", + HermesWsClient.pendingCallIdsForTest().isEmpty(), + ) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt new file mode 100644 index 00000000..655e174d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTypedSendTest.kt @@ -0,0 +1,484 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.CookieManager +import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher +import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar +import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionCreateParams +import com.m57.hermescontrol.data.ws.contract.SessionResumeParams +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class HermesWsClientTypedSendTest { + private lateinit var mockWebServer: MockWebServer + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + mockWebServer = MockWebServer() + mockWebServer.start() + mockkObject(AuthManager) + every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") + every { AuthManager.isAutoReconnect() } returns false + every { AuthManager.getSessionCookie() } returns null + every { AuthManager.serverStore } returns + mockk().also { + every { it.getLatestState() } returns + com.m57.hermescontrol.data.config + .ServerStoreState() + } + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + mockkObject(DashboardSessionTokenRefresher) + coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null + every { DashboardSessionTokenRefresher.refresh() } returns null + + HermesWsClient.disconnect(clearPendingMessages = true) + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.setAppForeground(true) + AuthManager.resetAuthStateForTest() + } + + @After + fun tearDown() { + AuthManager.resetAuthStateForTest() + HermesWsClient.releaseExternalActivityConnectionLease() + HermesWsClient.releaseBackgroundConnectionLease() + HermesWsClient.disconnect(clearPendingMessages = true) + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } + } + runCatching { mockWebServer.shutdown() } + unmockkAll() + } + + private fun connectClient() { + HermesWsClient.connect() + runBlocking { + withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } + } + } + + @Test + fun typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = SessionResumeParams(sessionId = "sess-1", omitMessages = true), + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + + assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc")) + assertEquals("session.resume", (frame["method"] as? JsonPrimitive)?.content) + assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content) + + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-1") + put("omit_messages", true) + } + assertEquals(expectedParams, params) + } + + @Test + fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-1") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = SessionResumeParams(sessionId = "sess-active", omitMessages = true), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-active") + put("omit_messages", true) + put("profile", "active-profile-1") + } + assertEquals(expectedParams, params) + assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.resume") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-1") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_RESUME, + params = + SessionResumeParams( + sessionId = "sess-active", + omitMessages = true, + profile = "explicit-resume-profile", + ), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-active") + put("omit_messages", true) + put("profile", "explicit-resume-profile") + } + assertEquals(expectedParams, params) + assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "session.create") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + AuthManager.setActiveProfileId("active-profile-2") + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.send( + method = RpcMethods.SESSION_CREATE, + params = SessionCreateParams(profile = "bot-1"), + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("profile", "bot-1") + } + assertEquals(expectedParams, params) + assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content) + } + + @Test + fun sendMessagePutsSessionIdAndTextOnTheWire() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + var onSentId: String? = null + val returnedId = + HermesWsClient.sendMessage( + sessionId = "sess-msg-1", + text = "plain message", + onSent = { id -> onSentId = id }, + ) + + assertTrue(returnedId.isNotBlank()) + assertEquals(returnedId, onSentId) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-1") + put("text", "plain message") + } + assertEquals(expectedParams, params) + assertFalse(params!!.containsKey("queued")) + } + + @Test + fun sendMessageWithQueuedTrueAddsQueuedTrue() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.sendMessage( + sessionId = "sess-msg-2", + text = "queued message", + queued = true, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-2") + put("text", "queued message") + put("queued", true) + } + assertEquals(expectedParams, params) + val queuedElement = params!!["queued"] as? JsonPrimitive + assertTrue(queuedElement != null && queuedElement.isString.not()) + assertEquals("true", queuedElement!!.content) + } + + @Test + fun sendMessageWithQueuedFalseHasNoQueuedKey() { + val opened = CountDownLatch(1) + val captured = CountDownLatch(1) + val frameRef = AtomicReference(null) + + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) = opened.countDown() + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return + val name = (json["method"] as? JsonPrimitive)?.content + if (name == "prompt.submit") { + frameRef.set(json) + captured.countDown() + } + } + }, + ), + ) + + connectClient() + assertTrue(opened.await(5, TimeUnit.SECONDS)) + + HermesWsClient.sendMessage( + sessionId = "sess-msg-3", + text = "un为其queued message", + queued = false, + ) + + assertTrue(captured.await(5, TimeUnit.SECONDS)) + val frame = requireNotNull(frameRef.get()) + val params = frame["params"] as? JsonObject + assertNotNull(params) + + val expectedParams = + buildJsonObject { + put("session_id", "sess-msg-3") + put("text", "un为其queued message") + } + assertEquals(expectedParams, params) + assertFalse(params!!.containsKey("queued")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt index 2bff3c55..06c5e2d4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ModelOptionsRepositoryTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -21,7 +22,7 @@ class ModelOptionsRepositoryTest { ModelOptionsRepository( connected = { true }, request = { params -> - assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params) + assertEquals(ModelOptionsParams(refresh = true, includeUnconfigured = false), params) Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""") }, rest = { error("REST must not run") }, diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt index e861aa1a..3a7dabd8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginManageRepositoryTest.kt @@ -16,6 +16,9 @@ import org.junit.Assert.fail import org.junit.Test class PluginManageRepositoryTest { + private fun pluginRepo(handler: suspend (String, Map) -> Any?) = + PluginManageRepository(fakeCaller(handler)) + // CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list. @Test fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() = @@ -44,7 +47,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -80,7 +83,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockPayload @@ -98,19 +101,19 @@ class PluginManageRepositoryTest { fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() = runTest { val emptyObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{}") } assertTrue(emptyObjRepo.listPlugins().isEmpty()) val nonObjRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("[]") } assertTrue(nonObjRepo.listPlugins().isEmpty()) val nullRepo = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> null } assertTrue(nullRepo.listPlugins().isEmpty()) @@ -139,7 +142,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -156,7 +159,7 @@ class PluginManageRepositoryTest { assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod) assertEquals("settings", capturedParams["action"]) assertEquals("camofox", capturedParams["key"]) - assertEquals(JsonObject(valuesToSave), capturedParams["values"]) + assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"]) assertFalse(capturedParams.containsKey("profile")) assertTrue(result.ok) @@ -186,7 +189,7 @@ class PluginManageRepositoryTest { ) val repository = - PluginManageRepository { method, params -> + pluginRepo { method, params -> capturedMethod = method capturedParams = params mockResponse @@ -204,7 +207,7 @@ class PluginManageRepositoryTest { mapOf( "action" to "settings", "key" to "camofox", - "values" to JsonObject(valuesToSave), + "values" to mapOf("port" to 8080), "profile" to "staging", ), capturedParams, @@ -222,7 +225,7 @@ class PluginManageRepositoryTest { fun `saveSettings throws SerializationException when response is malformed`() = runTest { val repository = - PluginManageRepository { _, _ -> + pluginRepo { _, _ -> OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}") } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt index 81a6d21f..1c1f6e50 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt @@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue import org.junit.Test class PluginRemovalRepositoryTest { + private fun removalRepo(handler: suspend (String, Map) -> Any?) = + PluginRemovalRepository(fakeCaller(handler)) + @Test fun `remove sends scoped gateway action and decodes success`() = runTest { var method = "" var params: Map = emptyMap() val repository = - PluginRemovalRepository { requestedMethod, requestedParams -> + removalRepo { requestedMethod, requestedParams -> method = requestedMethod params = requestedParams Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") @@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest { @Test fun `refusal is a typed failure and missing ok cannot masquerade as success`() = runTest { - val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } + val refused = removalRepo { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } assertFalse(refused.remove("builtin").ok) assertEquals("bundled plugin", refused.remove("builtin").error) - val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } + val malformed = removalRepo { _, _ -> mapOf("name" to "demo") } try { malformed.remove("demo") throw AssertionError("Missing ok must fail decoding") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt index f17be50b..a21f80aa 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt @@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue import org.junit.Test class ProjectsSourceTest { + private fun projectsSource( + connected: Boolean = true, + handler: suspend (String, Map) -> Any?, + ) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler)) + @Test fun `decodes the untyped rpc map`() = runTest { var calledMethod: String? = null val source = - HermesProjectsSource(isConnected = { true }) { method, _ -> + projectsSource { method, _ -> calledMethod = method mapOf( "projects" to @@ -49,13 +54,10 @@ class ProjectsSourceTest { runTest { var calls = 0 val source = - HermesProjectsSource( - rpcRequest = { _, _ -> - calls++ - mapOf("projects" to emptyList()) - }, - isConnected = { false }, - ) + projectsSource(connected = false) { _, _ -> + calls++ + mapOf("projects" to emptyList()) + } assertNull(source.fetchProjects()) assertEquals("a disconnected fetch must not queue an RPC", 0, calls) @@ -65,10 +67,10 @@ class ProjectsSourceTest { fun `rpc failure or unexpected payload yields null`() = runTest { assertNull( - HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(), + projectsSource { _, _ -> error("method not found") }.fetchProjects(), ) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects()) - assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects()) + assertNull(projectsSource { _, _ -> "nope" }.fetchProjects()) + assertNull(projectsSource { _, _ -> null }.fetchProjects()) } @Test @@ -76,9 +78,7 @@ class ProjectsSourceTest { runTest { val thrown = runCatching { - HermesProjectsSource( - isConnected = { true }, - ) { _, _ -> throw CancellationException("gone") }.fetchProjects() + projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects() }.exceptionOrNull() assertTrue(thrown is CancellationException) } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt new file mode 100644 index 00000000..3c9fae18 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RawRpcParamsRatchetTest.kt @@ -0,0 +1,318 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.lang.reflect.Modifier + +/** + * Ratchet guard preventing new raw Map-param request/send call sites for contract RPC methods (#1375). + * + * Background: + * Issue #1375 migrates raw `HermesWsClient.request(...)`/`.send(...)`/wrapper call sites that pass hand-built + * `Map` params to typed `HermesWsClient.call(RpcMethods.X, params)`. + * + * This test asserts that the set/multiset of raw call sites in `app/src/main/java` matches [BASELINE]. + * - Adding a new raw call site fails CI: author must migrate to `HermesWsClient.call(RpcMethods.X, params)` or, + * for a not-yet-migrated method, consciously add the entry to [BASELINE]. + * - Migrating a call site to typed `call(...)` makes the baseline entry stale, failing CI until removed. + * + * Scanner limitations / blind spots: + * 1. Indirect wrappers: does not catch wrappers that alias method names through intermediate variables or functions + * (e.g. `val m = WsMethods.FOO; send(m)` or helper functions accepting dynamic strings). + * 2. Raw string literals: does not catch direct string literals bypassing `WsMethods` (e.g. `send("session.list")`). + * 3. Dynamic dispatch: reflection or computed method identifiers are invisible to the static regex scanner. + * 4. Custom parameter maps wrapped in separate helper classes or extensions not calling standard request/send. + */ +class RawRpcParamsRatchetTest { + private val wsMethodConstants: Map = + WsMethods::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .associate { it.name to it.get(null) as String } + + @Test + fun rawRpcCallSitesMatchBaselineRatchet() { + val rootDir = resolveMainJavaRoot() + val contractMethodNames = GatewayContract.methods + val contractConstantNames = + wsMethodConstants + .filter { (_, methodStr) -> methodStr in contractMethodNames } + .keys + + val scannedSites = mutableListOf() + rootDir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" } + .forEach { file -> + val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath + val code = stripComments(file.readText()) + val sites = scanRawRpcCalls(code, contractConstantNames) + for (site in sites) { + scannedSites.add("$relativePath::$site") + } + } + + scannedSites.sort() + + val staleEntries = BASELINE.filter { it !in scannedSites } + val newEntries = scannedSites.filter { it !in BASELINE } + + val failureMessage = + buildString { + if (newEntries.isNotEmpty()) { + appendLine( + "raw call added: migrate to HermesWsClient.call(RpcMethods...) or, for a not-yet-migrated method, add to BASELINE:", + ) + newEntries.forEach { appendLine(" + $it") } + } + if (staleEntries.isNotEmpty()) { + appendLine("stale baseline entry: remove it (call site was migrated or removed):") + staleEntries.forEach { appendLine(" - $it") } + } + } + + assertEquals( + failureMessage.trimEnd(), + BASELINE, + scannedSites, + ) + } + + @Test + fun scannerSelfTestDetectsRawCallsAndIgnoresSafeUsages() { + val contractConstants = setOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT") + + val sampleCode = + """ + // Commented call should be ignored: + // wsClient.request(WsMethods.SESSION_LIST, emptyMap()) + /* Multi-line comment: + HermesWsClient.send(WsMethods.SESSION_LIST) + */ + + // When branch routing should be ignored: + when (method) { + WsMethods.SESSION_LIST -> handleList() + WsMethods.SESSION_CREATE -> handleCreate() + } + + // Comparison checks should be ignored: + if (method == WsMethods.SESSION_LIST) return + if (method != WsMethods.PROMPT_SUBMIT) return + + // Tracking helpers should be ignored: + trackRequest(id, WsMethods.SESSION_LIST) + trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) + + // Positional single-line call: + HermesWsClient.request(WsMethods.SESSION_LIST, emptyMap()) + + // Named argument call across lines: + request( + method = WsMethods.SESSION_CREATE, + params = mapOf("profile" to "test"), + ) + + // Positional multi-line call: + wsClient.send( + WsMethods.PROMPT_SUBMIT, + mapOf("text" to "hello"), + ) + """.trimIndent() + + val cleaned = stripComments(sampleCode) + val detected = scanRawRpcCalls(cleaned, contractConstants) + + assertEquals( + listOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT"), + detected, + ) + } + + companion object { + internal fun resolveMainJavaRoot(): File { + val userDir = File(System.getProperty("user.dir") ?: ".") + val candidates = + listOf( + File(userDir, "app/src/main/java/com/m57/hermescontrol"), + File(userDir, "src/main/java/com/m57/hermescontrol"), + File(userDir, "../app/src/main/java/com/m57/hermescontrol"), + ) + return candidates.firstOrNull { it.isDirectory } + ?: error("Could not resolve app/src/main/java/com/m57/hermescontrol from $userDir") + } + + internal fun stripComments(text: String): String { + val result = StringBuilder(text.length) + var i = 0 + val n = text.length + var inString = false + var inTripleString = false + + while (i < n) { + if (!inString && !inTripleString) { + if (text.startsWith("\"\"\"", i)) { + inTripleString = true + result.append("\"\"\"") + i += 3 + } else if (text[i] == '"') { + inString = true + result.append('"') + i++ + } else if (text.startsWith("//", i)) { + var j = text.indexOf('\n', i) + if (j == -1) j = n + while (i < j) { + result.append(' ') + i++ + } + } else if (text.startsWith("/*", i)) { + var depth = 1 + var j = i + 2 + while (j < n && depth > 0) { + if (text.startsWith("/*", j)) { + depth++ + j += 2 + } else if (text.startsWith("*/", j)) { + depth-- + j += 2 + } else { + j++ + } + } + while (i < j) { + result.append(if (text[i] == '\n') '\n' else ' ') + i++ + } + } else { + result.append(text[i]) + i++ + } + } else if (inTripleString) { + if (text.startsWith("\"\"\"", i)) { + inTripleString = false + result.append("\"\"\"") + i += 3 + } else { + result.append(text[i]) + i++ + } + } else { + if (text[i] == '\\' && i + 1 < n) { + result.append(text[i]).append(text[i + 1]) + i += 2 + } else if (text[i] == '"') { + inString = false + result.append('"') + i++ + } else { + result.append(text[i]) + i++ + } + } + } + return result.toString() + } + + internal fun scanRawRpcCalls( + cleanCode: String, + contractConstants: Set, + ): List { + val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""") + val results = mutableListOf() + + for (match in regex.findAll(cleanCode)) { + val constantName = match.groupValues[1] + if (constantName !in contractConstants) continue + + val startIndex = match.range.first + if (isMethodArgumentOfRpcCall(cleanCode, startIndex)) { + results.add(constantName) + } + } + return results + } + + private fun isMethodArgumentOfRpcCall( + cleanCode: String, + matchStart: Int, + ): Boolean { + var i = matchStart - 1 + while (i >= 0 && cleanCode[i].isWhitespace()) { + i-- + } + + var isNamedMethodParam = false + if (i >= 0 && cleanCode[i] == '=') { + var j = i - 1 + while (j >= 0 && cleanCode[j].isWhitespace()) { + j-- + } + val identEnd = j + 1 + while (j >= 0 && cleanCode[j].isLetterOrDigit()) { + j-- + } + val paramName = cleanCode.substring(j + 1, identEnd) + if (paramName == "method") { + isNamedMethodParam = true + i = j + while (i >= 0 && cleanCode[i].isWhitespace()) { + i-- + } + } else { + return false + } + } + + if (i < 0) return false + val delimiter = cleanCode[i] + if (delimiter != '(' && delimiter != ',') return false + if (delimiter == ',' && !isNamedMethodParam) { + // Positional method argument in Kotlin RPC calls is the first argument directly following '(' + return false + } + + var parenDepth = 0 + var k = i + while (k >= 0) { + val c = cleanCode[k] + if (c == ')') { + parenDepth++ + } else if (c == '(') { + if (parenDepth > 0) { + parenDepth-- + } else { + var m = k - 1 + while (m >= 0 && cleanCode[m].isWhitespace()) { + m-- + } + val endFn = m + 1 + while (m >= 0 && + (cleanCode[m].isLetterOrDigit() || cleanCode[m] == '.' || cleanCode[m] == '_') + ) { + m-- + } + val fnName = cleanCode.substring(m + 1, endFn) + val lowerFn = fnName.lowercase() + if (fnName in setOf("if", "while", "for", "when")) return false + if (lowerFn.contains("track")) return false + return true + } + } else if (c == '{' || c == '}' || c == ';') { + return false + } + k-- + } + return false + } + + /** + * Multiset of remaining raw RPC call sites for contract methods in `app/src/main/java/com/m57/hermescontrol/`. + * Empty: every contract method goes through `RpcMethods`. Keep it empty; new contract methods must be typed. + * The untyped `request`/`send` stay module-internal for `gateway.ping` and the legacy `*.respond` fallbacks. + */ + val BASELINE: List = emptyList() + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt new file mode 100644 index 00000000..2b3936b3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ReconnectPolicyTest.kt @@ -0,0 +1,98 @@ +package com.m57.hermescontrol.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReconnectPolicyTest { + @Test + fun defaultInitialBackoffIs1000() { + val policy = ReconnectPolicy() + assertEquals(1000L, policy.currentBackoff) + } + + @Test + fun nextDelayMsUsesInjectedRandomAndFloorsAt300() { + // With random = 0.0, calculated delay is 0, floored to 300 (or ceiling if ceiling < 300) + val minPolicy = ReconnectPolicy(random = { 0.0 }) + assertEquals(300L, minPolicy.nextDelayMs()) + + // With random = 0.5 and ceiling = 1000, 0.5 * 1000 = 500 + val midPolicy = ReconnectPolicy(random = { 0.5 }) + assertEquals(500L, midPolicy.nextDelayMs()) + + // With random nearly 1.0 (0.999), 0.999 * 1000 = 999 + val maxPolicy = ReconnectPolicy(random = { 0.999 }) + assertEquals(999L, maxPolicy.nextDelayMs()) + } + + @Test + fun nextDelayMsRespectsCeilingCapsBelowFloorAndZero() { + // Ceiling <= 0 results in 0 + val zeroPolicy = ReconnectPolicy(random = { 0.5 }) + zeroPolicy.currentBackoff = 0L + assertEquals(0L, zeroPolicy.nextDelayMs()) + + val negativePolicy = ReconnectPolicy(random = { 0.5 }) + negativePolicy.currentBackoff = -100L + assertEquals(0L, negativePolicy.nextDelayMs()) + + // When ceiling is 100 (which is < 300 floor), delay should be capped to ceiling (100) + val lowCeilingPolicy = ReconnectPolicy(random = { 0.0 }) + lowCeilingPolicy.currentBackoff = 100L + assertEquals(100L, lowCeilingPolicy.nextDelayMs()) + } + + @Test + fun advanceDoublesBackoffUpToCapOf30000() { + val policy = ReconnectPolicy() + assertEquals(1000L, policy.currentBackoff) + + policy.advance() + assertEquals(2000L, policy.currentBackoff) + + policy.advance() + assertEquals(4000L, policy.currentBackoff) + + policy.advance() + assertEquals(8000L, policy.currentBackoff) + + policy.advance() + assertEquals(16000L, policy.currentBackoff) + + // Doubles past 30000 should cap exactly at 30000 (not speculative 15000 or 32000) + policy.advance() + assertEquals(30000L, policy.currentBackoff) + + policy.advance() + assertEquals(30000L, policy.currentBackoff) + } + + @Test + fun resetRestoresInitialBackoffOf1000() { + val policy = ReconnectPolicy() + policy.advance() + policy.advance() + assertEquals(4000L, policy.currentBackoff) + + policy.reset() + assertEquals(1000L, policy.currentBackoff) + } + + @Test + fun setBackoffForTestOverridesCurrentAndInitialBackoffDeterministically() { + val policy = ReconnectPolicy(random = { 0.5 }) + policy.setBackoffForTest(1500L) + + assertEquals(1500L, policy.currentBackoff) + // With test override, delay should be deterministic override value + assertEquals(1500L, policy.nextDelayMs()) + + // advance() still uses deterministic override if set, or advances from set initial/current + policy.advance() + assertEquals(1500L, policy.nextDelayMs()) + + // reset() retains deterministic override behavior or resets to overridden initial + policy.reset() + assertEquals(1500L, policy.nextDelayMs()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt new file mode 100644 index 00000000..7d1d3c4c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ReplayTrackerTest.kt @@ -0,0 +1,555 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class ReplayTrackerTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject + + @Test + fun `initial state has empty watermarks and no replay in flight`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `watermarks can be set read and cleared without cancelling jobs`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.setSeqWatermark("s2", 25) + + assertEquals(mapOf("s1" to 10, "s2" to 25), tracker.getSeqWatermarks()) + + tracker.clearSeqWatermarks() + + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `triggerReplay is no-op when watermarks are empty`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + var fetchCalled = false + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { + fetchCalled = true + SessionEventsSinceResult() + }, + emitEvent = {}, + ) + + tracker.triggerReplay() + + assertFalse(tracker.isReplayInFlight()) + testScope.advanceUntilIdle() + assertFalse(fetchCalled) + } + + @Test + fun `triggerReplay arms hold synchronously and sets replay in flight before coroutine runs`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val deferred = CompletableDeferred() + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { deferred.await() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + + // Arms hold synchronously + tracker.triggerReplay() + + assertTrue(tracker.isReplayInFlight()) + + // Calling triggerReplay again while in flight is ignored + tracker.triggerReplay() + assertTrue(tracker.isReplayInFlight()) + + // A live event arriving now for s1 must be parked into hold without advancing watermark + val liveMsg = WsEvent.MessageToken(token = "live", sessionId = "s1") + tracker.acceptLiveEvent("s1", 8, liveMsg) + + // Watermark has NOT advanced to 8 because replay is in flight and event is held + assertEquals(5, tracker.getSeqWatermarks()["s1"]) + assertTrue(emitted.isEmpty()) + + // Complete replay + deferred.complete(SessionEventsSinceResult()) + testScope.advanceUntilIdle() + + // Replay completed, held event released and emitted, watermark advanced + assertFalse(tracker.isReplayInFlight()) + assertEquals(listOf(liveMsg), emitted) + assertEquals(8, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `isDuplicate drops equal or older seq and accepts newer seq`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + + // Same seq: duplicate + assertTrue(tracker.isDuplicate("s1", 10)) + // Older seq: duplicate + assertTrue(tracker.isDuplicate("s1", 5)) + // Newer seq: not duplicate + assertFalse(tracker.isDuplicate("s1", 11)) + // Unknown session defaults to seq 0 + assertFalse(tracker.isDuplicate("s2", 1)) + assertTrue(tracker.isDuplicate("s2", 0)) + } + + @Test + fun `acceptLiveEvent without in-flight replay emits event and advances watermark`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + val event = WsEvent.MessageToken(token = "tok", sessionId = "s1") + + // Duplicate seq: dropped + tracker.acceptLiveEvent("s1", 10, event) + assertTrue(emitted.isEmpty()) + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Newer seq: emitted and updates watermark + tracker.acceptLiveEvent("s1", 12, event) + assertEquals(listOf(event), emitted) + assertEquals(12, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `replay events are emitted in provided order and advance watermarks`() = + runTest { + val emitted = mutableListOf() + val event1 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"a"}}""", + ) + val event2 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":7,"payload":{"text":"b"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(event1, event2), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.fetchReplayForTest() + + assertEquals(2, emitted.size) + assertEquals("a", (emitted[0] as WsEvent.MessageToken).token) + assertEquals("b", (emitted[1] as WsEvent.MessageToken).token) + assertEquals(7, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `replay events with seq at or below watermark are dropped`() = + runTest { + val emitted = mutableListOf() + val oldEvent = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":5,"payload":{"text":"old"}}""", + ) + val newEvent = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"new"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(oldEvent, newEvent), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.fetchReplayForTest() + + assertEquals(1, emitted.size) + assertEquals("new", (emitted[0] as WsEvent.MessageToken).token) + assertEquals(6, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `held live duplicates covered by replay are dropped and fresh held follow`() = + runTest { + val deferred = CompletableDeferred() + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { deferred.await() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 5) + tracker.triggerReplay() + + // Live event 6 arrives while replay in flight + val live6 = WsEvent.MessageToken(token = "live6", sessionId = "s1") + val live7 = WsEvent.MessageToken(token = "live7", sessionId = "s1") + tracker.acceptLiveEvent("s1", 6, live6) + tracker.acceptLiveEvent("s1", 7, live7) + + // Replay also returns event 6 + val replay6 = + jsonElement( + """{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replay6"}}""", + ) + deferred.complete( + SessionEventsSinceResult( + events = listOf(replay6), + ), + ) + + testScheduler.advanceUntilIdle() + + // Replay 6 is emitted first, advancing watermark to 6. + // Held live 6 (seq 6 <= 6) is dropped as duplicate. + // Held live 7 (seq 7 > 6) is emitted and advances watermark to 7. + assertEquals(2, emitted.size) + assertEquals("replay6", (emitted[0] as WsEvent.MessageToken).token) + assertEquals("live7", (emitted[1] as WsEvent.MessageToken).token) + assertEquals(7, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `epoch change clears all watermarks updates epoch and requests transcript resync`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + epoch = "epoch-2", + latestSeq = 50, + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + tracker.setSeqWatermark("s2", 20) + + tracker.fetchReplayForTest() + + // Watermarks wiped + assertTrue(tracker.getSeqWatermarks().isEmpty()) + assertEquals(1, emitted.size) + assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0]) + } + + @Test + fun `empty or null epoch in result does not trigger epoch change`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + epoch = "", + events = emptyList(), + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + tracker.fetchReplayForTest() + + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + assertTrue(emitted.isEmpty()) + } + + @Test + fun `truncated resync fast-forwards watermark to latestSeq when epoch is same`() = + runTest { + val emitted = mutableListOf() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + truncated = true, + latestSeq = 100, + epoch = "epoch-1", + ) + }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + tracker.fetchReplayForTest() + + // Fast-forwards to latestSeq and emits TranscriptResyncRequired + assertEquals(100, tracker.getSeqWatermarks()["s1"]) + assertEquals(listOf(WsEvent.TranscriptResyncRequired("s1")), emitted) + } + + @Test + fun `replay failure triggers resync invokes onReplayFailure and releases held events`() = + runTest { + val deferred = CompletableDeferred() + val emitted = mutableListOf() + val failedSessions = mutableListOf>() + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + deferred.await() + }, + emitEvent = { emitted.add(it) }, + onReplayFailure = { sid, ex -> failedSessions.add(sid to ex) }, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.triggerReplay() + + // Park a live event while waiting + val liveEvent = WsEvent.MessageToken(token = "held", sessionId = "s1") + tracker.acceptLiveEvent("s1", 15, liveEvent) + + // Fail replay + val testException = IllegalStateException("network error") + deferred.completeExceptionally(testException) + + testScheduler.advanceUntilIdle() + + // Failure emitted TranscriptResyncRequired, reported to callback, and released held events + assertEquals(1, failedSessions.size) + assertEquals("s1", failedSessions[0].first) + assertTrue(failedSessions[0].second is IllegalStateException) + assertEquals("network error", failedSessions[0].second.message) + + assertEquals(2, emitted.size) + assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0]) + assertEquals(liveEvent, emitted[1]) + assertEquals(15, tracker.getSeqWatermarks()["s1"]) + assertFalse(tracker.isReplayInFlight()) + } + + @Test + fun `MessageComplete event uses resolveStoredSessionId injection`() = + runTest { + val emitted = mutableListOf() + val msgCompleteJson = + jsonElement( + """{"type":"message.complete","session_id":"wire-sid","payload":{"text":"done"}}""", + ) + + val tracker = + ReplayTracker( + scope = this, + fetchEventsSince = { + SessionEventsSinceResult( + events = listOf(msgCompleteJson), + ) + }, + emitEvent = { emitted.add(it) }, + resolveStoredSessionId = { wireSid -> "resolved-$wireSid" }, + ) + + tracker.setSeqWatermark("wire-sid", 5) + tracker.fetchReplayForTest() + + assertEquals(1, emitted.size) + val complete = emitted[0] as WsEvent.MessageComplete + assertEquals("done", complete.text) + assertEquals("wire-sid", complete.sessionId) + assertEquals("resolved-wire-sid", complete.storedSessionId) + } + + @Test + fun `updateEpoch clears watermarks when epoch changes`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + + tracker.setReplayEpochForTest("epoch-1") + tracker.setSeqWatermark("s1", 10) + + // Same epoch does not clear + tracker.updateEpoch("epoch-1") + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Empty / null does not clear + tracker.updateEpoch(null) + tracker.updateEpoch("") + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + + // Different epoch clears watermarks + tracker.updateEpoch("epoch-2") + assertTrue(tracker.getSeqWatermarks().isEmpty()) + } + + @Test + fun `clear cancels in-flight job and resets all state`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val deferred = CompletableDeferred() + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { deferred.await() }, + emitEvent = {}, + ) + + tracker.setSeqWatermark("s1", 10) + tracker.triggerReplay() + assertTrue(tracker.isReplayInFlight()) + + tracker.clear() + + assertFalse(tracker.isReplayInFlight()) + assertTrue(tracker.getSeqWatermarks().isEmpty()) + } + + @Test + fun `acceptLiveEvent with eventFactory drops duplicate seq without invoking factory or emitting`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + var factoryInvoked = false + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + + // Duplicate seq <= watermark must not invoke factory nor emit + tracker.acceptLiveEvent(sessionId = "s1", seq = 10) { + factoryInvoked = true + WsEvent.MessageToken(token = "duplicate", sessionId = "s1") + } + + assertFalse(factoryInvoked) + assertTrue(emitted.isEmpty()) + assertEquals(10, tracker.getSeqWatermarks()["s1"]) + } + + @Test + fun `acceptLiveEvent with eventFactory invokes factory once and preserves single-check ordering`() { + val testDispatcher = StandardTestDispatcher() + val testScope = TestScope(testDispatcher) + val emitted = mutableListOf() + val invocationCount = AtomicInteger(0) + val tracker = + ReplayTracker( + scope = testScope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { emitted.add(it) }, + ) + + tracker.setSeqWatermark("s1", 10) + val expectedEvent = WsEvent.MessageToken(token = "fresh", sessionId = "s1") + + // Factory intentionally injects an interleaved watermark advance (e.g. simulating race or side effect). + // Under single-check ordering, the initial pre-parse check (seq 11 > 10) succeeds, calls factory exactly once, + // and proceeds to emit and advance watermark without silently dropping due to an unexpected second check. + tracker.acceptLiveEvent(sessionId = "s1", seq = 11) { + invocationCount.incrementAndGet() + tracker.setSeqWatermark("s1", 99) + expectedEvent + } + + assertEquals(1, invocationCount.get()) + assertEquals(listOf(expectedEvent), emitted) + assertEquals(11, tracker.getSeqWatermarks()["s1"]) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt new file mode 100644 index 00000000..5e1304a8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/RpcChannelTest.kt @@ -0,0 +1,790 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RpcChannelTest { + private val testJson = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + @Serializable + private data class SampleRequestParams( + val message: String, + val count: Int? = null, + ) + + @Serializable + private data class SampleResult( + val status: String, + val total: Int, + ) + + private val sampleMethod = + RpcMethod("test.sample", SampleRequestParams.serializer(), SampleResult.serializer()) + + private class FakeGateway( + scope: CoroutineScope, + json: Json, + outboundLock: Any = Any(), + val onResultCallback: (String) -> Unit = {}, + val onErrorCallback: (String) -> Unit = {}, + resolveStoredSessionId: (String?) -> String? = { it }, + val logParseFailureCallback: (Exception) -> Unit = {}, + decorateParams: (String, Map) -> Map = { _, p -> p }, + ) { + val sentRequests = mutableListOf() + val sentFrames = mutableListOf() + val removedQueuedIds = mutableListOf() + val rejectedLogs = mutableListOf>() + var idleCheckCount = 0 + var sendFrameReturnValue = true + + var immediateResolveOnSend: ((id: String, onSent: ((String) -> Unit)?) -> Unit)? = null + + val channel: RpcChannel = + RpcChannel( + scope = scope, + json = json, + sendRequest = { method, params, onSent -> + val id = "req-${sentRequests.size + 1}" + sentRequests.add(SentRequestRecord(id, method, params)) + onSent?.invoke(id) + immediateResolveOnSend?.invoke(id, onSent) + id + }, + sendFrame = { frame -> + sentFrames.add(frame) + sendFrameReturnValue + }, + removeQueuedMessage = { id -> + removedQueuedIds.add(id) + }, + onIdleCheck = { + idleCheckCount++ + }, + logRejected = { method, id -> + rejectedLogs.add(method to id) + }, + outboundLock = outboundLock, + onResult = onResultCallback, + onError = onErrorCallback, + resolveStoredSessionId = resolveStoredSessionId, + logParseFailure = logParseFailureCallback, + decorateParams = decorateParams, + ) + + data class SentRequestRecord( + val id: String, + val method: String, + val params: Map, + ) + } + + @Test + fun nextIdProducesIncreasingNonBlankStrings() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val firstId = gateway.channel.nextId() + val secondId = gateway.channel.nextId() + + assertEquals("1", firstId) + assertTrue(firstId.isNotBlank()) + assertTrue(secondId.isNotBlank()) + val firstNum = firstId.toLong() + val secondNum = secondId.toLong() + assertTrue(secondNum > firstNum) + assertEquals(firstNum + 1L, secondNum) + } + + @Test + fun requestSuccessResolvesDeferredAndCleansUpInOrder() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("user.ping", mapOf("key" to "value")) + val id = gateway.sentRequests.first().id + + assertTrue(gateway.channel.hasPendingCalls()) + assertTrue(gateway.channel.pendingCallIdsForTest().contains(id)) + assertNotNull(gateway.channel.pendingCallTimeoutForTest(id)) + + val dummyResult = JsonPrimitive("pong") + gateway.channel.resolvePending(id, dummyResult, null) + + val resolved = deferred.await() + assertEquals(dummyResult, resolved) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + assertNull(gateway.channel.pendingCallTimeoutForTest(id)) + } + + @Test + fun requestErrorResolvesWithHermesRpcExceptionAndErrorData() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("user.fail") + val id = gateway.sentRequests.first().id + + val errorData = buildJsonObject { put("reason", "auth_failed") } + val rpcError = JsonRpcError(code = 401, message = "Unauthorized", data = errorData) + + gateway.channel.resolvePending(id, null, rpcError) + + try { + deferred.await() + fail("Expected HermesRpcException") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Unauthorized", e.message) + assertEquals(401, e.code) + assertEquals(errorData, e.data) + } + + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun requestTimeoutFailsWithExactMessageAndCodeMinusOne() = + runTest { + val gateway = FakeGateway(this, testJson) + val timeoutMs = 5_000L + val deferred = gateway.channel.request("agent.turn", timeoutMs = timeoutMs) + val id = gateway.sentRequests.first().id + + runCurrent() + advanceTimeBy(4_999L) + runCurrent() + assertTrue(gateway.channel.hasPendingCalls()) + assertFalse(deferred.isCompleted) + + advanceTimeBy(1L) + runCurrent() + + assertTrue(deferred.isCompleted) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + + try { + deferred.await() + fail("Expected HermesRpcException on timeout") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Request timed out: agent.turn", e.message) + assertEquals(-1, e.code) + assertNull(e.data) + } + } + + @Test + fun deferredCancellationCancelsTimerRemovesQueuedAndTriggersIdle() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("slow.method", timeoutMs = 60_000L) + val id = gateway.sentRequests.first().id + + val timeoutJob = gateway.channel.pendingCallTimeoutForTest(id) + assertNotNull(timeoutJob) + assertFalse(timeoutJob!!.isCancelled) + + deferred.cancel() + runCurrent() + + assertTrue(deferred.isCancelled) + assertFalse(gateway.channel.hasPendingCalls()) + assertTrue(timeoutJob.isCancelled) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + + // Advancing past original timeout should do nothing further + advanceTimeBy(70_000L) + runCurrent() + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun suppressionFlagReflectedInIsErrorEventSuppressed() = + runTest { + val gateway = FakeGateway(this, testJson) + gateway.channel.request("normal.call", suppressErrorEvent = false) + val normalId = gateway.sentRequests[0].id + + gateway.channel.request("silent.call", suppressErrorEvent = true) + val silentId = gateway.sentRequests[1].id + + assertFalse(gateway.channel.isErrorEventSuppressed(normalId)) + assertTrue(gateway.channel.isErrorEventSuppressed(silentId)) + assertFalse(gateway.channel.isErrorEventSuppressed("non-existent-id")) + } + + @Test + fun rejectAllPendingFailsAllCallsWithProvidedExceptionAndNoIdleCheck() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred1 = gateway.channel.request("method.one") + val deferred2 = gateway.channel.request("method.two") + val id1 = gateway.sentRequests[0].id + val id2 = gateway.sentRequests[1].id + + val customException = HermesWsClient.HermesRpcException("Socket terminated abruptly", code = 1006) + + gateway.channel.rejectAllPending(customException) + + assertFalse(gateway.channel.hasPendingCalls()) + // Verified contract: rejectAllPending performs NO onIdleCheck + assertEquals(0, gateway.idleCheckCount) + assertEquals(setOf(id1, id2), gateway.removedQueuedIds.toSet()) + assertEquals(setOf("method.one" to id1, "method.two" to id2), gateway.rejectedLogs.toSet()) + + try { + deferred1.await() + fail("deferred1 should have failed") + } catch (e: HermesWsClient.HermesRpcException) { + assertSame(customException, e) + } + + try { + deferred2.await() + fail("deferred2 should have failed") + } catch (e: HermesWsClient.HermesRpcException) { + assertSame(customException, e) + } + } + + @Test + fun rejectAllPendingDefaultExceptionMatchesExpectedMessage() = + runTest { + val gateway = FakeGateway(this, testJson) + val deferred = gateway.channel.request("some.call") + + gateway.channel.rejectAllPending() + + try { + deferred.await() + fail("Expected default exception") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals("Connection lost — request cancelled", e.message) + } + } + + @Test + fun immediateResolutionDuringOnSentHandledSafely() = + runTest { + val gateway = FakeGateway(this, testJson) + gateway.immediateResolveOnSend = { id, _ -> + gateway.channel.resolvePending(id, "instant-result", null) + } + + val deferred = gateway.channel.request("instant.call") + val id = gateway.sentRequests.first().id + + assertTrue(deferred.isCompleted) + assertEquals("instant-result", deferred.await()) + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun typedCallSerializesParamsOmittingDefaultsAndDecodesResult() = + runTest { + val gateway = FakeGateway(this, testJson) + + val job = + async { + gateway.channel.call( + sampleMethod, + SampleRequestParams(message = "hello", count = null), + ) + } + + runCurrent() + assertEquals(1, gateway.sentRequests.size) + val sent = gateway.sentRequests.first() + assertEquals("test.sample", sent.method) + // encodeDefaults = false means count (null) is omitted from params + val encodedParams = + gateway.channel.encodeParams( + sampleMethod, + SampleRequestParams(message = "hello", count = null), + ) + assertFalse(encodedParams.containsKey("count")) + assertTrue(encodedParams.containsKey("message")) + + // Resolve pending with raw json representation + val rawResult = + buildJsonObject { + put("status", "ok") + put("total", 42) + put("extra_ignored_key", "bonus") + } + gateway.channel.resolvePending(sent.id, rawResult, null) + + val result = job.await() + assertEquals("ok", result.status) + assertEquals(42, result.total) + } + + @Test + fun typedCallCancellationPropagatesAndCancelsDeferred() = + runTest { + val gateway = FakeGateway(this, testJson) + + val job = + launch { + gateway.channel.call( + sampleMethod, + SampleRequestParams(message = "cancel-me"), + ) + } + + runCurrent() + val id = gateway.sentRequests.first().id + assertTrue(gateway.channel.hasPendingCalls()) + + job.cancel() + runCurrent() + + assertFalse(gateway.channel.hasPendingCalls()) + assertEquals(listOf(id), gateway.removedQueuedIds) + assertEquals(1, gateway.idleCheckCount) + } + + @Test + fun rawJsonNumericFidelityPreservedIntoResolve() = + runTest { + val gateway = FakeGateway(this, testJson) + + val deferred = + gateway.channel.requestTyped( + sampleMethod, + SampleRequestParams(message = "precision"), + ) + val id = gateway.sentRequests.first().id + + // Map with integer-value double, e.g. 177.0 -> preserves int semantics in toJsonElement + val mapResult = mapOf("status" to "precise", "total" to 177.0) + gateway.channel.resolvePending(id, mapResult, null) + + val raw = deferred.await() + val jsonElement = raw.toJsonElement() as JsonObject + assertEquals(177, jsonElement["total"]?.jsonPrimitive?.int) + + // Direct decode with injected channel json to verify round-trip + val decoded = testJson.decodeFromJsonElement(sampleMethod.result, jsonElement) + assertEquals(177, decoded.total) + assertEquals("precise", decoded.status) + } + + @Test + fun respondToServerRequestFormatsExactJsonRpcResponseAndPropagatesSink() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val serverReqId = "srv-999" + val payload = buildJsonObject { put("pong", true) } + + gateway.sendFrameReturnValue = true + val result = gateway.channel.respondToServerRequest(serverReqId, payload) + assertTrue(result) + assertEquals(1, gateway.sentFrames.size) + + val frame = testJson.decodeFromString(gateway.sentFrames.first()) + assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content) + assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content) + assertEquals(payload, frame["result"]) + assertFalse(frame.containsKey("method")) + assertFalse(frame.containsKey("params")) + + // Blank id returns false immediately without sending frame + val blankResult = gateway.channel.respondToServerRequest(" ", payload) + assertFalse(blankResult) + assertEquals(1, gateway.sentFrames.size) + + // Sink returning false is propagated + gateway.sendFrameReturnValue = false + val falseResult = gateway.channel.respondToServerRequest("srv-1000", payload) + assertFalse(falseResult) + assertEquals(2, gateway.sentFrames.size) + } + + @Test + fun respondToServerRequestErrorFormatsExactJsonRpcErrorAndPropagatesSink() { + val gateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val serverReqId = "srv-888" + + gateway.sendFrameReturnValue = true + val result = gateway.channel.respondToServerRequestError(serverReqId, -32601, "Method not found") + assertTrue(result) + assertEquals(1, gateway.sentFrames.size) + + val frame = testJson.decodeFromString(gateway.sentFrames.first()) + assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content) + assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content) + assertFalse(frame.containsKey("result")) + val errorObj = frame["error"] as JsonObject + assertEquals(-32601, errorObj["code"]?.jsonPrimitive?.int) + assertEquals("Method not found", errorObj["message"]?.jsonPrimitive?.content) + + // Blank id returns false without sending frame + assertFalse(gateway.channel.respondToServerRequestError("", -1, "Bad")) + assertEquals(1, gateway.sentFrames.size) + + // Sink returning false is propagated + gateway.sendFrameReturnValue = false + val falseResult = gateway.channel.respondToServerRequestError("srv-889", -32000, "Error") + assertFalse(falseResult) + assertEquals(2, gateway.sentFrames.size) + } + + @Test + fun createRequestDecoratesOnceAllocatesIdAndEncodesOmittingDefaults() { + var decorateCount = 0 + val gateway = + FakeGateway( + scope = CoroutineScope(CompletableDeferred()), + json = testJson, + decorateParams = { method, params -> + decorateCount++ + params + ("decoratedMethod" to method) + }, + ) + + val request = gateway.channel.createRequest("test.action", mapOf("key" to "value")) + assertEquals("1", request.id) + assertEquals("test.action", request.method) + assertEquals(1, decorateCount) + assertEquals(JsonPrimitive("value"), request.params["key"]) + assertEquals(JsonPrimitive("test.action"), request.params["decoratedMethod"]) + + val encoded = gateway.channel.encodeRequest(request) + // Injected json with encodeDefaults = false omits default jsonrpc ("2.0") + assertFalse(encoded.contains("\"jsonrpc\"")) + assertTrue(encoded.contains("\"id\":\"1\"") || encoded.contains("\"id\": \"1\"")) + assertTrue(encoded.contains("\"method\":\"test.action\"") || encoded.contains("\"method\": \"test.action\"")) + + // An empty params map is omitted by encodeRequest under encodeDefaults = false + val plainGateway = FakeGateway(CoroutineScope(CompletableDeferred()), testJson) + val emptyParamsReq = plainGateway.channel.createRequest("test.empty", emptyMap()) + val encodedEmpty = plainGateway.channel.encodeRequest(emptyParamsReq) + assertFalse(encodedEmpty.contains("\"params\"")) + } + + @Test + fun handleIncomingFrameOrderRawJsonResolvedBeforeEventEmittedAndDoubleOnResult() = + runTest { + val onResultCalls = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + onResultCallback = { onResultCalls.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val deferred = gateway.channel.request("client.calc") + val id = gateway.sentRequests.first().id + + var rawResolvedAtEventTime: Boolean? = null + var onResultCallsAtEventTime = 0 + val emitSink: (WsEvent) -> Unit = { event -> + events.add(event) + rawResolvedAtEventTime = deferred.isCompleted + onResultCallsAtEventTime = onResultCalls.size + } + + val incomingFrame = """{"jsonrpc":"2.0","id":"$id","result":{"ans":42.0}}""" + gateway.channel.handleIncomingFrame(incomingFrame, replayTracker, emitSink) + + // onResult is invoked twice baseline: once before raw resolve, and again before parsed RpcResult resolution + assertEquals(2, onResultCalls.size) + assertEquals(listOf(id, id), onResultCalls) + assertEquals(2, onResultCallsAtEventTime) + + // Raw deferred was settled BEFORE emitEvent was called + assertEquals(true, rawResolvedAtEventTime) + assertTrue(deferred.isCompleted) + + // Numeric fidelity preserved on result + val rawResult = deferred.await() + assertTrue(rawResult is JsonObject) + val rawObj = rawResult as JsonObject + assertEquals(JsonPrimitive(42.0), rawObj["ans"]) + + // Event emitted was RpcResult + assertEquals(1, events.size) + val rpcResult = events.first() as WsEvent.RpcResult + assertEquals(id, rpcResult.id) + } + + @Test + fun handleIncomingFrameRpcErrorInvokesCallbackAndRespectsSuppression() = + runTest { + val onErrorCalls = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + onErrorCallback = { onErrorCalls.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + // 1. Normal unsuppressed error + val normalDeferred = gateway.channel.request("call.normal", suppressErrorEvent = false) + val normalId = gateway.sentRequests[0].id + val errorFrame1 = + """{"jsonrpc":"2.0","id":"$normalId","error":{"code":-32603,"message":"Internal error"}}""" + gateway.channel.handleIncomingFrame(errorFrame1, replayTracker) { events.add(it) } + + assertEquals(listOf(normalId), onErrorCalls) + assertTrue(normalDeferred.isCompleted) + try { + normalDeferred.await() + fail("Expected HermesRpcException") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(-32603, e.code) + assertEquals("Internal error", e.message) + } + assertEquals(1, events.size) + assertTrue(events.first() is WsEvent.RpcError) + assertEquals(normalId, (events.first() as WsEvent.RpcError).id) + + // 2. Suppressed error resolves deferred but emits NONE + val silentDeferred = gateway.channel.request("call.silent", suppressErrorEvent = true) + val silentId = gateway.sentRequests[1].id + val errorFrame2 = + """{"jsonrpc":"2.0","id":"$silentId","error":{"code":404,"message":"Not found"}}""" + gateway.channel.handleIncomingFrame(errorFrame2, replayTracker) { events.add(it) } + + assertEquals(listOf(normalId, silentId), onErrorCalls) + assertTrue(silentDeferred.isCompleted) + try { + silentDeferred.await() + fail("Expected HermesRpcException on silent deferred") + } catch (e: HermesWsClient.HermesRpcException) { + assertEquals(404, e.code) + } + // Event list size remains 1 (no RpcError event emitted for suppressed call) + assertEquals(1, events.size) + } + + @Test + fun handleIncomingFrameCapabilitySuppressionHidesEventUntilCleared() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val capId = "cap-42" + gateway.channel.registerCapabilityRequest(capId) + + val capResponseFrame = """{"jsonrpc":"2.0","id":"$capId","result":{"supported":true}}""" + gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) } + + // Registered capability response is hidden from events + assertTrue(events.isEmpty()) + + // After clearing capabilities, subsequent responses with that ID are visible + gateway.channel.registerCapabilityRequest(capId) + gateway.channel.clearCapabilityRequests() + gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val rpcResult = events.first() as WsEvent.RpcResult + assertEquals(capId, rpcResult.id) + } + + @Test + fun handleIncomingFrameMalformedJsonInvokesParseFailureAndEmitsUnknown() = + runTest { + val parseFailures = mutableListOf() + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + logParseFailureCallback = { parseFailures.add(it) }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val malformed = "{ invalid json frame ::" + gateway.channel.handleIncomingFrame(malformed, replayTracker) { events.add(it) } + + assertEquals(1, parseFailures.size) + assertEquals(1, events.size) + val unknown = events.first() as WsEvent.Unknown + assertEquals(malformed, unknown.raw) + } + + @Test + fun handleIncomingFrameServerRequestClassifiedCorrectlyOverRpcResult() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + // Normal srq frame with id + method should emit ServerRequest, not RpcResult + val srqFrame = """{"jsonrpc":"2.0","id":"srq-101","method":"clarify","params":{"text":"Confirm?"}}""" + gateway.channel.handleIncomingFrame(srqFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val event = events.first() + assertTrue("Expected ServerRequest, got: $event", event is WsEvent.ServerRequest) + val srq = event as WsEvent.ServerRequest + assertEquals("srq-101", srq.id) + assertEquals("clarify", srq.method) + assertFalse(srq.replayed) + } + + @Test + fun handleIncomingFrameSequencedNotificationDeduplicatesViaReplayTracker() = + runTest { + val events = mutableListOf() + val gateway = FakeGateway(this, testJson) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val seqFrame = + """{"method":"events","params":{"type":"message.token","session_id":"s1","seq":11,"payload":{"text":"fresh"}}}""" + + // First delivery (fresh seq) emits once + gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) } + assertEquals(1, events.size) + val tokenEvent = events.first() as WsEvent.MessageToken + assertEquals("fresh", tokenEvent.token) + assertEquals("s1", tokenEvent.sessionId) + + // Duplicate delivery (same seq 11) is dropped, emits none + gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) } + assertEquals(1, events.size) + } + + @Test + fun handleIncomingFrameSettlesRawResultBeforeReplayingOpenRequests() = + runTest { + val order = mutableListOf() + val gateway = FakeGateway(this, testJson, onResultCallback = { order.add("result:$it") }) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = {}, + ) + val deferred = gateway.channel.request("session.resume") + val id = gateway.sentRequests.first().id + deferred.invokeOnCompletion { order.add("resolved") } + val frame = + """{"id":"$id","result":{"open_requests":[{"id":"srq-open","method":"clarify","params":{"text":"replayed"}}]}}""" + + gateway.channel.handleIncomingFrame(frame, replayTracker) { event -> + assertTrue("Raw deferred must settle before either event", deferred.isCompleted) + when (event) { + is WsEvent.ServerRequest -> { + assertTrue(event.replayed) + assertEquals("clarify", event.method) + assertEquals("replayed", event.params["text"]) + order.add("server:${event.id}") + } + + is WsEvent.RpcResult -> { + order.add("rpc:${event.id}") + } + + else -> { + fail("Unexpected event: $event") + } + } + } + + assertEquals(listOf("result:$id", "resolved", "server:srq-open", "result:$id", "rpc:$id"), order) + assertTrue(deferred.await() is JsonObject) + assertFalse(gateway.channel.hasPendingCalls()) + } + + @Test + fun handleIncomingFrameDecoratesStoredSessionIdOnMessageComplete() = + runTest { + val events = mutableListOf() + val gateway = + FakeGateway( + scope = this, + json = testJson, + resolveStoredSessionId = { sid -> "stored-$sid" }, + ) + val replayTracker = + ReplayTracker( + scope = this, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { events.add(it) }, + ) + + val completeFrame = + """{"method":"events","params":{"type":"message.complete","session_id":"active-session","payload":{"text":"done","reasoning":"reason"}}}""" + gateway.channel.handleIncomingFrame(completeFrame, replayTracker) { events.add(it) } + + assertEquals(1, events.size) + val completeEvent = events.first() as WsEvent.MessageComplete + assertEquals("done", completeEvent.text) + assertEquals("reason", completeEvent.reasoning) + assertEquals("active-session", completeEvent.sessionId) + assertEquals("stored-active-session", completeEvent.storedSessionId) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt index 4a32ef87..a10d23dd 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SessionLiveStatusSourceTest.kt @@ -121,14 +121,12 @@ class SessionLiveStatusSourceTest { @Test fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() = runBlocking { - var capturedMethod: String? = null - var capturedParams: Map? = null + var rpcCalls = 0 val source = HermesSessionLiveStatusSource( - rpcRequest = { method, params -> - capturedMethod = method - capturedParams = params + rpcRequest = { + rpcCalls++ mapOf( "sessions" to listOf( @@ -146,8 +144,8 @@ class SessionLiveStatusSourceTest { val snapshot = source.fetchActiveSessionsSnapshot() - assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod) - assertTrue(capturedParams!!.isEmpty()) + // Method name and empty params are pinned on the wire in HermesWsClientApprovalsTest. + assertEquals(1, rpcCalls) assertNotNull(snapshot) assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"]) } @@ -157,7 +155,7 @@ class SessionLiveStatusSourceTest { runBlocking { val source = HermesSessionLiveStatusSource( - rpcRequest = { _, _ -> throw RuntimeException("RPC error") }, + rpcRequest = { throw RuntimeException("RPC error") }, eventsProvider = { MutableSharedFlow() }, connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) }, ) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt index 10fc76d9..d44eb665 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/SubagentRepositoryTest.kt @@ -3,6 +3,18 @@ package com.m57.hermescontrol.data.ws import com.m57.hermescontrol.data.model.SubagentListItem import com.m57.hermescontrol.data.model.SubagentListResponse import com.m57.hermescontrol.data.model.SubagentTailResponse +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import com.m57.hermescontrol.data.ws.contract.SubagentTailParams +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -11,6 +23,48 @@ import org.junit.Assert.assertTrue import org.junit.Test class SubagentRepositoryTest { + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun listSubagentsPreservesSuppressedErrorsAndDecodesResult() { + val captured = slot() + mockkObject(HermesWsClient) + coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_LIST, capture(captured), any(), true) } returns + buildJsonObject { put("subagents", kotlinx.serialization.json.buildJsonArray { }) } + + val response = runBlocking { SubagentRepository.listSubagents("s1") } + + assertEquals(SessionIdParams("s1"), captured.captured) + assertNotNull(response) + assertTrue(response?.subagents?.isEmpty() == true) + } + + @Test + fun tailSubagentSendsExactlySessionIdAndSubagentId() { + // #1379: the contract forbids extra keys (max_bytes) and requires session_id. + val captured = slot() + mockkObject(HermesWsClient) + coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, capture(captured), any(), true) } returns + buildJsonObject { put("subagent_id", "sub-1") } + + runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") } + + assertEquals(SubagentTailParams("s1", "sub-1"), captured.captured) + } + + @Test + fun tailSubagentSkipsTheCallWhenAnIdIsBlank() { + mockkObject(HermesWsClient) + runBlocking { + assertNull(SubagentRepository.tailSubagent("", "sub-1")) + assertNull(SubagentRepository.tailSubagent("s1", " ")) + } + coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, any(), any(), any()) } + } + @Test fun testWsMethodsConstants() { assertEquals("subagent.list", WsMethods.SUBAGENT_LIST) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt new file mode 100644 index 00000000..fbb11bc4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/WsTransportTest.kt @@ -0,0 +1,489 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.Response +import okhttp3.WebSocket +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class WsTransportTest { + private val testJson = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + + private class Fixture( + val scope: CoroutineScope, + json: Json, + nowMs: () -> Long = { + if (scope is TestScope) { + scope.testScheduler.currentTime + } else { + System.nanoTime() / 1_000_000L + } + }, + ) { + val outboundLock = Any() + val rawMessages = mutableListOf() + val replayEmittedEvents = mutableListOf() + val reconnectPolicy = ReconnectPolicy() + + val rpcSentFrames = mutableListOf() + val rpcRemovedQueueIds = mutableListOf() + var rpcIdleChecks = 0 + + var transportRef: WsTransport? = null + private val rpcIdCounter = AtomicInteger(1) + + val connectionHealth = + ConnectionHealth( + scope = scope, + isConnected = { transportRef?.isConnected ?: false }, + pingRequest = { /* ping fake unit */ }, + cancelSocket = { transportRef?.cancelSocket() }, + pendingReply = { transportRef?.pendingReply ?: false }, + nowMs = nowMs, + ) + + val replayTracker = + ReplayTracker( + scope = scope, + fetchEventsSince = { SessionEventsSinceResult() }, + emitEvent = { replayEmittedEvents.add(it) }, + ) + + val rpcChannel = + RpcChannel( + scope = scope, + json = json, + sendRequest = { method, params, onSent -> + val id = "rpc-${rpcIdCounter.getAndIncrement()}" + val req = + rpcChannelRef?.createRequest(method, params)?.copy(id = id) + ?: JsonRpcRequest(id = id, method = method) + transportRef?.send(req, onSent) ?: id + }, + sendFrame = { frame -> + rpcSentFrames.add(frame) + transportRef?.sendDirectFrame(frame) ?: false + }, + removeQueuedMessage = { id -> + rpcRemovedQueueIds.add(id) + transportRef?.removeQueuedMessage(id) + }, + onIdleCheck = { + rpcIdleChecks++ + transportRef?.disconnectIfIdleInBackground() + }, + outboundLock = outboundLock, + onResult = { id -> transportRef?.onRpcResult(id) }, + onError = { id -> transportRef?.onRpcError(id) }, + ).also { rpcChannelRef = it } + + val transport = + WsTransport( + scope = scope, + outboundLock = outboundLock, + reconnectPolicy = reconnectPolicy, + connectionHealth = connectionHealth, + replayTracker = replayTracker, + rpcChannel = rpcChannel, + onRawMessage = { rawMessages.add(it) }, + ).also { transportRef = it } + + companion object { + private var rpcChannelRef: RpcChannel? = null + } + } + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.w(any(), any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + ActiveSessionHolder.clear() + + mockkObject(AuthManager) + every { AuthManager.isAutoReconnect() } returns false + + mockkObject(NetworkMonitor) + every { NetworkMonitor.isConnected } returns MutableStateFlow(true) + } + + @After + fun tearDown() { + ActiveSessionHolder.clear() + unmockkAll() + } + + @Test + fun sendAcceptCallbackRunsUnderOutboundLockAfterSocketAccept() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + + var callbackRan = false + var callbackThreadHoldsLock = false + val request = JsonRpcRequest(id = "req-1", method = "session.list") + + val id = + fixture.transport.send( + request = request, + onSent = { + callbackRan = true + callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock) + }, + ) + + assertEquals("req-1", id) + assertTrue(callbackRan) + assertTrue(callbackThreadHoldsLock) + verify(exactly = 1) { socket.send(any()) } + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + } + + @Test + fun rejectedRetryableSendEnqueuesAndCancelDoesNotAutoRedialWhenAutoReconnectDisabled() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns false + every { socket.queueSize() } returns 0L + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + var onSentCalled = false + var callbackThreadHoldsLock = false + val request = JsonRpcRequest(id = "req-queued", method = "prompt.submit") + + fixture.transport.send( + request = request, + onSent = { + onSentCalled = true + callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock) + }, + queueIfDisconnected = true, + ) + + // When socket rejects, request is pushed back into retryable queue + val queue = fixture.transport.messageQueueForTest + assertEquals(1, queue.size) + assertTrue(queue.peek()?.contains("req-queued") == true) + assertTrue(onSentCalled) + assertTrue(callbackThreadHoldsLock) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.reconnectJobForTest) + verify(exactly = 1) { socket.cancel() } + } + + @Test + fun staleOnMessageIgnoredWhileCurrentGenerationFrameUpdatesHealthAndRawCallback() = + runTest { + val fixture = + Fixture( + scope = this, + json = testJson, + nowMs = { testScheduler.currentTime }, + ) + val currentSocket = mockk(relaxed = true) + val staleSocket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.connectionGenerationForTest.set(2) + fixture.transport.webSocketForTest = currentSocket + + val initialTimestamp = fixture.connectionHealth.lastPongTimestamp + assertEquals(0L, initialTimestamp) + + val staleListener = fixture.transport.createListenerForTest(1) + val currentListener = fixture.transport.createListenerForTest(2) + + advanceTimeBy(1_000L) + // Message from stale listener/generation should be discarded + staleListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old\"}") + staleListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old-mismatched-gen\"}") + assertTrue(fixture.rawMessages.isEmpty()) + assertEquals(0L, fixture.connectionHealth.lastPongTimestamp) + + advanceTimeBy(1_000L) + // Message from current generation but mismatched socket should be discarded + currentListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"wrong-socket\"}") + assertTrue(fixture.rawMessages.isEmpty()) + assertEquals(0L, fixture.connectionHealth.lastPongTimestamp) + + advanceTimeBy(1_000L) + val expectedTimestamp = testScheduler.currentTime + // Matching generation and matching active socket passes through and updates health + currentListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}") + assertEquals(listOf("{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}"), fixture.rawMessages) + assertEquals(expectedTimestamp, fixture.connectionHealth.lastPongTimestamp) + } + + @Test + fun staleOnOpenClosesSupersededWithoutChangingOwnershipOrFlushingQueue() = + runTest { + val fixture = Fixture(this, testJson) + val staleSocket = mockk(relaxed = true) + val currentSocket = mockk(relaxed = true) + val response = mockk(relaxed = true) + + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(2) + fixture.transport.webSocketForTest = currentSocket + fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"queued-1\"}") + + val staleListener = fixture.transport.createListenerForTest(1) + staleListener.onOpen(staleSocket, response) + + // Stale onOpen must close the stale socket with 1000 ("Superseded") + verify(exactly = 1) { staleSocket.close(1000, "Superseded") } + // Must not change active socket or flush the queue on stale socket + verify(exactly = 0) { staleSocket.send(any()) } + assertEquals(currentSocket, fixture.transport.webSocketForTest) + assertEquals(1, fixture.transport.messageQueueForTest.size) + } + + @Test + fun authClosingCodeSetsTerminalAuthExpiredAndDoesNotAutoRedial() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + // Register a pending RPC call to verify it is NOT newly rejected + val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + val listener = fixture.transport.createListenerForTest(1) + listener.onClosing(socket, 4401, "unauthorized") + + assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value) + assertTrue(fixture.transport.isConnected) + assertNull(fixture.transport.reconnectJobForTest) + assertFalse(pendingDeferred.isCompleted) + + listener.onClosed(socket, 4401, "unauthorized") + + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.reconnectJobForTest) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } + + @Test + fun disconnectPreservesPendingCallsWhileClearFlagControlsQueueAndLeaseCleanup() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.webSocketForTest = socket + + // Register actual pending RPC call over socket accepting send + val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + // Populate retryable message queue + fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"}") + + // Ordinary disconnect preserves queued messages and does NOT reject pending calls + fixture.transport.disconnect(clearPendingMessages = false) + assertEquals(1, fixture.transport.messageQueueForTest.size) + assertFalse(fixture.transport.isConnected) + assertFalse(pendingDeferred.isCompleted) + + // Disconnect with clearPendingMessages = true clears queue and resets leases, still not rejecting pending calls + fixture.transport.acquireBackgroundConnectionLease() + assertTrue(fixture.transport.hasBackgroundConnectionLease()) + + fixture.transport.disconnect(clearPendingMessages = true) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + assertFalse(fixture.transport.hasBackgroundConnectionLease()) + assertFalse(pendingDeferred.isCompleted) + + pendingDeferred.cancel() + } + + @Test + fun reconnectForNetworkChangeRejectsPendingCallsAndExecutesReplacementCallbackOnce() = + runTest { + every { AuthManager.isAutoReconnect() } returns true + + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send(any()) } returns true + + fixture.transport.connectedForTest.set(true) + fixture.transport.intentionalCloseForTest.set(false) + fixture.transport.connectionGenerationForTest.set(1) + fixture.transport.webSocketForTest = socket + + // Register non-empty pending RPC while socket accepts send + val pendingDeferred = fixture.rpcChannel.request("custom.network_call", emptyMap()) + assertFalse(pendingDeferred.isCompleted) + + var replacementOpens = 0 + fixture.transport.reconnectForNetworkChange( + networkAvailable = true, + openReplacement = { replacementOpens++ }, + ) + + // Outgoing socket canceled once + verify(exactly = 1) { socket.cancel() } + assertEquals(1, replacementOpens) + assertEquals(2, fixture.transport.connectionGenerationForTest.get()) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value) + assertNull(fixture.transport.webSocketForTest) + + // Network replacement uses the default connection-lost exception, not a timeout. + assertTrue(pendingDeferred.isCompleted) + try { + pendingDeferred.await() + fail("Expected HermesRpcException") + } catch (exception: HermesWsClient.HermesRpcException) { + assertEquals(0, exception.code) + assertEquals("Connection lost — request cancelled", exception.message) + } + } + + @Test + fun combinedLeasesSurviveIdleBackgroundUntilBothReleased() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + + fixture.transport.acquireExternalActivityConnectionLease() + fixture.transport.acquireBackgroundConnectionLease() + assertTrue(fixture.transport.hasBackgroundConnectionLease()) + + // Backgrounding app with both leases active keeps connection alive + fixture.transport.setAppForeground(false) + assertTrue(fixture.transport.isConnected) + + // Releasing external lease keeps connection alive due to background lease + fixture.transport.releaseExternalActivityConnectionLease() + assertTrue(fixture.transport.isConnected) + + // Releasing background lease releases all leases and disconnects idle background socket + fixture.transport.releaseBackgroundConnectionLease() + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + } + + @Test + fun observeEventMarksPendingReplyStartAndCompleteRespectingIdlePolicy() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED) + + // When a message start event arrives BEFORE setAppForeground(false) + val startEvent = WsEvent.MessageStart(sessionId = "s1") + fixture.transport.observeEvent(startEvent) + assertTrue(fixture.transport.pendingReply) + + // Setting app foreground to false while pending reply is true stays connected + fixture.transport.setAppForeground(false) + assertTrue(fixture.transport.isConnected) + + // Idle disconnect check while pending reply is true keeps socket connected + fixture.transport.disconnectIfIdleInBackground() + assertTrue(fixture.transport.isConnected) + + // Message complete event clears pending reply and immediately idle disconnects + val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "s1") + fixture.transport.observeEvent(completeEvent) + assertFalse(fixture.transport.pendingReply) + assertFalse(fixture.transport.isConnected) + assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value) + } + + @Test + fun sendDirectFrameBypassesQueueAndReturnsFalseWhenDisconnected() = + runTest { + val fixture = Fixture(this, testJson) + val socket = mockk(relaxed = true) + every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}") } returns true + every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") } returns false + + fixture.transport.connectedForTest.set(true) + fixture.transport.webSocketForTest = socket + + // 1. Connected with socket accepting send -> returns true, no queue + val connectedResult = + fixture.transport.sendDirectFrame( + "{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}", + ) + assertTrue(connectedResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + + // 2. Connected with socket rejecting send -> returns false, no queue + val sendFailsResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") + assertFalse(sendFailsResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + + // 3. Disconnected -> returns false, no queue + fixture.transport.connectedForTest.set(false) + fixture.transport.webSocketForTest = null + + val disconnectedResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"direct\"}") + assertFalse(disconnectedResult) + assertTrue(fixture.transport.messageQueueForTest.isEmpty()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt new file mode 100644 index 00000000..2b419264 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ApprovalsTypesTest.kt @@ -0,0 +1,209 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ApprovalsTypesTest { + @Test + fun approvalPendingParamsEncodesSessionIdExactly() { + val params = ApprovalPendingParams(sessionId = "sess-appr-pending-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalPendingParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-appr-pending-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalReceivedParamsEncodesSessionIdAndRequestId() { + val params = + ApprovalReceivedParams( + sessionId = "sess-appr-rec-1", + requestId = "req-appr-rec-99", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalReceivedParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-appr-rec-1") + put("request_id", "req-appr-rec-99") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesOnlySessionIdWhenOptionalsNull() { + val params = ApprovalRespondParams(sessionId = "sess-respond-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesAllFieldsWhenPresent() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-2", + choice = "allow", + all = true, + requestId = "req-respond-2", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-2") + put("choice", "allow") + put("all", true) + put("request_id", "req-respond-2") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsEncodesExplicitFalseForAll() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-3", + choice = "deny", + all = false, + requestId = "req-respond-3", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-3") + put("choice", "deny") + put("all", false) + put("request_id", "req-respond-3") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys) + } + + @Test + fun approvalRespondParamsOmitsOnlyNullFields() { + val params = + ApprovalRespondParams( + sessionId = "sess-respond-4", + choice = "allow", + all = null, + requestId = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-respond-4") + put("choice", "allow") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "choice"), (encoded as JsonObject).keys) + } + + @Test + fun sessionActiveListParamsEncodesToEmptyJsonObjectWhenCurrentSessionIdNull() { + val params = SessionActiveListParams() + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun sessionActiveListParamsEncodesCurrentSessionIdWhenSet() { + val params = SessionActiveListParams(currentSessionId = "sess-active-curr-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params) + + val expected = + buildJsonObject { + put("current_session_id", "sess-active-curr-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("current_session_id"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("approval.pending", RpcMethods.APPROVAL_PENDING.name) + assertEquals("approval.received", RpcMethods.APPROVAL_RECEIVED.name) + assertEquals("approval.respond", RpcMethods.APPROVAL_RESPOND.name) + assertEquals("session.active_list", RpcMethods.SESSION_ACTIVE_LIST.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_PENDING)) + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RECEIVED)) + assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RESPOND)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_ACTIVE_LIST)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "pending") + put("count", 3) + put("nested", buildJsonObject { put("detail", "value") }) + } + + val decodedPending: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_PENDING.result, + arbitraryPayload, + ) + val decodedReceived: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_RECEIVED.result, + arbitraryPayload, + ) + val decodedRespond: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.APPROVAL_RESPOND.result, + arbitraryPayload, + ) + val decodedActiveList: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_ACTIVE_LIST.result, + arbitraryPayload, + ) + + assertEquals(arbitraryPayload, decodedPending) + assertEquals(arbitraryPayload, decodedReceived) + assertEquals(arbitraryPayload, decodedRespond) + assertEquals(arbitraryPayload, decodedActiveList) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt new file mode 100644 index 00000000..46adba43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/BillingVaultTypesTest.kt @@ -0,0 +1,333 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BillingVaultTypesTest { + @Test + fun emptyParamsEncodesToEmptyJsonObject() { + val params = EmptyParams + val encoded = OkHttpProvider.json.encodeToJsonElement(EmptyParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionPreviewParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionPreviewParams(subscriptionTypeId = "pro-monthly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "pro-monthly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionPreviewParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionPreviewParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun subscriptionChangeParamsEncodesAllFieldsWhenPresent() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-2", + cancel = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-2") + put("cancel", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsEncodesExplicitFalseForCancel() { + val params = + SubscriptionChangeParams( + subscriptionTypeId = "tier-1", + cancel = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "tier-1") + put("cancel", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionChangeParamsOmitsOnlyNullFields() { + val paramsOnlyCancel = SubscriptionChangeParams(subscriptionTypeId = null, cancel = false) + val encodedOnlyCancel = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyCancel) + val expectedOnlyCancel = + buildJsonObject { + put("cancel", false) + } + assertEquals(expectedOnlyCancel, encodedOnlyCancel) + assertTrue(encodedOnlyCancel is JsonObject) + assertEquals(setOf("cancel"), (encodedOnlyCancel as JsonObject).keys) + + val paramsOnlyType = SubscriptionChangeParams(subscriptionTypeId = "tier-3", cancel = null) + val encodedOnlyType = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyType) + val expectedOnlyType = + buildJsonObject { + put("subscription_type_id", "tier-3") + } + assertEquals(expectedOnlyType, encodedOnlyType) + assertTrue(encodedOnlyType is JsonObject) + assertEquals(setOf("subscription_type_id"), (encodedOnlyType as JsonObject).keys) + + val paramsEmpty = SubscriptionChangeParams(subscriptionTypeId = null, cancel = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun subscriptionUpgradeParamsEncodesSubscriptionTypeIdWhenPresent() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = "enterprise-yearly") + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = + buildJsonObject { + put("subscription_type_id", "enterprise-yearly") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys) + } + + @Test + fun subscriptionUpgradeParamsOmitsNullSubscriptionTypeId() { + val params = SubscriptionUpgradeParams(subscriptionTypeId = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultSourceSetParamsEncodesAllFieldsWhenPresent() { + val params = + VaultSourceSetParams( + name = "1password", + enabled = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "1password") + put("enabled", true) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsEncodesExplicitFalseForEnabled() { + val params = + VaultSourceSetParams( + name = "bitwarden", + enabled = false, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "bitwarden") + put("enabled", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys) + } + + @Test + fun vaultSourceSetParamsOmitsNullFields() { + val params = VaultSourceSetParams(name = "keepass", enabled = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "keepass") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultSourceSetParams(name = null, enabled = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultUnlockParamsEncodesAllFieldsWhenPresent() { + val params = + VaultUnlockParams( + name = "local-vault", + password = "secret-password", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + put("password", "secret-password") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name", "password"), (encoded as JsonObject).keys) + } + + @Test + fun vaultUnlockParamsOmitsNullFields() { + val params = VaultUnlockParams(name = "local-vault", password = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "local-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + + val paramsEmpty = VaultUnlockParams(name = null, password = null) + val encodedEmpty = + OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), paramsEmpty) + val expectedEmpty = buildJsonObject {} + assertEquals(expectedEmpty, encodedEmpty) + assertTrue(encodedEmpty is JsonObject) + assertTrue((encodedEmpty as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesEmptyJsonObjectWhenNameNull() { + val params = VaultLockParams(name = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun vaultLockParamsEncodesNameWhenSet() { + val params = VaultLockParams(name = "primary-vault") + val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params) + + val expected = + buildJsonObject { + put("name", "primary-vault") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("name"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("subscription.state", RpcMethods.SUBSCRIPTION_STATE.name) + assertEquals("usage.bars", RpcMethods.USAGE_BARS.name) + assertEquals("subscription.resume", RpcMethods.SUBSCRIPTION_RESUME.name) + assertEquals("vault.list", RpcMethods.VAULT_LIST.name) + assertEquals("vault.sources", RpcMethods.VAULT_SOURCES.name) + assertEquals("subscription.preview", RpcMethods.SUBSCRIPTION_PREVIEW.name) + assertEquals("subscription.change", RpcMethods.SUBSCRIPTION_CHANGE.name) + assertEquals("subscription.upgrade", RpcMethods.SUBSCRIPTION_UPGRADE.name) + assertEquals("vault.source.set", RpcMethods.VAULT_SOURCE_SET.name) + assertEquals("vault.unlock", RpcMethods.VAULT_UNLOCK.name) + assertEquals("vault.lock", RpcMethods.VAULT_LOCK.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_STATE)) + assertTrue(RpcMethods.all.contains(RpcMethods.USAGE_BARS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_RESUME)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCES)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_PREVIEW)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_CHANGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_UPGRADE)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCE_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_UNLOCK)) + assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LOCK)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("tier", "pro") + put("credits", 4200) + put("details", buildJsonObject { put("active", true) }) + } + + val methods = + listOf( + RpcMethods.SUBSCRIPTION_STATE, + RpcMethods.USAGE_BARS, + RpcMethods.SUBSCRIPTION_RESUME, + RpcMethods.VAULT_LIST, + RpcMethods.VAULT_SOURCES, + RpcMethods.SUBSCRIPTION_PREVIEW, + RpcMethods.SUBSCRIPTION_CHANGE, + RpcMethods.SUBSCRIPTION_UPGRADE, + RpcMethods.VAULT_SOURCE_SET, + RpcMethods.VAULT_UNLOCK, + RpcMethods.VAULT_LOCK, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method ${method.name} must pass raw result untouched", arbitraryPayload, decoded) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt new file mode 100644 index 00000000..58689dae --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ChatCommandsTypesTest.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatCommandsTypesTest { + private val json = OkHttpProvider.json + + @Test + fun commandsCatalogEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + json.encodeToJsonElement(CommandsCatalogParams.serializer(), CommandsCatalogParams), + ) + } + + @Test + fun commandDispatchKeepsEmptyArgAndOmitsNullOnes() { + assertEquals( + buildJsonObject { + put("name", "help") + put("arg", "") + put("session_id", "s1") + }, + json.encodeToJsonElement( + CommandDispatchParams.serializer(), + CommandDispatchParams(name = "help", arg = "", sessionId = "s1"), + ), + ) + assertEquals( + buildJsonObject { put("name", "help") }, + json.encodeToJsonElement(CommandDispatchParams.serializer(), CommandDispatchParams(name = "help")), + ) + } + + @Test + fun slashExecEncodesSessionAndCommand() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("command", "/status") + }, + json.encodeToJsonElement( + SlashExecParams.serializer(), + SlashExecParams(sessionId = "s1", command = "/status"), + ), + ) + } + + @Test + fun fileAttachSendsDataUrlAndNameWithoutPath() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("data_url", "data:text/plain;base64,QQ==") + put("name", "a.txt") + }, + json.encodeToJsonElement( + FileAttachParams.serializer(), + FileAttachParams(sessionId = "s1", dataUrl = "data:text/plain;base64,QQ==", name = "a.txt"), + ), + ) + } + + @Test + fun imageAttachBytesEncodesContentBase64FilenameAndExt() { + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("content_base64", "data:image/png;base64,QQ==") + put("filename", "p.png") + put("ext", "png") + }, + json.encodeToJsonElement( + ImageAttachBytesParams.serializer(), + ImageAttachBytesParams( + sessionId = "s1", + contentBase64 = "data:image/png;base64,QQ==", + filename = "p.png", + ext = "png", + ), + ), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + listOf( + RpcMethods.COMMANDS_CATALOG to "commands.catalog", + RpcMethods.COMMAND_DISPATCH to "command.dispatch", + RpcMethods.SLASH_EXEC to "slash.exec", + RpcMethods.FILE_ATTACH to "file.attach", + RpcMethods.IMAGE_ATTACH_BYTES to "image.attach_bytes", + ).forEach { (method, name) -> + assertTrue("$name not in RpcMethods.all", method in RpcMethods.all) + assertEquals(name, method.name) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt new file mode 100644 index 00000000..8f786b28 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConfigModelTypesTest.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConfigModelTypesTest { + private val json = OkHttpProvider.json + + @Test + fun configSetEncodesOnlyKeyAndValueByDefault() { + assertEquals( + buildJsonObject { + put("key", "fast") + put("value", "normal") + }, + json.encodeToJsonElement(ConfigSetParams.serializer(), ConfigSetParams(key = "fast", value = "normal")), + ) + } + + @Test + fun configSetEncodesSessionScopeAndConfirmWhenPresent() { + assertEquals( + buildJsonObject { + put("key", "model") + put("value", "gpt-4o --provider openai --session") + put("session_id", "s1") + put("scope", "global") + put("confirm_expensive_model", true) + }, + json.encodeToJsonElement( + ConfigSetParams.serializer(), + ConfigSetParams( + key = "model", + value = "gpt-4o --provider openai --session", + sessionId = "s1", + scope = "global", + confirmExpensiveModel = true, + ), + ), + ) + } + + @Test + fun configGetOmitsNullSessionAndCwd() { + assertEquals( + buildJsonObject { + put("key", "reasoning") + put("session_id", "s1") + }, + json.encodeToJsonElement( + ConfigGetParams.serializer(), + ConfigGetParams(key = "reasoning", sessionId = "s1"), + ), + ) + } + + @Test + fun modelOptionsKeepsExplicitFalseAndOmitsUnsetFlags() { + assertEquals( + buildJsonObject { + put("refresh", true) + put("include_unconfigured", false) + }, + json.encodeToJsonElement( + ModelOptionsParams.serializer(), + ModelOptionsParams(refresh = true, includeUnconfigured = false), + ), + ) + assertEquals( + buildJsonObject { }, + json.encodeToJsonElement(ModelOptionsParams.serializer(), ModelOptionsParams()), + ) + } + + @Test + fun clientCapabilitiesAlwaysEncodesServerRequests() { + assertEquals( + buildJsonObject { put("server_requests", true) }, + json.encodeToJsonElement( + ClientCapabilitiesParams.serializer(), + ClientCapabilitiesParams(serverRequests = true), + ), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + listOf( + RpcMethods.CONFIG_SET to "config.set", + RpcMethods.CONFIG_GET to "config.get", + RpcMethods.MODEL_OPTIONS to "model.options", + RpcMethods.CLIENT_CAPABILITIES to "client.capabilities", + ).forEach { (method, name) -> + assertTrue("$name not in RpcMethods.all", method in RpcMethods.all) + assertEquals(name, method.name) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt new file mode 100644 index 00000000..49ea10cb --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectionOperationTypesTest.kt @@ -0,0 +1,257 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionOperationTypesTest { + @Test + fun connectionRespondParamsWithApprovedEnvAndAccountOwnerEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-approved-1", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "linear", + status = "approved", + detail = "User accepted linear integration", + env = mapOf("API_KEY" to "dummy_secret_key_123"), + ), + ), + settledBy = null, + ), + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-approved-1") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "linear") + put("status", "approved") + put("detail", "User accepted linear integration") + put( + "env", + buildJsonObject { + put("API_KEY", "dummy_secret_key_123") + }, + ) + }, + ) + }, + ) + }, + ) + put("profile", "work") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result", "profile"), (encoded as JsonObject).keys) + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("targets"), resultObj.keys) + assertFalse(resultObj.containsKey("settled_by")) + } + + @Test + fun connectionRespondParamsWithSkippedOmittedEnvAndSessionOwnerEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-xyz"), + opId = "op-skip-2", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "skipped", + detail = null, + env = null, + ), + ), + settledBy = null, + ), + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-xyz") + }, + ) + put("op_id", "op-skip-2") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("targets"), resultObj.keys) + assertFalse(resultObj.containsKey("settled_by")) + + val targetObj = (resultObj["targets"] as kotlinx.serialization.json.JsonArray)[0] as JsonObject + assertEquals(setOf("name", "status"), targetObj.keys) + assertFalse(targetObj.containsKey("env")) + assertFalse(targetObj.containsKey("detail")) + } + + @Test + fun connectionRespondParamsWithContinueAndOmittedTargetsEncodesExactWireJson() { + val params = + ConnectionRespondParams( + owner = ConnectorOwner.session("sess-cont-3"), + opId = "op-cont-3", + result = + ConnectionAnswer( + targets = null, + settledBy = "continue", + ), + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-cont-3") + }, + ) + put("op_id", "op-cont-3") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + + val resultObj = (encoded as JsonObject)["result"] as JsonObject + assertEquals(setOf("settled_by"), resultObj.keys) + assertFalse(resultObj.containsKey("targets")) + } + + @Test + fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndEncodesAllFieldsWhenProfilePresent() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-wake-1"), + opId = "op-wake-1", + profile = "staging", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-wake-1") + }, + ) + put("op_id", "op-wake-1") + put("profile", "staging") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndOmitsProfileWhenNull() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-wake-2", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-wake-2") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + } + + @Test + fun connectionRespondAndWakeAreRegisteredInGatewayContractRpcMethodsAll() { + val allMethods = RpcMethods.all + val respondMethod = allMethods.firstOrNull { it.name == "connection.respond" } + val wakeMethod = allMethods.firstOrNull { it.name == "connectors.operation.wake" } + + org.junit.Assert.assertNotNull("connection.respond must be registered in RpcMethods.all", respondMethod) + org.junit.Assert.assertNotNull("connectors.operation.wake must be registered in RpcMethods.all", wakeMethod) + + assertEquals(RpcMethods.CONNECTION_RESPOND, respondMethod) + assertEquals(RpcMethods.CONNECTORS_OPERATION_WAKE, wakeMethod) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt new file mode 100644 index 00000000..9203210c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ConnectorsTypesTest.kt @@ -0,0 +1,539 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectorsTypesTest { + @Test + fun connectorOwnerAccountEncodesToTypeAccountWithoutSessionId() { + val owner = ConnectorOwner.account() + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "account") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type"), (encoded as JsonObject).keys) + } + + @Test + fun connectorOwnerSessionEncodesToTypeSessionAndSessionId() { + val owner = ConnectorOwner.session("s1") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner) + + val expected = + buildJsonObject { + put("type", "session") + put("session_id", "s1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("type", "session_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsEncodesOwnerAndProfileWhenProfileProvided() { + val params = ConnectorsListParams(owner = ConnectorOwner.account(), profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsListParamsOmitsProfileWhenNull() { + val params = ConnectorsListParams(owner = ConnectorOwner.session("s1"), profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s1") + }, + ) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = listOf("slack", "github"), + reconnect = true, + profile = "personal", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("slack")) + add(JsonPrimitive("github")) + }, + ) + put("reconnect", true) + put("profile", "personal") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsConnectParamsEncodesExplicitFalseForReconnect() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.session("s2"), + connectors = listOf("jira"), + reconnect = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "s2") + }, + ) + put( + "connectors", + buildJsonArray { + add(JsonPrimitive("jira")) + }, + ) + put("reconnect", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors", "reconnect"), (encoded as JsonObject).keys) + assertFalse((encoded as JsonObject).containsKey("profile")) + } + + @Test + fun connectorsConnectParamsOmitsNullReconnectAndProfile() { + val params = + ConnectorsConnectParams( + owner = ConnectorOwner.account(), + connectors = emptyList(), + reconnect = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("connectors", buildJsonArray {}) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "connectors"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("sess-1"), + opId = "op-99", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "sess-1") + }, + ) + put("op_id", "op-99") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsOperationStatusParamsOmitsNullProfile() { + val params = + ConnectorsOperationStatusParams( + owner = ConnectorOwner.account(), + opId = "op-100", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params) + + val expected = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "account") + }, + ) + put("op_id", "op-100") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsEncodesProfileWhenPresent() { + val params = ConnectorsCatalogParams(profile = "default") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "default") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsCatalogParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsCatalogParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesAllFieldsWhenPresent() { + val params = ConnectorsAccountsParams(connector = "google", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "google") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsParamsOmitsNullFieldsResultingInEmptyJsonObject() { + val params = ConnectorsAccountsParams(connector = null, profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsAccountsParamsEncodesConnectorOnlyWhenProfileNull() { + val params = ConnectorsAccountsParams(connector = "github", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params) + + val expected = + buildJsonObject { + put("connector", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connector"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsEncodesConnectionIdAndProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-123", profile = "work") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-123") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsAccountsRemoveParamsOmitsNullProfile() { + val params = ConnectorsAccountsRemoveParams(connectionId = "conn-456", profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params) + + val expected = + buildJsonObject { + put("connection_id", "conn-456") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("connection_id"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsEncodesProfileWhenPresent() { + val params = ConnectorsPolicyGetParams(profile = "custom-profile") + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = + buildJsonObject { + put("profile", "custom-profile") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsPolicyGetParamsOmitsNullProfileResultingInEmptyJsonObject() { + val params = ConnectorsPolicyGetParams(profile = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun connectorsPolicySetParamsEncodesNestedChangeWithArraysUntouched() { + val nestedChange = + buildJsonObject { + put("action", "allow") + put( + "tools", + buildJsonArray { + add(JsonPrimitive("gmail.send")) + add(JsonPrimitive("gmail.list")) + }, + ) + put( + "scope", + buildJsonObject { + put("level", "read-write") + put( + "allowed_users", + buildJsonArray { + add(JsonPrimitive("alice")) + add(JsonPrimitive("bob")) + }, + ) + }, + ) + } + val params = + ConnectorsPolicySetParams( + change = nestedChange, + expectedRevision = "4", + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", nestedChange) + put("expected_revision", "4") + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision", "profile"), (encoded as JsonObject).keys) + assertEquals(nestedChange, (encoded as JsonObject)["change"]) + } + + @Test + fun connectorsPolicySetParamsOmitsNullProfile() { + val change = buildJsonObject { put("enabled", true) } + val params = + ConnectorsPolicySetParams( + change = change, + expectedRevision = "1", + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params) + + val expected = + buildJsonObject { + put("change", change) + put("expected_revision", "1") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("change", "expected_revision"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesAllFieldsWhenPresent() { + val params = + ConnectorsToolsParams( + slug = "gmail", + refresh = true, + profile = "work", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "gmail") + put("refresh", true) + put("profile", "work") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsEncodesExplicitFalseForRefresh() { + val params = + ConnectorsToolsParams( + slug = "slack", + refresh = false, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "slack") + put("refresh", false) + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug", "refresh"), (encoded as JsonObject).keys) + } + + @Test + fun connectorsToolsParamsOmitsNullRefreshAndProfile() { + val params = + ConnectorsToolsParams( + slug = "github", + refresh = null, + profile = null, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params) + + val expected = + buildJsonObject { + put("slug", "github") + } + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("slug"), (encoded as JsonObject).keys) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("connectors.list", RpcMethods.CONNECTORS_LIST.name) + assertEquals("connectors.connect", RpcMethods.CONNECTORS_CONNECT.name) + assertEquals("connectors.operation.status", RpcMethods.CONNECTORS_OPERATION_STATUS.name) + assertEquals("connectors.catalog", RpcMethods.CONNECTORS_CATALOG.name) + assertEquals("connectors.accounts", RpcMethods.CONNECTORS_ACCOUNTS.name) + assertEquals("connectors.accounts.remove", RpcMethods.CONNECTORS_ACCOUNTS_REMOVE.name) + assertEquals("connectors.policy.get", RpcMethods.CONNECTORS_POLICY_GET.name) + assertEquals("connectors.policy.set", RpcMethods.CONNECTORS_POLICY_SET.name) + assertEquals("connectors.tools", RpcMethods.CONNECTORS_TOOLS.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CONNECT)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_OPERATION_STATUS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CATALOG)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS_REMOVE)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_GET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_SET)) + assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_TOOLS)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put( + "items", + buildJsonArray { + add(JsonPrimitive("elem1")) + add(JsonPrimitive("elem2")) + }, + ) + put("nested", buildJsonObject { put("revision", 10) }) + } + + val methods = + listOf( + RpcMethods.CONNECTORS_LIST, + RpcMethods.CONNECTORS_CONNECT, + RpcMethods.CONNECTORS_OPERATION_STATUS, + RpcMethods.CONNECTORS_CATALOG, + RpcMethods.CONNECTORS_ACCOUNTS, + RpcMethods.CONNECTORS_ACCOUNTS_REMOVE, + RpcMethods.CONNECTORS_POLICY_GET, + RpcMethods.CONNECTORS_POLICY_SET, + RpcMethods.CONNECTORS_TOOLS, + ) + + for (method in methods) { + val decoded: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + method.result, + arbitraryPayload, + ) + assertEquals("Method " + method.name + " must pass raw result untouched", arbitraryPayload, decoded) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt new file mode 100644 index 00000000..c5777015 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ParamsDescriptorCheckTest.kt @@ -0,0 +1,107 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.ws.GatewayContract +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ParamsDescriptorCheckTest { + @Serializable + private data class ValidParams( + @SerialName("session_id") val sessionId: String, + @SerialName("last_seen") val lastSeen: Int? = null, + ) + + @Serializable + private data class ExtraFieldParams( + @SerialName("session_id") val sessionId: String, + @SerialName("since_seq") val sinceSeq: Int? = null, + ) + + @Serializable + private data class MissingRequiredParams( + @SerialName("last_seen") val lastSeen: Int? = null, + ) + + @Serializable + private data class DefaultOnRequiredParams( + @SerialName("session_id") val sessionId: String = "default_id", + ) + + @Serializable + private data class NullableRequiredParams( + @SerialName("session_id") val sessionId: String?, + ) + + @Serializable + private data class DeliverMissingProfileParams( + val message: String, + ) + + @Test + fun correctClassPasses() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + ValidParams.serializer().descriptor, + ) + assertTrue(problems.joinToString("\n"), problems.isEmpty()) + } + + @Test + fun extraFieldIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + ExtraFieldParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "unknown param 'since_seq'" in it }) + } + + @Test + fun missingRequiredFieldIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + MissingRequiredParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "missing required param 'session_id'" in it }) + } + + @Test + fun requiredFieldWithDefaultValueIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + DefaultOnRequiredParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "required param 'session_id' must not be optional" in it }) + } + + @Test + fun requiredFieldTypedNullableIsRejected() { + val problems = + GatewayContract.paramsDescriptorProblems( + "session.events.since", + NullableRequiredParams.serializer().descriptor, + ) + assertEquals( + 1, + problems.count { "required param 'session_id' must not be nullable unless schema allows null" in it }, + ) + } + + @Test + fun omittingRequiredProfileIsRejectedLikeAnyRequiredParam() { + // bot_relay.deliver has required: ["profile", "message"]. Profile injection is skipped when no + // profile is active, so `profile` gets no special treatment: the params class must declare it. + val problems = + GatewayContract.paramsDescriptorProblems( + "bot_relay.deliver", + DeliverMissingProfileParams.serializer().descriptor, + ) + assertEquals(1, problems.count { "missing required param 'profile'" in it }) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt new file mode 100644 index 00000000..8e1de470 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/PluginsProjectsTypesTest.kt @@ -0,0 +1,60 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginsProjectsTypesTest { + private fun encode(params: PluginsManageParams) = + OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params) + + @Test + fun listEncodesOnlyAction() { + assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list"))) + } + + @Test + fun removeEncodesActionAndName() { + assertEquals( + buildJsonObject { + put("action", "remove") + put("name", "demo") + }, + encode(PluginsManageParams(action = "remove", name = "demo")), + ) + } + + @Test + fun settingsEncodesKeyValuesAndExplicitProfile() { + val values = buildJsonObject { put("port", JsonPrimitive(8080)) } + assertEquals( + buildJsonObject { + put("action", "settings") + put("profile", "staging") + put("key", "camofox") + put("values", values) + }, + encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")), + ) + } + + @Test + fun projectsListEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all) + assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all) + assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name) + assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt new file mode 100644 index 00000000..443aae32 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProcessTypesTest.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProcessTypesTest { + @Test + fun killEncodesExactlySessionAndProcessIds() { + val encoded = + OkHttpProvider.json.encodeToJsonElement( + ProcessKillParams.serializer(), + ProcessKillParams(sessionId = "s1", processId = "p1"), + ) + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("process_id", "p1") + }, + encoded, + ) + } + + @Test + fun stopEncodesAnEmptyObjectAndDecodesKilledCount() { + val encoded = OkHttpProvider.json.encodeToJsonElement(ProcessStopParams.serializer(), ProcessStopParams) + assertEquals(buildJsonObject {}, encoded) + val result = OkHttpProvider.json.decodeFromString(ProcessStopResult.serializer(), """{"killed":3}""") + assertEquals(ProcessStopResult(killed = 3), result) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROCESS_STOP in RpcMethods.all) + assertEquals("process.stop", RpcMethods.PROCESS_STOP.name) + assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all) + assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all) + assertEquals("process.list", RpcMethods.PROCESS_LIST.name) + assertEquals("process.kill", RpcMethods.PROCESS_KILL.name) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt new file mode 100644 index 00000000..1083f9bc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/ProfilesTypesTest.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfilesTypesTest { + private fun encode(params: ProfilesConfigureParams) = + OkHttpProvider.json.encodeToJsonElement(ProfilesConfigureParams.serializer(), params) + + @Test + fun configureEncodesNameAndUiMetaOnly() { + val meta = buildJsonObject { put("hermes-bots-groups", buildJsonObject { put("version", 3) }) } + assertEquals( + buildJsonObject { + put("name", "default") + put("ui_meta", meta) + }, + encode(ProfilesConfigureParams(name = "default", uiMeta = meta)), + ) + } + + @Test + fun configureIncludesSoulWhenPresent() { + val meta = buildJsonObject { put("hermes-bots", buildJsonObject { put("title", "Scout") }) } + assertEquals( + buildJsonObject { + put("name", "scout") + put("ui_meta", meta) + put("soul", "# Scout") + }, + encode(ProfilesConfigureParams(name = "scout", uiMeta = meta, soul = "# Scout")), + ) + } + + @Test + fun listEncodesAsEmptyObject() { + assertEquals( + buildJsonObject { }, + OkHttpProvider.json.encodeToJsonElement(ProfilesListParams.serializer(), ProfilesListParams), + ) + } + + @Test + fun descriptorsAreRegisteredWithContractNames() { + assertTrue(RpcMethods.PROFILES_LIST in RpcMethods.all) + assertTrue(RpcMethods.PROFILES_CONFIGURE in RpcMethods.all) + assertEquals("profiles.list", RpcMethods.PROFILES_LIST.name) + assertEquals("profiles.configure", RpcMethods.PROFILES_CONFIGURE.name) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt new file mode 100644 index 00000000..9ca3cc21 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionControlTypesTest.kt @@ -0,0 +1,119 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionControlTypesTest { + @Test + fun sessionInterruptParamsEncodesSessionIdExactly() { + val params = SessionInterruptParams(sessionId = "sess-int-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionInterruptParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-int-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCorrectionParamsEncodesSessionIdAndTextExactly() { + val params = SessionCorrectionParams(sessionId = "sess-corr-1", text = "stop that action") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCorrectionParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-corr-1") + put("text", "stop that action") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun sessionInterruptResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "interrupted") + put("unknown_key", "ignored_value") + put("extra_flag", true) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionInterruptResult.serializer(), + payload, + ) + + assertEquals("interrupted", decoded.status) + } + + @Test + fun sessionInterruptResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionInterruptResult.serializer(), + payload, + ) + + assertNull(decoded.status) + } + + @Test + fun sessionCorrectionResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "applied") + put("text", "stop that action") + put("ignored_num", 42) + put("nested", buildJsonObject { put("ignored", "yes") }) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCorrectionResult.serializer(), + payload, + ) + + assertEquals("applied", decoded.status) + assertEquals("stop that action", decoded.text) + } + + @Test + fun sessionCorrectionResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCorrectionResult.serializer(), + payload, + ) + + assertNull(decoded.status) + assertNull(decoded.text) + } + + @Test + fun rpcMethodsRegistrationMatchesControlContract() { + assertEquals("session.interrupt", RpcMethods.SESSION_INTERRUPT.name) + assertEquals("session.steer", RpcMethods.SESSION_STEER.name) + assertEquals("session.redirect", RpcMethods.SESSION_REDIRECT.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_INTERRUPT)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_STEER)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_REDIRECT)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt new file mode 100644 index 00000000..d4b4b76b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionEventsSinceTypesTest.kt @@ -0,0 +1,126 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionEventsSinceTypesTest { + @Test + fun paramsWithLastSeenEncodesToExpectedJsonObject() { + val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = 7) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s1") + put("last_seen", 7) + } + + assertEquals(expected, encoded) + } + + @Test + fun paramsWithNullLastSeenEncodesToSessionIdOnly() { + val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = null) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun resultDecodesFullRealisticPayloadIgnoringUnknownKeys() { + val payload = + buildJsonObject { + put( + "events", + kotlinx.serialization.json.buildJsonArray { + add( + buildJsonObject { + put("type", "message.token") + put("seq", 1) + put("session_id", "s1") + }, + ) + }, + ) + put("latest_seq", 42) + put("truncated", false) + put("epoch", "ep-999") + // Extra unknown keys from server that must be ignored + put("count", 1) + put( + "open_requests", + kotlinx.serialization.json.buildJsonArray { + add(JsonPrimitive("req-1")) + }, + ) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertEquals("ep-999", decoded.epoch) + assertEquals(42, decoded.latestSeq) + assertEquals(false, decoded.truncated) + assertEquals(1, decoded.events?.size) + } + + @Test + fun resultDecodesEmptyObjectToAllNullDefaults() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertNull(decoded.events) + assertNull(decoded.latestSeq) + assertNull(decoded.truncated) + assertNull(decoded.epoch) + } + + @Test + fun resultToleratesTruncatedAbsent() { + val payload = + buildJsonObject { + put("latest_seq", 10) + put("epoch", "epoch-1") + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionEventsSinceResult.serializer(), + payload, + ) + + assertNull(decoded.truncated) + assertEquals(10, decoded.latestSeq) + assertEquals("epoch-1", decoded.epoch) + assertNull(decoded.events) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.events.since", RpcMethods.SESSION_EVENTS_SINCE.name) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_EVENTS_SINCE)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt new file mode 100644 index 00000000..f32892a1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionMiscTypesTest.kt @@ -0,0 +1,295 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionMiscTypesTest { + @Test + fun sessionListParamsEncodesToEmptyJsonObject() { + val params = SessionListParams + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionListParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun sessionIdParamsEncodesSessionIdExactly() { + val params = SessionIdParams(sessionId = "sess-misc-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-misc-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchParams(sessionId = "sess-branch-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchParams(sessionId = "sess-branch-2", name = "experiment-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-2") + put("name", "experiment-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchParams(sessionId = "sess-branch-3", name = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-branch-3") + put("name", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdOnlyWhenNameNull() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsEncodesSessionIdAndNameWhenPresent() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-2", name = "full-tree-fork") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-2") + put("name", "full-tree-fork") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionBranchWholeParamsPassesBlankNameThroughAsIs() { + val params = SessionBranchWholeParams(sessionId = "sess-whole-3", name = " \t ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-whole-3") + put("name", " \t ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdOnlyWhenFocusTopicNull() { + val params = SessionCompressParams(sessionId = "sess-comp-1") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-1") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsEncodesSessionIdAndFocusTopicWhenPresent() { + val params = SessionCompressParams(sessionId = "sess-comp-2", focusTopic = "summarize-db-schema") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-2") + put("focus_topic", "summarize-db-schema") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCompressParamsPassesBlankFocusTopicThroughAsIs() { + val params = SessionCompressParams(sessionId = "sess-comp-3", focusTopic = " ") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-comp-3") + put("focus_topic", " ") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwParamsEncodesSessionIdAndText() { + val params = PromptBtwParams(sessionId = "sess-btw-1", text = "by the way, take note") + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptBtwParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "sess-btw-1") + put("text", "by the way, take note") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun promptBtwResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("task_id", "task-xyz-987") + put("unknown_flag", true) + put("extra_num", 100) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertEquals("task-xyz-987", decoded.taskId) + } + + @Test + fun promptBtwResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptBtwResult.serializer(), + payload, + ) + + assertNull(decoded.taskId) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.list", RpcMethods.SESSION_LIST.name) + assertEquals("session.branch", RpcMethods.SESSION_BRANCH.name) + assertEquals("session.branch_whole", RpcMethods.SESSION_BRANCH_WHOLE.name) + assertEquals("session.compress", RpcMethods.SESSION_COMPRESS.name) + assertEquals("session.context_breakdown", RpcMethods.SESSION_CONTEXT_BREAKDOWN.name) + assertEquals("session.usage", RpcMethods.SESSION_USAGE.name) + assertEquals("prompt.btw", RpcMethods.PROMPT_BTW.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_LIST)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH_WHOLE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_COMPRESS)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CONTEXT_BREAKDOWN)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_USAGE)) + assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_BTW)) + } + + @Test + fun jsonElementPassthroughMethodsDecodeResultUntouched() { + val arbitraryPayload = + buildJsonObject { + put("status", "ok") + put("tokens", 4567) + put("nested", buildJsonObject { put("item", "val") }) + } + + val decodedList: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_LIST.result, + arbitraryPayload, + ) + val decodedBranch: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH.result, + arbitraryPayload, + ) + val decodedBranchWhole: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_BRANCH_WHOLE.result, + arbitraryPayload, + ) + val decodedCompress: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_COMPRESS.result, + arbitraryPayload, + ) + val decodedBreakdown: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_CONTEXT_BREAKDOWN.result, + arbitraryPayload, + ) + val decodedUsage: JsonElement = + OkHttpProvider.json.decodeFromJsonElement( + RpcMethods.SESSION_USAGE.result, + arbitraryPayload, + ) + + assertEquals(arbitraryPayload, decodedList) + assertEquals(arbitraryPayload, decodedBranch) + assertEquals(arbitraryPayload, decodedBranchWhole) + assertEquals(arbitraryPayload, decodedCompress) + assertEquals(arbitraryPayload, decodedBreakdown) + assertEquals(arbitraryPayload, decodedUsage) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt new file mode 100644 index 00000000..bf5c9c44 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SessionPromptTypesTest.kt @@ -0,0 +1,290 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionPromptTypesTest { + @Test + fun createWithSourceOnlyEncodesExactlySource() { + val params = SessionCreateParams(source = "cli") + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("source", "cli") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("source"), (encoded as JsonObject).keys) + } + + @Test + fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() { + val params = + SessionCreateParams( + profile = "work", + title = "Team sync", + source = "matrix", + hidden = true, + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("source", "matrix") + put("profile", "work") + put("title", "Team sync") + put("hidden", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive + assertTrue(hiddenElement != null && hiddenElement.isString.not()) + } + + @Test + fun allNullCreateParamsEncodeToEmptyObject() { + val params = SessionCreateParams() + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = buildJsonObject {} + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertTrue((encoded as JsonObject).isEmpty()) + } + + @Test + fun createWithExplicitFalseHiddenIsEncoded() { + val params = SessionCreateParams(hidden = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params) + + val expected = + buildJsonObject { + put("hidden", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("hidden"), (encoded as JsonObject).keys) + } + + @Test + fun resumeEncodesSessionIdAndOmitMessagesExactly() { + val params = SessionResumeParams(sessionId = "s-123", omitMessages = true) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-123") + put("omit_messages", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys) + } + + @Test + fun resumeWithProfileEncodesExpectedThreeKeys() { + val params = + SessionResumeParams( + sessionId = "s-456", + omitMessages = true, + profile = "personal", + ) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-456") + put("omit_messages", true) + put("profile", "personal") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys) + } + + @Test + fun resumeWithExplicitFalseOmitMessagesIsEncoded() { + val params = SessionResumeParams(sessionId = "s-789", omitMessages = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-789") + put("omit_messages", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys) + } + + @Test + fun submitEncodesSessionIdAndText() { + val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes") + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-100") + put("text", "hello hermes") + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys) + } + + @Test + fun submitWithQueuedTrueEncodesQueued() { + val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true) + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-101") + put("text", "queued prompt") + put("queued", true) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys) + } + + @Test + fun submitWithExplicitFalseQueuedIsEncoded() { + val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false) + val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params) + + val expected = + buildJsonObject { + put("session_id", "s-102") + put("text", "direct prompt") + put("queued", false) + } + + assertEquals(expected, encoded) + assertTrue(encoded is JsonObject) + assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys) + } + + @Test + fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("session_id", "sess-created-1") + put("stored_session_id", "stored-1") + put("created_at", 123456789) + put("unknown_flag", true) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCreateResult.serializer(), + payload, + ) + + assertEquals("sess-created-1", decoded.sessionId) + assertEquals("stored-1", decoded.storedSessionId) + } + + @Test + fun sessionCreateResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionCreateResult.serializer(), + payload, + ) + + assertNull(decoded.sessionId) + assertNull(decoded.storedSessionId) + } + + @Test + fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("session_id", "sess-resumed-1") + put("active_model", "gemini") + put("unread_count", 0) + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionResumeResult.serializer(), + payload, + ) + + assertEquals("sess-resumed-1", decoded.sessionId) + } + + @Test + fun sessionResumeResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + SessionResumeResult.serializer(), + payload, + ) + + assertNull(decoded.sessionId) + } + + @Test + fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() { + val payload = + buildJsonObject { + put("status", "accepted") + put("ticket_id", "tick-42") + put("queued_at", "2026-09-29T10:00:00Z") + } + + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptSubmitResult.serializer(), + payload, + ) + + assertEquals("accepted", decoded.status) + } + + @Test + fun promptSubmitResultDecodesEmptyObjectToNulls() { + val payload = buildJsonObject {} + val decoded = + OkHttpProvider.json.decodeFromJsonElement( + PromptSubmitResult.serializer(), + payload, + ) + + assertNull(decoded.status) + } + + @Test + fun rpcMethodsRegistrationMatchesContract() { + assertEquals("session.create", RpcMethods.SESSION_CREATE.name) + assertEquals("session.resume", RpcMethods.SESSION_RESUME.name) + assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name) + + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE)) + assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME)) + assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt new file mode 100644 index 00000000..9c888002 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/contract/SubagentTypesTest.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.data.ws.contract + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubagentTypesTest { + @Test + fun listEncodesOnlySessionId() { + val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), SessionIdParams("s1")) + assertEquals(buildJsonObject { put("session_id", "s1") }, encoded) + } + + @Test + fun tailEncodesExactlyTheTwoRequiredIds() { + val encoded = + OkHttpProvider.json.encodeToJsonElement( + SubagentTailParams.serializer(), + SubagentTailParams("s1", "sub-1"), + ) + assertEquals( + buildJsonObject { + put("session_id", "s1") + put("subagent_id", "sub-1") + }, + encoded, + ) + } + + @Test + fun descriptorsAreRegisteredAndPreserveRawResults() { + assertTrue(RpcMethods.SUBAGENT_LIST in RpcMethods.all) + assertTrue(RpcMethods.SUBAGENT_TAIL in RpcMethods.all) + assertEquals("subagent.list", RpcMethods.SUBAGENT_LIST.name) + assertEquals("subagent.tail", RpcMethods.SUBAGENT_TAIL.name) + val raw: JsonElement = buildJsonObject { put("future_field", "retained") } + assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_LIST.result, raw)) + assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_TAIL.result, raw)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt index 2a641154..abd1ca1c 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionControllerTest.kt @@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns true every { AuthManager.getToken() } returns null every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns null every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns "some-token" every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns true every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest { every { AuthManager.isGatedMode() } returns false every { AuthManager.getToken() } returns "some-token" every { AuthManager.isKeepConnectedInBackground() } returns true + every { AuthManager.isNotifySessionCompletions() } returns false every { AuthManager.isAutoReconnect() } returns false every { ChatNotificationService.isAppInForeground() } returns false every { HermesWsClient.pendingReply } returns false @@ -407,4 +411,55 @@ class BackgroundConnectionControllerTest { unmockkObject(NetworkMonitor) } } + + @Test + fun testOnKeepConnectedDisabled_whenBackgrounded_releasesLeaseAndStopsService() { + var leaseReleased = false + var serviceStopped = false + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + ) + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + releaseLease = { leaseReleased = true }, + requestServiceStop = { serviceStopped = true }, + ) + + controller.onKeepConnectedDisabled() + + assertTrue(leaseReleased) + assertTrue(serviceStopped) + } + + @Test + fun testOnKeepConnectedDisabled_whenReplyPending_keepsServiceAndRefreshesNotification() { + var serviceStopped = false + var refreshed: BackgroundNotificationState? = null + val snapshot = + BackgroundConnectionSnapshot( + appInForeground = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + ) + val controller = + BackgroundConnectionController( + snapshotProvider = { snapshot }, + releaseLease = {}, + requestServiceStop = { serviceStopped = true }, + onNotificationStateChanged = { refreshed = it }, + ) + + controller.onKeepConnectedDisabled() + + assertFalse(serviceStopped) + assertEquals(BackgroundNotificationState.WaitingForReplies, refreshed) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt index fda85801..c0bbc81a 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/BackgroundConnectionPolicyTest.kt @@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest { assertTrue(decision.shouldHoldPersistentLease) assertEquals(BackgroundNotificationState.Connecting, decision.notificationState) } + + // ── Session-completion notify opt-in (implies the keep-connected lease) ── + + @Test + fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = false, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + notifyCompletionsOptIn = true, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertTrue(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState) + } + + @Test + fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() { + val state = + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + notifyCompletionsOptIn = false, + ) + val decision = BackgroundConnectionPolicy.evaluate(state) + assertTrue(decision.shouldHoldService) + assertFalse(decision.shouldHoldPersistentLease) + assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState) + } } 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 71e116e7..225a33c1 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/ChatNotificationServiceTest.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.notification import android.content.Context +import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.WsEvent import io.mockk.mockk import io.mockk.verify @@ -164,3 +165,116 @@ class ChatNotificationServiceTest { assertEquals("Answer\ncontinued", plan.correlationText) } } + +class ChatNotificationServiceActiveListTest { + @Test + fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() { + val raw = + """ + {"sessions":[ + {"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0}, + {"id":"rt2","session_key":"stored-2","status":"idle"} + ]} + """.trimIndent() + + val found = parseActiveSessionLookup(raw, "rt1") + assertEquals("stored-1", found?.storedId) + assertEquals("Nightly report", found?.title) + + // Row without a title: notification falls back to the app name. + val untitled = parseActiveSessionLookup(raw, "rt2") + assertEquals("stored-2", untitled?.storedId) + assertNull(untitled?.title) + } + + @Test + fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() { + val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}""" + val element = + kotlinx.serialization.json.Json + .parseToJsonElement(raw) + + val found = parseActiveSessionLookup(element, "rt9") + assertEquals("stored-9", found?.storedId) + assertEquals("T", found?.title) + } + + @Test + fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() { + val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}""" + + assertNull(parseActiveSessionLookup(raw, "rt1")) + assertNull(parseActiveSessionLookup(raw, "missing")) + assertNull(parseActiveSessionLookup(null, "rt1")) + assertNull(parseActiveSessionLookup(raw, "")) + assertNull(parseActiveSessionLookup("not json", "rt1")) + } +} + +class MessageCompleteRouteTest { + @Test + fun `active session routes to the reply path`() { + val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `toggle off keeps legacy behavior for foreign sessions`() { + val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `toggle on routes foreign sessions to the plain alert`() { + assertTrue( + messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is + MessageCompleteRoute.ForeignSession, + ) + } + + @Test + fun `toggle on with a cleared holder routes the pinned alert`() { + // After a background reconnect the holder is cleared: the user's own + // reply lands in the plain alert (resolved stored id keeps the tap + // working) instead of being dropped. + assertTrue( + messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is + MessageCompleteRoute.ForeignSession, + ) + } + + @Test + fun `toggle off with a cleared holder still uses the reply path`() { + val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1") + assertEquals(MessageCompleteRoute.Reply, route) + } + + @Test + fun `legacy reply route retires the service when the wait is over`() { + // Toggle off + holder cleared by a reconnect: the route is Reply, and the + // Reply branch always ends in onReplyCompleted(generation). + assertEquals( + MessageCompleteRoute.Reply, + messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"), + ) + var completedGeneration: Long? = null + val controller = + BackgroundConnectionController( + snapshotProvider = { + BackgroundConnectionSnapshot( + appInForeground = false, + isDeparting = false, + keepConnectedOptIn = false, + pendingReply = true, + isEligibleForConnection = true, + status = ConnectionStatus.CONNECTED, + isAutoReconnect = true, + hasActiveNetwork = true, + ) + }, + requestServiceComplete = { gen -> completedGeneration = gen }, + ) + controller.onReplyCompleted(7L) + assertEquals(7L, completedGeneration) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt index c287e05f..e9b0aaa3 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt @@ -21,6 +21,7 @@ import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -108,7 +109,7 @@ class NotificationReplyReceiverTest { val params = arg>(1) val result = if (method == WsMethods.SESSION_RESUME) { - mapOf("session_id" to "runtime-${params["session_id"]}") + mapOf("session_id" to "runtime-${params.str("session_id")}") } else { mapOf("accepted" to true) } @@ -147,7 +148,7 @@ class NotificationReplyReceiverTest { verify { HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" }, + match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" }, any(), ) } @@ -329,19 +330,19 @@ class NotificationReplyReceiverTest { verify { HermesWsClient.request( WsMethods.SESSION_RESUME, - match { it["session_id"] == "session-abc" && it["omit_messages"] == true }, + match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) }, any(), ) HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "runtime-session-abc" }, + match { it.str("session_id") == "runtime-session-abc" }, any(), ) } verify(inverse = true) { HermesWsClient.request( WsMethods.PROMPT_SUBMIT, - match { it["session_id"] == "stale-runtime" }, + match { it.str("session_id") == "stale-runtime" }, any(), ) } @@ -412,16 +413,23 @@ class NotificationReplyReceiverTest { kotlinx.coroutines.delay(10000) } - receiver.onReceive(mockContext, mockIntent) + // Same timeout path as production, with a short deadline instead of 5s. + val shortTimeoutReceiver = + object : NotificationReplyReceiver() { + override fun goAsyncCompat(): BroadcastReceiver.PendingResult = mockPendingResult - // Wait longer than the 5-second timeout, but less than the 10-second delay - Thread.sleep(6000) + override fun buildReplyNotification(context: Context): Notification = mockNotification + + override val persistenceTimeoutMs: Long = 50L + } + + shortTimeoutReceiver.onReceive(mockContext, mockIntent) // Timeout should be caught in catch block and logged - verify { android.util.Log.e("NotificationReply", any(), any()) } + verify(timeout = 3_000) { android.util.Log.e("NotificationReply", any(), any()) } // Pending result must still finish after timeout - verify { mockPendingResult.finish() } + verify(timeout = 3_000) { mockPendingResult.finish() } } // ── Helpers ──────────────────────────────────────────────────────────── @@ -447,3 +455,5 @@ class NotificationReplyReceiverTest { every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId } } + +private fun Map.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt new file mode 100644 index 00000000..c2e5568c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */ +class ThemeComponentContrastTest { + @Test + fun everyResolvedPresetSupportsEnabledComponentStates() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val c = resolveColorScheme(preset, dark) + // PR #1417: these are component-state combinations, not just on-color pairs. + val graphics = + listOf( + "switch on thumb/track" to (c.onPrimary to c.primary), + "switch on track/surface" to (c.primary to c.surface), + "switch off thumb/track" to (c.outline to c.surfaceContainerHighest), + "switch off border/surface" to (c.outline to c.surface), + "radio selected/surface" to (c.primary to c.surface), + "radio unselected/surface" to (c.onSurfaceVariant to c.surface), + "checkbox selected/surface" to (c.primary to c.surface), + "checkbox checkmark/box" to (c.onPrimary to c.primary), + "checkbox unselected/surface" to (c.onSurfaceVariant to c.surface), + "slider active/inactive" to (c.primary to c.secondaryContainer), + "field normal border/surface" to (c.outline to c.surface), + "field focused border/surface" to (c.primary to c.surface), + "field error border/surface" to (c.error to c.surface), + "segment selected border/container" to (c.outline to c.secondaryContainer), + "segment unselected border/surface" to (c.outline to c.surface), + ) + val text = + listOf( + "primary foreground/surface" to (c.primary to c.surface), + "primary foreground/background" to (c.primary to c.background), + "error foreground/surface" to (c.error to c.surface), + "error foreground/background" to (c.error to c.background), + "field normal label/surface" to (c.onSurfaceVariant to c.surface), + "field focused label/surface" to (c.primary to c.surface), + "field error label/surface" to (c.error to c.surface), + "segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer), + "segment unselected content/surface" to (c.onSurface to c.surface), + ) + graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) } + text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) } + } + } + } + + private fun checkPair( + preset: ThemePreset, + dark: Boolean, + name: String, + pair: Pair, + minimum: Float, + ) { + val a = pair.first.luminance() + val b = pair.second.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt index 2228a910..e85dc2d9 100644 --- a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt @@ -1,67 +1,164 @@ package com.m57.hermescontrol.theme +import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import com.m57.hermescontrol.theme.presets.AmoledTheme -import com.m57.hermescontrol.theme.presets.CatppuccinTheme import com.m57.hermescontrol.theme.presets.DefaultTheme -import com.m57.hermescontrol.theme.presets.GruvboxTheme -import com.m57.hermescontrol.theme.presets.MonochromeTheme +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.lang.reflect.Modifier /** * Guards the palette template's color invariants: - * 1. Every shipped error slot pair meets >= 3:1 WCAG contrast. + * 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode. * 2. ThemeMode combinations are validated at construction time. - * 3. One-mode themes resolve and fall back to the default theme correctly. + * 3. Every Material role is explicitly mapped; fixed roles are mode-independent. + * 4. One-mode themes resolve and fall back to the default theme correctly. */ class ThemePaletteTest { - private val themes = - listOf( - ThemePreset.DEFAULT to DefaultTheme, - ThemePreset.MONOCHROME to MonochromeTheme, - ThemePreset.GRUVBOX to GruvboxTheme, - ThemePreset.CATPPUCCIN to CatppuccinTheme, - ThemePreset.AMOLED to AmoledTheme, - ) + @Test + fun registryCoversEveryPresetExactlyOnce() { + assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset }) + } @Test - fun errorSlotPairsMeetContrastInEveryShippedMode() { - themes.forEach { (preset, theme) -> + fun textSlotPairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> listOf(true, false).forEach { dark -> - val scheme = theme.schemeFor(dark) ?: return@forEach - assertTrue( - "$preset dark=$dark onError/error contrast must be >= 3:1", - contrast(scheme.onError, scheme.error) >= 3f, - ) - assertTrue( - "$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1", - contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f, - ) + val c = resolveColorScheme(preset, dark) + val pairs = + listOf( + "onPrimary/primary" to (c.onPrimary to c.primary), + "onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer), + "onSecondary/secondary" to (c.onSecondary to c.secondary), + "onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer), + "onTertiary/tertiary" to (c.onTertiary to c.tertiary), + "onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer), + "onBackground/background" to (c.onBackground to c.background), + "onSurface/surface" to (c.onSurface to c.surface), + "onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant), + "onSurface/surfaceDim" to (c.onSurface to c.surfaceDim), + "onSurface/surfaceBright" to (c.onSurface to c.surfaceBright), + "onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest), + "onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow), + "onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer), + "onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh), + "onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest), + "inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface), + "onError/error" to (c.onError to c.error), + "onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer), + "onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed), + "onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed), + "onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim), + "onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim), + "onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed), + "onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed), + "onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim), + "onSecondaryFixedVariant/secondaryFixedDim" to + (c.onSecondaryFixedVariant to c.secondaryFixedDim), + "onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed), + "onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed), + "onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim), + "onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim), + ) + pairs.forEach { (name, colors) -> + val ratio = contrast(colors.first, colors.second) + assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } } } } + @Test + fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val colors = resolveStatusColors(preset, dark) + StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type -> + // PR #1417: test the mapping consumed by both reusable badge renderers. + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + val ratio = contrast(foreground, background) + assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } + } + } + } + + @Test + fun statusBadgesUseMatchingStatusFillsAndOnColors() { + val c = dummyColors().status + assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c)) + assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c)) + assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c)) + assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c)) + } + + @Test + fun fixedRolesDoNotChangeBetweenShippedModes() { + ThemeRegistry.forEach { (preset, theme) -> + val dark = theme.darkScheme + val light = theme.lightScheme + if (dark != null && light != null) { + val darkRoles = colorFields(dark).filterKeys { "Fixed" in it } + val lightRoles = colorFields(light).filterKeys { "Fixed" in it } + assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles) + } + } + } + + @Test + fun everyMaterialColorRoleIsMappedFromThePalette() { + // Distinct sentinel values catch swapped roles and accidental Material defaults. + val colors = dummyColors() + val expected = colorFields(colors).toMutableMap() + expected["surfaceTint"] = expected.getValue("primary") + expected.putAll( + colorFields(colors.status).filterKeys { + it in + setOf( + "error", + "onError", + "errorContainer", + "onErrorContainer", + ) + }, + ) + val theme = buildTheme(colors, colors) + listOf(theme.darkScheme, theme.lightScheme).forEach { scheme -> + assertEquals(expected, colorFields(requireNotNull(scheme))) + } + } + + private fun colorFields(value: Any): Map = + value.javaClass.declaredFields + .filter { + it.type == java.lang.Long.TYPE && + !Modifier.isStatic(it.modifiers) + }.associate { field -> + field.isAccessible = true + field.name to field.getLong(value) + } + /** * Full-bleed chat renderer gate (issue #866): agent prose renders directly * on the screen background (HermesScaffold containerColor = background), * so the full-bleed text pairs must hold against [ColorScheme.background]: * - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text * - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI - * (The header deliberately avoids `primary`: Nord's light mode reuses its - * pastel Frost accent as primary, which cannot reach 3:1 on a light - * background.) - * Iterates ThemePreset.entries directly so EVERY shipped preset (incl. - * NORD, which the [themes] list above predates) is covered. + * Header text uses onSurfaceVariant independently of the theme's accent. + * The registry covers every shipped preset and its fallback mode. */ @Test fun fullBleedTextPairsMeetContrastInEveryShippedMode() { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> listOf(true, false).forEach { dark -> - val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach + val scheme = resolveColorScheme(preset, darkTheme = dark) assertTrue( "$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)", contrast(scheme.onSurface, scheme.background) >= 4.5f, @@ -84,18 +181,18 @@ class ThemePaletteTest { @Test fun everyPresetResolvesItsDeclaredModes() { - themes.forEach { (preset, theme) -> + ThemeRegistry.forEach { (preset, theme) -> listOf(true, false).forEach { dark -> - if (theme.schemeFor(dark) != null) { - assertTrue( - "$preset dark=$dark scheme must resolve", - resolveColorScheme(preset, darkTheme = dark) != null, - ) - assertTrue( - "$preset dark=$dark status must resolve", - resolveStatusColors(preset, darkTheme = dark) != null, - ) - } + assertSame( + "$preset dark=$dark must resolve its palette or Default fallback", + theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark), + resolveColorScheme(preset, dark), + ) + assertSame( + "$preset dark=$dark must resolve its status or Default fallback", + theme.statusFor(dark) ?: DefaultTheme.statusFor(dark), + resolveStatusColors(preset, dark), + ) } } } @@ -169,50 +266,64 @@ class ThemePaletteTest { private fun dummyColors(): PaletteColors = PaletteColors( - primary = Color.White, - onPrimary = Color.Black, - primaryContainer = Color.White, - onPrimaryContainer = Color.Black, - secondary = Color.White, - onSecondary = Color.Black, - secondaryContainer = Color.White, - onSecondaryContainer = Color.Black, - tertiary = Color.White, - onTertiary = Color.Black, - tertiaryContainer = Color.White, - onTertiaryContainer = Color.Black, - background = Color.White, - onBackground = Color.Black, - surface = Color.White, - onSurface = Color.Black, - surfaceVariant = Color.White, - onSurfaceVariant = Color.Black, - surfaceContainerLowest = Color.White, - surfaceContainerLow = Color.White, - surfaceContainer = Color.White, - surfaceContainerHigh = Color.White, - surfaceContainerHighest = Color.White, - inverseSurface = Color.Black, - inverseOnSurface = Color.White, - inversePrimary = Color.Black, - outline = Color.Gray, - outlineVariant = Color.Gray, - scrim = Color.Black, + primary = Color(0xFF000000L + 1), + onPrimary = Color(0xFF000000L + 2), + primaryContainer = Color(0xFF000000L + 3), + onPrimaryContainer = Color(0xFF000000L + 4), + secondary = Color(0xFF000000L + 5), + onSecondary = Color(0xFF000000L + 6), + secondaryContainer = Color(0xFF000000L + 7), + onSecondaryContainer = Color(0xFF000000L + 8), + tertiary = Color(0xFF000000L + 9), + onTertiary = Color(0xFF000000L + 10), + tertiaryContainer = Color(0xFF000000L + 11), + onTertiaryContainer = Color(0xFF000000L + 12), + background = Color(0xFF000000L + 13), + onBackground = Color(0xFF000000L + 14), + surface = Color(0xFF000000L + 15), + onSurface = Color(0xFF000000L + 16), + surfaceVariant = Color(0xFF000000L + 17), + onSurfaceVariant = Color(0xFF000000L + 18), + surfaceDim = Color(0xFF000000L + 19), + surfaceBright = Color(0xFF000000L + 20), + primaryFixed = Color(0xFF000000L + 21), + primaryFixedDim = Color(0xFF000000L + 22), + onPrimaryFixed = Color(0xFF000000L + 23), + onPrimaryFixedVariant = Color(0xFF000000L + 24), + secondaryFixed = Color(0xFF000000L + 25), + secondaryFixedDim = Color(0xFF000000L + 26), + onSecondaryFixed = Color(0xFF000000L + 27), + onSecondaryFixedVariant = Color(0xFF000000L + 28), + tertiaryFixed = Color(0xFF000000L + 29), + tertiaryFixedDim = Color(0xFF000000L + 30), + onTertiaryFixed = Color(0xFF000000L + 31), + onTertiaryFixedVariant = Color(0xFF000000L + 32), + surfaceContainerLowest = Color(0xFF000000L + 33), + surfaceContainerLow = Color(0xFF000000L + 34), + surfaceContainer = Color(0xFF000000L + 35), + surfaceContainerHigh = Color(0xFF000000L + 36), + surfaceContainerHighest = Color(0xFF000000L + 37), + inverseSurface = Color(0xFF000000L + 38), + inverseOnSurface = Color(0xFF000000L + 39), + inversePrimary = Color(0xFF000000L + 40), + outline = Color(0xFF000000L + 41), + outlineVariant = Color(0xFF000000L + 42), + scrim = Color(0xFF000000L + 43), status = HermesStatusColors( - success = Color.Green, - successContainer = Color.White, - onSuccess = Color.Black, - warning = Color.Yellow, - warningContainer = Color.White, - onWarning = Color.Black, - error = Color.Red, - errorContainer = Color.White, - onError = Color.Black, - onErrorContainer = Color.Black, - info = Color.Blue, - infoContainer = Color.White, - onInfo = Color.Black, + success = Color(0xFF000000L + 100), + successContainer = Color(0xFF000000L + 101), + onSuccess = Color(0xFF000000L + 102), + warning = Color(0xFF000000L + 103), + warningContainer = Color(0xFF000000L + 104), + onWarning = Color(0xFF000000L + 105), + error = Color(0xFF000000L + 106), + errorContainer = Color(0xFF000000L + 107), + onError = Color(0xFF000000L + 108), + onErrorContainer = Color(0xFF000000L + 109), + info = Color(0xFF000000L + 110), + infoContainer = Color(0xFF000000L + 111), + onInfo = Color(0xFF000000L + 112), ), ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt new file mode 100644 index 00000000..31f1d62c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/BrowserReturnTrackerTest.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrowserReturnTrackerTest { + @Test + fun `browser return requires a departure and consumes the lease once`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + assertNull(tracker.onResume()) + tracker.onPause() + assertEquals("operation-1", tracker.onResume()?.operationId) + assertNull(tracker.onResume()) + assertFalse(tracker.abandon()) + } + + @Test + fun `legacy browser without operation id still reports return`() { + val tracker = BrowserReturnTracker() + tracker.start() + tracker.onPause() + assertTrue(tracker.onResume() != null) + assertNull(tracker.onResume()) + } + + @Test + fun `failed launch clears tracking and does not release a departed lease`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + tracker.onPause() + tracker.cancel() + assertNull(tracker.onResume()) + assertFalse(tracker.abandon()) + } + + @Test + fun `non configuration disposal releases only a departed lease and clears the id`() { + val tracker = BrowserReturnTracker() + tracker.start("operation-1") + assertFalse(tracker.abandon()) + tracker.start("operation-2") + tracker.onPause() + assertTrue(tracker.abandon()) + assertFalse(tracker.abandon()) + assertNull(tracker.onResume()) + } + + @Test + fun `new launch resets departed state from the previous launch`() { + val tracker = BrowserReturnTracker() + tracker.start("first") + tracker.onPause() + tracker.start("second") + assertNull(tracker.onResume()) + tracker.onPause() + assertEquals("second", tracker.onResume()?.operationId) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt index bc797046..ec98fa19 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -1,7 +1,10 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.RpcMethod +import com.m57.hermescontrol.data.ws.contract.TypedRpcSender import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -9,6 +12,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals @@ -25,7 +30,7 @@ class ChatApprovalsDelegateTest { private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1")) private var runtimeId: String? = null private val sentMethods = mutableListOf() - private val sentParams = mutableListOf>() + private val sentParams = mutableListOf() private val trackedRequests = mutableListOf>() private val systemMessages = mutableListOf() @@ -35,11 +40,22 @@ class ChatApprovalsDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { method, params, onSent -> - sentMethods.add(method) - sentParams.add(params) - onSent?.invoke("req-id-${sentMethods.size}") - }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String { + sentMethods.add(method.name) + sentParams.add( + OkHttpProvider.json.encodeToJsonElement(method.params, params) as JsonObject, + ) + val id = "req-id-${sentMethods.size}" + onSent?.invoke(id) + return id + } + }, trackRequest = { id, method -> trackedRequests.add(id to method) }, addSystemMessage = { text -> systemMessages.add(text) }, ) @@ -98,8 +114,8 @@ class ChatApprovalsDelegateTest { assertFalse(uiState.value.isAgentTyping) assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods) - assertEquals("runtime-session-xyz", sentParams.first()["session_id"]) - assertEquals("req-123", sentParams.first()["request_id"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams.first()["session_id"]) + assertEquals(JsonPrimitive("req-123"), sentParams.first()["request_id"]) } @Test @@ -125,11 +141,11 @@ class ChatApprovalsDelegateTest { advanceUntilIdle() assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods) - assertEquals("once", sentParams[0]["choice"]) - assertEquals("req-999", sentParams[0]["request_id"]) - assertEquals(false, sentParams[0]["all"]) - assertEquals("runtime-session-xyz", sentParams[0]["session_id"]) - assertEquals("runtime-session-xyz", sentParams[1]["session_id"]) + assertEquals(JsonPrimitive("once"), sentParams[0]["choice"]) + assertEquals(JsonPrimitive("req-999"), sentParams[0]["request_id"]) + assertEquals(JsonPrimitive(false), sentParams[0]["all"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[0]["session_id"]) + assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[1]["session_id"]) } @Test @@ -270,7 +286,14 @@ class ChatApprovalsDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { "session-1" }, - wsSend = { _, _, _ -> error("new approval must not use legacy response") }, + rpc = + object : TypedRpcSender { + override fun

send( + method: RpcMethod, + params: P, + onSent: ((String) -> Unit)?, + ): String = error("new approval must not use legacy response") + }, trackRequest = { _, _ -> }, addSystemMessage = { }, respondToServerRequest = { id, result -> responses += id to result }, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt index daed040b..3e0294ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCompressRpcTest.kt @@ -201,7 +201,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) } @@ -224,8 +224,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("auth decisions", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"]) } @Test @@ -247,7 +247,7 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic")) assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) } @@ -271,8 +271,8 @@ class ChatCompressRpcTest { val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS) assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0) val params = paramsCalls[compressIndex] - assertEquals(sessionId, params["session_id"]) - assertEquals("architecture details", params["focus_topic"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("architecture details"), params["focus_topic"]) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt index 2a4b1315..3d31b0ae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatConnectionOperationDelegateTest.kt @@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetState +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner +import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest { ) assertEquals(1, requester.calls.size) - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTION_RESPOND, call.first) - assertEquals(setOf("owner", "op_id", "result"), call.second.keys) - assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"]) - assertEquals("op-a", call.second["op_id"]) - @Suppress("UNCHECKED_CAST") - val result = call.second["result"] as Map + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Respond) + val respond = action as ConnectionOperationRequest.Respond + + assertEquals( + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "approved", + env = mapOf("TOKEN" to canary), + ), + ), + ), + ), + respond.params, + ) + + val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), respond.params) + val expectedWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "approved") + put( + "env", + buildJsonObject { + put("TOKEN", canary) + }, + ) + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedWire, encoded) - @Suppress("UNCHECKED_CAST") - val answer = (result["targets"] as List>).single() - assertEquals("github", answer["name"]) - assertEquals("approved", answer["status"]) - assertEquals(mapOf("TOKEN" to canary), answer["env"]) assertFalse( delegate.state.value .toString() @@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest { skipDelegate.acceptRequest(snapshot()) skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false) - @Suppress("UNCHECKED_CAST") - val skipResult = skipRequester.calls.single().second["result"] as Map + val skipAction = skipRequester.calls.single() + assertTrue(skipAction is ConnectionOperationRequest.Respond) + val skipRespond = skipAction as ConnectionOperationRequest.Respond + assertEquals( + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = + ConnectionAnswer( + targets = + listOf( + ConnectionAnswerTarget( + name = "github", + status = "skipped", + ), + ), + ), + ), + skipRespond.params, + ) - @Suppress("UNCHECKED_CAST") - val skipAnswer = (skipResult["targets"] as List>).single() - assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer) + val encodedSkip = + OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params) + val expectedSkipWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put( + "targets", + buildJsonArray { + add( + buildJsonObject { + put("name", "github") + put("status", "skipped") + }, + ) + }, + ) + }, + ) + } + assertEquals(expectedSkipWire, encodedSkip) val continueRequester = RecordingRequester() val continueDelegate = delegate(continueRequester) @@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest { continueDelegate.acceptRequest(snapshot()) continueDelegate.continueOperation() + val continueAction = continueRequester.calls.single() + assertTrue(continueAction is ConnectionOperationRequest.Respond) + val continueRespond = continueAction as ConnectionOperationRequest.Respond assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + ConnectionRespondParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), ), - continueRequester.calls.single().second, + continueRespond.params, ) + + val encodedContinue = + OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), continueRespond.params) + val expectedContinueWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + put( + "result", + buildJsonObject { + put("settled_by", "continue") + }, + ) + } + assertEquals(expectedContinueWire, encodedContinue) } @Test @@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest { delegate.wake("op-a") - val call = requester.calls.single() - assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first) + val action = requester.calls.single() + assertTrue(action is ConnectionOperationRequest.Wake) + val wake = action as ConnectionOperationRequest.Wake assertEquals( - mapOf( - "owner" to mapOf("type" to "session", "session_id" to "session-a"), - "op_id" to "op-a", + ConnectorsOperationStatusParams( + owner = ConnectorOwner.session("session-a"), + opId = "op-a", ), - call.second, + wake.params, ) + + val encoded = + OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), wake.params) + val expectedWire = + buildJsonObject { + put( + "owner", + buildJsonObject { + put("type", "session") + put("session_id", "session-a") + }, + ) + put("op_id", "op-a") + } + assertEquals(expectedWire, encoded) } @Test @@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest { runTest { val gate = CompletableDeferred() val requester = - ConnectionOperationRequester { _, _ -> + ConnectionOperationRequester { gate.await() throw IllegalStateException("old failure") } @@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest { private class RecordingRequester( private val failure: Throwable? = null, ) : ConnectionOperationRequester { - val calls = mutableListOf>>() + val calls = mutableListOf() - override suspend fun request( - method: String, - params: Map, - ): Any? { - calls += method to params + override suspend fun request(action: ConnectionOperationRequest): Any? { + calls += action failure?.let { throw it } return Unit } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt new file mode 100644 index 00000000..a612d9a7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatMessageIdentityTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.local.canonicalMessageOrder +import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Issue #1336: one owner for the REST row-id format and the known display kinds. */ +class ChatMessageIdentityTest { + @Test + fun restIdsRoundTripThroughTheSingleFormatOwner() { + val id = RestMessageId.of("s-1", 42) + + assertEquals("rest-s-1-42", id) + assertTrue(RestMessageId.isRest(id)) + assertFalse(RestMessageId.isRest("ws-42")) + assertEquals(42L, canonicalMessageOrder(id, "s-1")) + assertNull(canonicalMessageOrder(RestMessageId.of("other", 42), "s-1")) + assertEquals(id, ChatMessage(id = id, role = MessageRole.USER, content = "").canonicalRestId) + } + + @Test + fun onlyUnknownOrMarkerKindsAreTimelineMarkers() { + fun kind(value: String?) = ChatMessage(role = MessageRole.USER, content = "x", displayKind = value) + + DisplayKind.nonMarkerKinds.forEach { assertFalse(it, kind(it).isTimelineMarker()) } + assertTrue(kind(DisplayKind.MODEL_SWITCH).isTimelineMarker()) + assertTrue(kind("future_backend_kind").isTimelineMarker()) + assertFalse(kind(null).isTimelineMarker()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 94370290..0e4dacdc 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -8,7 +8,7 @@ import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +18,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -31,8 +33,7 @@ class ChatModelSwitchDelegateTest { private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3")) private var runtimeId: String? = "runtime-1" - private val sentMethods = mutableListOf() - private val sentParams = mutableListOf>() + private val sentParams = mutableListOf() private val slashCommands = mutableListOf() private val assistantMessages = mutableListOf() private var contextRefetched = 0 @@ -71,10 +72,9 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { method, params, onSent -> - sentMethods.add(method) + wsSend = { params, onSent -> sentParams.add(params) - onSent?.invoke("req-${sentMethods.size}") + onSent?.invoke("req-${sentParams.size}") }, trackRequest = { _, _ -> }, addAssistantMessage = { assistantMessages.add(it) }, @@ -304,8 +304,9 @@ class ChatModelSwitchDelegateTest { delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session") advanceUntilIdle() - assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) - assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"]) + assertEquals(1, sentParams.size) + assertEquals("model", sentParams.first().key) + assertEquals("gpt-4o --provider openai --session", sentParams.first().value) delegate.handleConfigSetResult( id = "req-1", @@ -320,8 +321,8 @@ class ChatModelSwitchDelegateTest { delegate.confirmModelSwitchExpensive() advanceUntilIdle() - assertEquals(2, sentMethods.size) - assertEquals(true, sentParams[1]["confirm_expensive_model"]) + assertEquals(2, sentParams.size) + assertEquals(true, sentParams[1].confirmExpensiveModel) } @Test @@ -366,7 +367,6 @@ class ChatModelSwitchDelegateTest { // Switch to model with fast capability delegate.sendSlashModel("openai", "gpt-4o") advanceUntilIdle() - sentMethods.clear() sentParams.clear() assertTrue(uiState.value.currentModelCapabilities?.fast == true) @@ -377,10 +377,10 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() assertTrue(uiState.value.isFastModeChanging) assertFalse(uiState.value.fastMode) // Still unconfirmed! - assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) - assertEquals("fast", sentParams.first()["key"]) - assertEquals("fast", sentParams.first()["value"]) - assertEquals("runtime-1", sentParams.first()["session_id"]) + assertEquals(1, sentParams.size) + assertEquals("fast", sentParams.first().key) + assertEquals("fast", sentParams.first().value) + assertEquals("runtime-1", sentParams.first().sessionId) // Backend acknowledges delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast")) @@ -391,7 +391,7 @@ class ChatModelSwitchDelegateTest { delegate.toggleFastMode() advanceUntilIdle() assertTrue(uiState.value.isFastModeChanging) - assertEquals("normal", sentParams.last()["value"]) + assertEquals("normal", sentParams.last().value) delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal")) assertFalse(uiState.value.fastMode) @@ -405,7 +405,6 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() delegate.sendSlashModel("openai", "gpt-4o") advanceUntilIdle() - sentMethods.clear() sentParams.clear() assertTrue(uiState.value.currentModelCapabilities?.fast == true) @@ -420,10 +419,10 @@ class ChatModelSwitchDelegateTest { assertEquals(false, uiState.value.currentModelCapabilities?.fast) // Further toggle attempts are no-ops - val sentCount = sentMethods.size + val sentCount = sentParams.size delegate.toggleFastMode() advanceUntilIdle() - assertEquals(sentCount, sentMethods.size) + assertEquals(sentCount, sentParams.size) } @Test @@ -470,9 +469,8 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() = testScope.runTest { - val gate = CompletableDeferred() - val reqMethods = mutableListOf() - val reqParams = mutableListOf>() + val gate = CompletableDeferred() + val reqParams = mutableListOf() val customDelegate = ChatModelSwitchDelegate( @@ -480,14 +478,13 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { method, params -> - reqMethods.add(method) + wsRequest = { params -> reqParams.add(params) gate.await() }, @@ -503,14 +500,15 @@ class ChatModelSwitchDelegateTest { assertEquals("ultra", uiState.value.reasoningLevel) assertEquals("high", uiState.value.pendingReasoningLevel) assertNull(uiState.value.reasoningWireLevel) - assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods) - assertEquals("session", reqParams.first()["scope"]) - assertEquals("high", reqParams.first()["value"]) + assertEquals(1, reqParams.size) + assertEquals("reasoning", reqParams.first().key) + assertEquals("session", reqParams.first().scope) + assertEquals("high", reqParams.first().value) // Second pick while in-flight is rejected (serialized) customDelegate.setReasoningLevel("low") runCurrent() - assertEquals(1, reqMethods.size) + assertEquals(1, reqParams.size) // Simulate session.info winning the race and publishing the fresh // authoritative wire before the RPC ACK resumes the delegate. @@ -540,13 +538,13 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") }, + wsRequest = { throw RuntimeException("4002 unknown reasoning value") }, ) customDelegate.setReasoningLevel("ultra") @@ -560,7 +558,7 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() = testScope.runTest { - val reqMethods = mutableListOf() + val reqParams = mutableListOf() runtimeId = null val customDelegate = @@ -569,22 +567,22 @@ class ChatModelSwitchDelegateTest { ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { method, _ -> - reqMethods.add(method) - null + wsRequest = { params -> + reqParams.add(params) + JsonNull }, ) customDelegate.setReasoningLevel("high") advanceUntilIdle() - assertTrue(reqMethods.isEmpty()) + assertTrue(reqParams.isEmpty()) assertNull(uiState.value.pendingReasoningLevel) assertNull(uiState.value.reasoningLevel) } @@ -592,20 +590,20 @@ class ChatModelSwitchDelegateTest { @Test fun setReasoningLevel_staleScopeOrReset_dropsLateAck() = testScope.runTest { - val gate = CompletableDeferred() + val gate = CompletableDeferred() val customDelegate = ChatModelSwitchDelegate( scope = testScope, ioDispatcher = testDispatcher, uiState = uiState, runtimeSessionId = { runtimeId }, - wsSend = { _, _, _ -> }, + wsSend = { _, _ -> }, trackRequest = { _, _ -> }, addAssistantMessage = {}, handleSlashCommand = {}, fetchContextUsage = {}, dataScopeFlow = dataScope, - wsRequest = { _, _ -> gate.await() }, + wsRequest = { gate.await() }, ) customDelegate.setReasoningLevel("high") @@ -617,7 +615,12 @@ class ChatModelSwitchDelegateTest { assertNull(uiState.value.pendingReasoningLevel) // Late RPC response arrives - gate.complete(mapOf("key" to "reasoning", "value" to "high")) + gate.complete( + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("high")) + }, + ) advanceUntilIdle() assertNull(uiState.value.reasoningLevel) 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 69b18af1..de249a3d 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 @@ -952,6 +952,100 @@ class ChatPagingMergeTest { ) } + private fun laterTurnRows(assistantText: String) = + listOf( + SessionMessage( + id = 0, + role = "user", + content = JsonPrimitive("long running task"), + timestamp = JsonPrimitive(1), + ), + SessionMessage( + id = 1, + role = "assistant", + content = JsonPrimitive(assistantText), + timestamp = JsonPrimitive(2), + ), + SessionMessage( + id = 2, + role = "user", + content = JsonPrimitive("next question"), + timestamp = JsonPrimitive(3), + ), + SessionMessage( + id = 3, + role = "assistant", + content = JsonPrimitive("next answer"), + timestamp = JsonPrimitive(4), + ), + ) + + @Test + fun stopNoticesStayInPlaceWhenInterruptedReplyIsStillLiveOnly() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "Working on it...") + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val processes = + ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "Stopped 2 background processes.") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + + val merged = + applyServerPage(listOf(prompt, liveReply, stop, processes, interrupted), laterTurnRows("Working on it...")) + + assertEquals( + listOf( + "rest-session-0", + "rest-session-1", + "uuid-stop", + "uuid-procs", + "uuid-int", + "rest-session-2", + "rest-session-3", + ), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + + @Test + fun stopNoticesStayInPlaceWhenLongInterruptedReplyIsAPrefixOfTheServerCopy() { + val long = "Working on the migration plan step by step, first the schema then the data" + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = long) + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + + val merged = applyServerPage(listOf(prompt, liveReply, stop, interrupted), laterTurnRows("$long, then indexes")) + + val ids = merged.map { it.canonicalRestId ?: it.id } + assertTrue("notices before later turns: $ids", ids.indexOf("uuid-int") < ids.indexOf("rest-session-2")) + } + + @Test + fun processResultArrivingAfterInterruptNoticeStaysBeforeLaterTurns() { + val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task") + val reply = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...") + val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop") + val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted") + val processes = + ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "No background processes to stop.") + + val merged = + applyServerPage(listOf(prompt, reply, stop, interrupted, processes), laterTurnRows("Working on it...")) + + assertEquals( + listOf( + "rest-session-0", + "rest-session-1", + "uuid-stop", + "uuid-int", + "uuid-procs", + "rest-session-2", + "rest-session-3", + ), + merged.map { it.canonicalRestId ?: it.id }, + ) + } + @Test fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() { val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?") @@ -1115,4 +1209,59 @@ class ChatPagingMergeTest { chronological = !older, preserveLiveIds = true, ) + + @Test + fun restoredLocalCommandsKeepTheirChronologicalPlaceInCachedPage() { + fun row( + id: String, + role: MessageRole, + content: String, + ts: Long, + local: Long? = null, + ) = ChatMessage(id = id, role = role, content = content, timestamp = ts, localOrder = local) + // Room order: confirmed rows first, then every local row (sort_group 1). + val cachedPage = + listOf( + row("rest-s-1", MessageRole.USER, "hi", 10L), + row("rest-s-2", MessageRole.ASSISTANT, "hello", 11L), + row("rest-s-3", MessageRole.USER, "more", 30L), + row("rest-s-4", MessageRole.ASSISTANT, "ok", 31L), + row("rest-s-5", MessageRole.USER, "again", 50L), + row("rest-s-6", MessageRole.ASSISTANT, "sure", 51L), + row("cmd-a", MessageRole.USER, "/help", 12L, local = 1L), + row("cmd-b", MessageRole.USER, "/usage", 32L, local = 2L), + row("cmd-c", MessageRole.USER, "/model", 52L, local = 3L), + ) + + val merged = mergeCachedTranscriptPage(cachedPage, emptyList()) + + assertEquals( + listOf("rest-s-1", "rest-s-2", "cmd-a", "rest-s-3", "rest-s-4", "cmd-b", "rest-s-5", "rest-s-6", "cmd-c"), + merged.map { it.id }, + ) + } + + @Test + fun restoredCommandOlderThanLoadedWindowStaysAboveItNotAtTheTail() { + val server = + listOf( + ChatMessage(id = "rest-s-50", role = MessageRole.USER, content = "a", timestamp = 500L), + ChatMessage(id = "rest-s-51", role = MessageRole.ASSISTANT, content = "b", timestamp = 501L), + ) + val old = + ChatMessage( + id = "cmd", + role = MessageRole.USER, + content = "/model x", + timestamp = 100L, + localOrder = 9L, + ) + val merged = mergeCachedTranscriptPage(server + old, emptyList()) + assertEquals(listOf("cmd", "rest-s-50", "rest-s-51"), merged.map { it.id }) + // Stable when an older page arrives afterwards. + val older = ChatMessage(id = "rest-s-10", role = MessageRole.USER, content = "o", timestamp = 50L) + val olderMid = ChatMessage(id = "rest-s-11", role = MessageRole.ASSISTANT, content = "p", timestamp = 150L) + val more = mergeCachedTranscriptPage(listOf(older, olderMid), merged) + assertEquals(listOf("rest-s-10", "cmd", "rest-s-11", "rest-s-50", "rest-s-51"), more.map { it.id }) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt new file mode 100644 index 00000000..9aff13c2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPrefixDedupeTest.kt @@ -0,0 +1,162 @@ +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 + +/** + * Issue #1335: prefix dedupe must not merge distinct messages. + */ +class ChatPrefixDedupeTest { + // ── Contract 1 ──────────────────────────────────────────────────────────── + // Two USER messages, both >=40 chars, one a strict prefix of the other, + // different ids -> NOT the same logical message (both survive a merge). + + @Test + fun contract1_userMessages_strictPrefixDistinctIds_notSameLogicalMessage() { + val prefixText = "Please write a comprehensive test suite for issue thirteen thirty five." + val fullText = "$prefixText It should cover all edge cases thoroughly." + val msg1 = ChatMessage(id = "user-msg-1", role = MessageRole.USER, content = prefixText) + val msg2 = ChatMessage(id = "user-msg-2", role = MessageRole.USER, content = fullText) + + assertFalse(sameLogicalMessage(msg1, msg2)) + assertFalse(sameLogicalMessage(msg2, msg1)) + + val merged = mergeTranscriptWithLive(listOf(msg2), listOf(msg1), preserveLiveIds = true) + assertEquals(2, merged.size) + assertTrue(merged.any { it.id == "user-msg-1" && it.content == prefixText }) + assertTrue(merged.any { it.id == "user-msg-2" && it.content == fullText }) + } + + // ── Contract 2 ──────────────────────────────────────────────────────────── + // Two ASSISTANT messages that are both canonical REST rows (distinct restIds / + // canonical ids), one a >=40-char prefix of the other -> NOT same. + + @Test + fun contract2_assistantCanonicalRestRows_strictPrefix_notSameLogicalMessage() { + val prefixText = "I have inspected the project repository and verified the build files." + val fullText = "$prefixText Now proceeding with the implementation of the requested tests." + val restRow1 = + ChatMessage( + id = "rest-s-101", + role = MessageRole.ASSISTANT, + content = prefixText, + restId = "rest-s-101", + ) + val restRow2 = + ChatMessage( + id = "rest-s-102", + role = MessageRole.ASSISTANT, + content = fullText, + restId = "rest-s-102", + ) + + assertFalse(sameLogicalMessage(restRow1, restRow2)) + assertFalse(sameLogicalMessage(restRow2, restRow1)) + + val merged = mergeTranscriptWithLive(listOf(restRow2), listOf(restRow1), preserveLiveIds = true) + assertEquals(2, merged.size) + } + + // ── Contract 3 ──────────────────────────────────────────────────────────── + // TOOL rows never match by content prefix. + + @Test + fun contract3_toolRows_neverMatchByContentPrefix() { + val prefixToolContent = """{"output":"build succeeded without any compiler errors or warnings during check"}""" + val fullToolContent = + """{"output":"build succeeded without any compiler errors or warnings during check and all tests pass"}""" + val tool1 = + ChatMessage( + id = "tool-1", + role = MessageRole.TOOL, + content = prefixToolContent, + toolName = "terminal", + toolCallId = "call-1", + ) + val tool2 = + ChatMessage( + id = "tool-2", + role = MessageRole.TOOL, + content = fullToolContent, + toolName = "terminal", + toolCallId = "call-2", + ) + + assertFalse(sameLogicalMessage(tool1, tool2)) + assertFalse(sameLogicalMessage(tool2, tool1)) + } + + // ── Contract 4 ──────────────────────────────────────────────────────────── + // Exact-equal trimmed content still matches as before for a live vs REST pair + // of the same role (sanity). + + @Test + fun contract4_exactEqualTrimmedContent_liveVsRest_matchesSameLogicalMessage() { + for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT)) { + val live = + ChatMessage( + id = "live-temp-id", + role = role, + content = "\n\nHello, this is an important message for testing.\n ", + ) + val rest = + ChatMessage( + id = "rest-s-200", + role = role, + content = "Hello, this is an important message for testing.", + restId = "rest-s-200", + ) + + assertTrue("Role $role exact-equal trimmed content must match", sameLogicalMessage(live, rest)) + assertTrue( + "Role $role exact-equal trimmed content must match symmetrically", + sameLogicalMessage(rest, live), + ) + + val merged = mergeTranscriptWithLive(listOf(rest), listOf(live), preserveLiveIds = true) + assertEquals("Exact-equal trimmed pair should deduplicate into single message", 1, merged.size) + } + } + + // ── Contract 5 ──────────────────────────────────────────────────────────── + // Issue #842 scenario still dedupes: a live sealed interim assistant narration + // without completion/REST identity whose text (after trim) is a prefix of / + // equal-prefix to the persisted REST narration -> treated as same (reuse the + // shape used in ChatHistoryCommentaryTest if it exists). + + @Test + fun contract5_issue842_liveSealedInterimAssistantNarration_prefixOfRestNarration_stillDedupes() { + // Issue #842 / ChatToolDedupeTest pattern: + // Live sealed interim bubble without REST identity raced flush and ends prematurely. + // Rest row contains the full/completed narration. + val liveOrphan = + ChatMessage( + id = "live-orphan-sealed", + role = MessageRole.ASSISTANT, + content = + "\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" + + " — gimme dat creamy dreamy chickpea", + ) + val restRow = + ChatMessage( + id = "rest-s-842", + role = MessageRole.ASSISTANT, + content = + "tool's loaded! 🔍 now searchin' for the **best hummus recipe**" + + " — gimme dat creamy dreamy chickpea goodness:", + restId = "rest-s-842", + ) + + assertTrue(sameLogicalMessage(liveOrphan, restRow)) + assertTrue(sameLogicalMessage(restRow, liveOrphan)) + + val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan), preserveLiveIds = true) + assertEquals( + "Live interim orphan should deduplicate against persisted REST narration", + 1, + merged.size, + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt index b35bea74..100dad04 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt @@ -34,15 +34,25 @@ class ChatSearchControllerTest { } @Test - fun `excludes tool rows even when the payload contains the query`() { + fun `finds visible tool name without indexing hidden raw payload`() { val messages = listOf( message(MessageRole.USER, "check the logs"), - message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"), - message(MessageRole.ASSISTANT, "all good"), + message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"), ) - assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs"))) + assertEquals(listOf(SearchMatch(1, 0, SearchTarget.TOOL)), hits(controller.findMatches(messages, "terminal"))) + assertEquals(emptyList(), hits(controller.findMatches(messages, "private payload"))) + } + + @Test + fun `system marker carried as user does not yield an invisible hit`() { + val marker = + message( + MessageRole.USER, + "You've reached the maximum number of tool-calling iterations allowed.", + ) + assertEquals(emptyList(), hits(controller.findMatches(listOf(marker), "maximum"))) } @Test @@ -57,7 +67,7 @@ class ChatSearchControllerTest { } @Test - fun `excludes reasoning text that never made it to visible content`() { + fun `finds reasoning text independently of assistant prose`() { val messages = listOf( message( @@ -67,8 +77,10 @@ class ChatSearchControllerTest { ), ) - // Only the visible content is searchable — reasoning is invisible. - assertEquals(emptyList(), hits(controller.findMatches(messages, "deployment"))) + assertEquals( + listOf(SearchMatch(0, 19, SearchTarget.REASONING)), + hits(controller.findMatches(messages, "deployment")), + ) assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer"))) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt index a72289c0..5eaf44f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt @@ -139,6 +139,39 @@ class ChatSearchDelegateTest { assertEquals(0, delegate.searchState.currentIndex) } + @Test + fun `search tracks target kind while navigating reasoning tool and prose`() = + runTest { + val uiState = + MutableStateFlow( + ChatUiState( + messages = + listOf( + ChatMessage("m0", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"), + ChatMessage("m1", MessageRole.TOOL, "opaque", toolName = "thinking_tool"), + ), + ), + ) + val delegate = + ChatSearchDelegate( + scope = backgroundScope, + uiState = uiState, + dispatcher = StandardTestDispatcher(testScheduler), + debounceMs = 150, + ) + + delegate.setSearchQuery("thinking") + advanceTimeBy(150) + runCurrent() + assertEquals(listOf(SearchTarget.REASONING, SearchTarget.TOOL), delegate.searchState.matchTargets) + assertEquals("m0", delegate.searchState.currentMatchId) + delegate.navigateSearchMatch(1) + assertEquals("m1", delegate.searchState.currentMatchId) + assertEquals(SearchTarget.TOOL, delegate.searchState.matchTargets[delegate.searchState.currentIndex]) + delegate.clearSearch() + assertEquals(emptyList(), delegate.searchState.matchTargets) + } + @Test fun `clearSearch resets everything including matched sets`() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt new file mode 100644 index 00000000..baa54b7c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt @@ -0,0 +1,97 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.MessageReaction +import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.notification.ReplyNotificationTarget +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Issue #1337: the REST mapper runs on plain inputs, with no Android, AuthManager, or tracker state. */ +class ChatServerMessageMapperPurityTest { + private fun assistant( + id: Int, + text: String, + ) = SessionMessage(id = id, role = "assistant", content = JsonPrimitive(text)) + + @Test + fun mediaAttachmentsUseTheInjectedUrlBuilder() { + val mapped = + mapServerMessages( + "s", + listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), + 0, + true, + emptyList(), + mediaUrl = { path -> "gw://$path" }, + ).single() + + assertEquals("Chart", mapped.content.trim()) + assertEquals(listOf("gw:///opt/hermes/chart.png"), mapped.attachments?.map { it.uri }) + } + + @Test + fun mediaWithoutAGatewayStripsTheTokenAndAddsNoAttachment() { + val mapped = + mapServerMessages("s", listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), 0, true, emptyList()) + .single() + + assertEquals("Chart", mapped.content.trim()) + assertNull(mapped.attachments) + } + + @Test + fun activeReplyTargetIsAPlainInputScopedToItsSession() { + val rows = listOf(assistant(100, "Done"), assistant(200, "Done")) + val target = ReplyNotificationTarget("default", "s", "comp", 1L, serverMessageId = 100) + + val bound = mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target) + val otherSession = + mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target.copy(sessionId = "x")) + + assertEquals(listOf("comp", null), bound.map { it.completionId }) + assertEquals(listOf(null, null), otherSession.map { it.completionId }) + } + + @Test + fun rowsWithoutTimestampsUseTheInjectedClock() { + val mapped = mapServerMessages("s", listOf(assistant(1, "Hi")), 0, true, emptyList(), nowMs = 42L) + + assertEquals(42L, mapped.single().timestamp) + } + + @Test + fun persistedReactionsHydrateFromDisplayMetadata() { + val row = + SessionMessage( + id = 5, + role = "user", + content = JsonPrimitive("hello"), + display_metadata = + buildJsonObject { + put( + "reactions", + buildJsonArray { + add( + buildJsonObject { + put("emoji", "\uD83D\uDE02") + put("author", "agent") + }, + ) + add(buildJsonObject { put("author", "user") }) + }, + ) + }, + ) + val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single() + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions) + assertEquals( + emptyList(), + mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions, + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt index 6efcd0a2..e6aaa5a9 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt @@ -9,11 +9,11 @@ import org.junit.Test class ChatStreamingControllerTest { @Test - fun flushPendingReasoning_flushesThrottledReasoningOnTransition() = + fun flushPendingTransition_flushesThrottledReasoning() = runTest { // isTestEnvironment = false so the ~33ms throttle is live. The FIRST delta // always flushes (lastFlushMs == 0), but a SECOND delta within 33ms is held - // in the buffer and only pushed out by flushPendingReasoning() (called at + // in the buffer and only pushed out by flushPendingTransition() (called at // streaming transitions) — that is the exact drop we guard against. val uiState = MutableStateFlow(ChatUiState()) val streamingState = @@ -38,13 +38,13 @@ class ChatStreamingControllerTest { assertEquals("A", streamingState.value.streamingMessage?.reasoningText) // A streaming transition forces the buffered reasoning onto the message. - controller.flushPendingReasoning() + controller.flushPendingTransition() assertEquals("AB", streamingState.value.streamingMessage?.reasoningText) } @Test - fun flushPendingTokens_flushesThrottledTokensBeforeSeal() = + fun flushPendingTransition_flushesThrottledTokensBeforeSeal() = runTest { // Issue #842: a delta landing <33ms before tool.start stays in the // throttled buffer; flushing the tokens before the reducer seals the @@ -73,7 +73,7 @@ class ChatStreamingControllerTest { // The tool.start transition forces the buffered tokens out so the // sealed orphan carries the complete narration. - controller.flushPendingTokens() + controller.flushPendingTransition() assertEquals( "tool's loaded! 🔍 now searchin' for the best hummus", @@ -95,7 +95,7 @@ class ChatStreamingControllerTest { // A fresh reasoning delta + flush must carry ONLY the new value, proving the // stale buffer was cleared and cannot be resurrected. controller.handleReasoningDelta(WsEvent.ReasoningDelta("Fresh reasoning", "session")) - controller.flushPendingReasoning() + controller.flushPendingTransition() assertEquals("Fresh reasoning", streamingState.value.reasoningText) } @@ -168,6 +168,24 @@ class ChatStreamingControllerTest { assertEquals("First Second", streamingState.value.streamingMessage?.content) } + @Test + fun thinkingDelta_trailingFlushAndResetDoNotLeakIntoNextTurn() = + runTest { + val uiState = MutableStateFlow(ChatUiState()) + val streamingState = MutableStateFlow(StreamingState()) + val controller = controller(this, uiState, streamingState, isTestEnvironment = { false }) + + controller.handleThinkingDelta(WsEvent.ThinkingDelta("Think", "session")) + controller.handleThinkingDelta(WsEvent.ThinkingDelta(" more", "session")) + assertEquals("Think", streamingState.value.thinkingText) + testScheduler.advanceTimeBy(35L) + assertEquals("Think more", streamingState.value.thinkingText) + + controller.resetStreaming() + controller.handleThinkingDelta(WsEvent.ThinkingDelta("Fresh", "session")) + assertEquals("Fresh", streamingState.value.thinkingText) + } + private fun controller( scope: CoroutineScope, uiState: MutableStateFlow, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt index a3179f5d..cf4a55ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt @@ -448,7 +448,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returnsMany listOf( SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false), SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false), @@ -485,7 +485,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false) delegate.inspectSubagentTranscript("sub-1") @@ -507,7 +507,7 @@ class ChatSubagentsDelegateTest { advanceTimeBy(3000) // Verify tailSubagent was called at most once or twice, not indefinitely - coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) } + coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1") } delegate.closeSubagentTranscript() } @@ -526,7 +526,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Output") delegate.inspectSubagentTranscript("sub-1") @@ -553,7 +553,7 @@ class ChatSubagentsDelegateTest { ), ) - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } throws RuntimeException("Gateway connection error") delegate.inspectSubagentTranscript("sub-1") @@ -565,7 +565,7 @@ class ChatSubagentsDelegateTest { assertFalse(transcript?.isLoading == true) // Now recover on retry - coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns + coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs") delegate.retryTranscript() 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 a943144a..65c97925 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 @@ -35,6 +35,10 @@ 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.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopParams +import com.m57.hermescontrol.data.ws.contract.ProcessStopResult +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore @@ -63,6 +67,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -280,6 +285,9 @@ class ChatViewModelTest { HermesWsClient.send(arg(0), arg(1)) {} CompletableDeferred(Unit) } + // /stop also fires process.stop; default to "nothing to kill" so unrelated /stop tests never hit the socket. + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 0) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -767,8 +775,8 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) - assertEquals("my-fork", branchSent.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"]) } @Test @@ -813,7 +821,7 @@ class ChatViewModelTest { val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be dispatched for /fork", branchSent) - assertEquals(sessionId, branchSent!!.second["session_id"]) + assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"]) assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name")) } @@ -876,8 +884,8 @@ class ChatViewModelTest { val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE } assertNotNull("session.branch_whole should be sent", branchWholeSent) - assertEquals("my-retry-branch", branchWholeSent!!.second["name"]) - assertEquals(sessionId, branchWholeSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"]) // Backend answers -32601 unknown method mockEventsFlow.emit( @@ -891,8 +899,8 @@ class ChatViewModelTest { // Retries ONCE with session.branch using same params val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH } assertNotNull("session.branch fallback should be sent", branchSent) - assertEquals("my-retry-branch", branchSent!!.second["name"]) - assertEquals(sessionId, branchSent.second["session_id"]) + assertEquals(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"]) + assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"]) assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH }) // session.branch result is then handled @@ -963,6 +971,52 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + @Test + fun testSlashCommand_stop_alsoKillsBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns + ProcessStopResult(killed = 2) + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 1) { HermesWsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams, any(), any()) } + val contents = + viewModel.uiState.value.messages + .map { it.content } + assertTrue(contents.contains("Stopped 2 background processes.")) + } + + @Test + fun testSlashCommand_interrupt_doesNotKillBackgroundProcesses() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + viewModel.sendMessage("/interrupt") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } + coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } + } + + @Test + fun testSlashCommand_stop_reportsProcessStopFailure() = + runTest { + val (viewModel, _) = createViewModelWithSession() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } throws + IllegalStateException("boom") + + viewModel.sendMessage("/stop") + advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.messages + .any { it.content == "Could not stop background processes: boom" }, + ) + } + @Test fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() = runTest { @@ -1038,7 +1092,15 @@ class ChatViewModelTest { val contents = merged.map { it.content } assertEquals( - listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"), + listOf( + "start task", + "running...", + "/stop", + "No background processes to stop.", + "Session interrupted", + "next prompt", + "next reply", + ), contents, ) } @@ -1077,7 +1139,12 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.PROMPT_BTW, - mapOf("session_id" to sessionId, "text" to "which file was that in?"), + JsonObject( + mapOf( + "session_id" to JsonPrimitive(sessionId), + "text" to JsonPrimitive("which file was that in?"), + ), + ), any(), ) } @@ -1289,15 +1356,9 @@ class ChatViewModelTest { // commands). NOT command.dispatch (4018s on /model), NOT prompt.submit // (LLM would treat it as text). Capture the config.set params. val modelCalls = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) - modelCalls.add( - Triple( - params["key"] as String, - params["value"] as String, - params["session_id"] as String, - ), - ) + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) + modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty())) "req-cfg-${modelCalls.size}" } @@ -1309,7 +1370,7 @@ class ChatViewModelTest { "openai/gpt-4o", viewModel.uiState.value.currentSessionModel, ) - verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } val call = modelCalls.firstOrNull { it.first == "model" } assertNotNull("selection must route through config.set key=model", call) assertEquals("gpt-4o --provider openai --session", call!!.second) @@ -1334,7 +1395,7 @@ class ChatViewModelTest { // (key="model"), which the gateway routes to _apply_model_switch. NOT // command.dispatch (4018s on /model) and NOT prompt.submit (LLM would // treat it as text). - verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } } @Test @@ -1347,15 +1408,9 @@ class ChatViewModelTest { // slash prefix must be stripped, or parse_model_flags on the // backend won't recognize it and the hot-swap silently fails. val modelCalls = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) - modelCalls.add( - Triple( - params["key"] as String, - params["value"] as String, - params["session_id"] as String, - ), - ) + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) + modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty())) "req-cfg-ci-${modelCalls.size}" } @@ -1382,9 +1437,9 @@ class ChatViewModelTest { val (viewModel, sessionId) = createViewModelWithSession() var lastReqId = "" - val capturedParams = mutableListOf>() - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { - val params = arg>(1) + val capturedParams = mutableListOf() + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { + val params = arg(1) capturedParams.add(params) val id = "req-confirm-${capturedParams.size}" lastReqId = id @@ -1397,7 +1452,7 @@ class ChatViewModelTest { advanceUntilIdle() assertEquals(1, capturedParams.size) - assertNull(capturedParams[0]["confirm_expensive_model"]) + assertNull(capturedParams[0].confirmExpensiveModel) // Backend returns confirm_required = true mockEventsFlow.emit( @@ -1423,12 +1478,12 @@ class ChatViewModelTest { assertNull(viewModel.uiState.value.modelSwitchConfirmMessage) assertEquals(2, capturedParams.size) - assertEquals(true, capturedParams[1]["confirm_expensive_model"]) + assertEquals(true, capturedParams[1].confirmExpensiveModel) assertEquals( "muse-spark-1.3-contributor-free --provider opencode-free --session", - capturedParams[1]["value"], + capturedParams[1].value, ) - assertEquals(sessionId, capturedParams[1]["session_id"]) + assertEquals(sessionId, capturedParams[1].sessionId) } @Test @@ -1437,7 +1492,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() var lastReqId = "" - every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + every { HermesWsClient.send(RpcMethods.CONFIG_SET, any(), any()) } answers { val id = "req-cfg-dismiss" lastReqId = id arg<((String) -> Unit)?>(2)?.invoke(id) @@ -1487,7 +1542,11 @@ class ChatViewModelTest { every { HermesWsClient.request( WsMethods.COMMAND_DISPATCH, - mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId), + buildJsonObject { + put("name", "undo") + put("arg", "2") + put("session_id", sessionId) + }, any(), ) } returns @@ -1573,7 +1632,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-from-notification", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-from-notification"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -3858,6 +3922,41 @@ class ChatViewModelTest { verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) } } + @Test + fun lateCompletionAfterInterruptUpdatesSealedReplyInPlace() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit(WsEvent.MessageStart(sessionId)) + mockEventsFlow.emit(WsEvent.MessageToken("partial reply", sessionId)) + advanceUntilIdle() + val originalId = + viewModel.streamingState.value.streamingMessage!! + .id + + viewModel.interruptSession() + advanceUntilIdle() + val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second + mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted"))) + advanceUntilIdle() + assertNull(viewModel.streamingState.value.streamingMessage) + + mockEventsFlow.emit(WsEvent.MessageToken("late tail", sessionId)) + advanceUntilIdle() + assertNull(viewModel.streamingState.value.streamingMessage) + mockEventsFlow.emit(WsEvent.MessageComplete("partial reply with final tail", sessionId)) + advanceUntilIdle() + + val messages = viewModel.uiState.value.messages + val replies = messages.filter { it.role == MessageRole.ASSISTANT } + assertEquals(1, replies.size) + assertEquals(originalId, replies.single().id) + assertEquals("partial reply with final tail", replies.single().content) + assertTrue( + messages.indexOf(replies.single()) < messages.indexOfFirst { it.content == "Session interrupted" }, + ) + assertNull(viewModel.streamingState.value.interruptedMessage) + } + @Test fun hardInterruptWaitsForAckBeforeSubmitting() = runTest { @@ -4983,7 +5082,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -4996,7 +5100,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5090,7 +5194,7 @@ class ChatViewModelTest { val (viewModel, _) = createViewModelWithSession() val resumeRequests = mutableMapOf() every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { - val sessionId = arg>(1).getValue("session_id") + val sessionId = (arg>(1).getValue("session_id") as JsonPrimitive).content val requestId = "resume-$sessionId" resumeRequests[sessionId] = requestId arg<((String) -> Unit)?>(2)?.invoke(requestId) @@ -5689,14 +5793,14 @@ class ChatViewModelTest { verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } verify(exactly = 0) { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "session-456" }, + match { it["session_id"] == JsonPrimitive("session-456") }, any(), ) } @@ -5718,18 +5822,18 @@ class ChatViewModelTest { verify { HermesWsClient.request( WsMethods.SESSION_CONTEXT_BREAKDOWN, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } verify { HermesWsClient.request( WsMethods.SESSION_USAGE, - match { it["session_id"] == "runtime-456" }, + match { it["session_id"] == JsonPrimitive("runtime-456") }, any(), ) } - assertEquals("runtime-456", paramsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"]) } @Test @@ -5833,7 +5937,12 @@ class ChatViewModelTest { verify { HermesWsClient.send( WsMethods.SESSION_RESUME, - mapOf("session_id" to "session-456", "omit_messages" to true), + JsonObject( + mapOf( + "session_id" to JsonPrimitive("session-456"), + "omit_messages" to JsonPrimitive(true), + ), + ), any(), ) } @@ -6343,7 +6452,13 @@ class ChatViewModelTest { viewModel.interruptSession() advanceUntilIdle() - verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, mapOf("session_id" to sessionId), any()) } + verify { + HermesWsClient.send( + WsMethods.SESSION_INTERRUPT, + match { it["session_id"] == JsonPrimitive(sessionId) && it.size == 1 }, + any(), + ) + } } @Test @@ -6592,9 +6707,9 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals(sessionId, params["session_id"]) - assertEquals("once", params["choice"]) - assertEquals("req-42", params["request_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) + assertEquals(JsonPrimitive("once"), params["choice"]) + assertEquals(JsonPrimitive("req-42"), params["request_id"]) }, any(), ) @@ -6626,8 +6741,8 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("session", params["choice"]) - assertEquals("req-7", params["request_id"]) + assertEquals(JsonPrimitive("session"), params["choice"]) + assertEquals(JsonPrimitive("req-7"), params["request_id"]) }, any(), ) @@ -6657,7 +6772,7 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("deny", params["choice"]) + assertEquals(JsonPrimitive("deny"), params["choice"]) assertFalse(params.containsKey("request_id")) }, any(), @@ -6690,7 +6805,7 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_PENDING, withArg { params -> - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) }, any(), ) @@ -6736,8 +6851,8 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RECEIVED, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) - assertEquals("req-runtime-test", params["request_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) + assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"]) }, any(), ) @@ -6750,16 +6865,16 @@ class ChatViewModelTest { HermesWsClient.send( WsMethods.APPROVAL_RESPOND, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) - assertEquals("once", params["choice"]) - assertEquals("req-runtime-test", params["request_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) + assertEquals(JsonPrimitive("once"), params["choice"]) + assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"]) }, any(), ) HermesWsClient.send( WsMethods.APPROVAL_PENDING, withArg { params -> - assertEquals("runtime-sid-999", params["session_id"]) + assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"]) }, any(), ) @@ -7221,7 +7336,7 @@ class ChatViewModelTest { verify { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any()) } assertEquals( "session_id must be forwarded to image.attach_bytes", - sessionId, + JsonPrimitive(sessionId), paramsSlot.captured["session_id"], ) assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty()) @@ -9470,7 +9585,7 @@ class ChatViewModelTest { any(), ) } - assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"]) + assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"]) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 6d3dc887..e3cbb3bb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.MessageReaction import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.ws.WsEvent import org.junit.Assert.assertEquals @@ -9,6 +10,43 @@ import org.junit.Assert.assertTrue import org.junit.Test class ChatWsEventReducerTest { + @Test + fun interruptedIdentitySurvivesDoneButNotANewStart() { + val partial = ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "old reply") + val state = ChatUiState(currentSessionId = "session-1", messages = listOf(partial)) + val interrupted = StreamingState(interruptedMessage = partial) + val done = + ChatWsEventReducer.reduce( + state, + interrupted, + WsEvent.MessageDone("session-1"), + "session-1", + ) + assertEquals(partial, done.streamingState.interruptedMessage) + val started = + ChatWsEventReducer.reduce( + done.state, + done.streamingState, + WsEvent.MessageStart("session-1"), + "session-1", + ) + assertNull(started.streamingState.interruptedMessage) + val complete = + ChatWsEventReducer.reduce( + started.state, + started.streamingState, + WsEvent.MessageComplete("new reply", "session-1"), + "session-1", + ) + assertEquals( + "old reply", + complete.state.messages + .first() + .content, + ) + assertEquals(2, complete.state.messages.count { it.role == MessageRole.ASSISTANT }) + } + @Test fun testMessageComplete_clearsResolvedClarifyRequest() { val state = @@ -1605,4 +1643,60 @@ class ChatWsEventReducerTest { assertEquals("idle", result.state.compressionStatus) assertEquals(initialMessages, result.state.messages) } + + @Test + fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() { + val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other)) + val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent")) + val result = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"), + "s", + ) + assertEquals(reactions, result.state.messages[0].reactions) + assertTrue( + result.state.messages[1] + .reactions + .isEmpty(), + ) + val retracted = + ChatWsEventReducer.reduce( + result.state, + result.streamingState, + WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"), + "s", + ) + assertTrue( + retracted.state.messages[0] + .reactions + .isEmpty(), + ) + } + + @Test + fun messageReactionForUnknownRowOrOtherSessionIsIgnored() { + val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(msg)) + val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent")) + val unknownRow = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"), + "s", + ) + assertEquals(state, unknownRow.state) + val otherSession = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"), + "s", + ) + assertEquals(state, otherSession.state) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt index 93a76b07..6e9fefb0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import com.m57.hermescontrol.theme.GlyphWhite import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.resolveColorScheme import org.junit.Assert.assertTrue @@ -66,6 +67,20 @@ class ComposerPaletteTest { } } + @Test + fun actionButtonFollowsThePresetPrimaryWhenItIsLegible() { + forEveryPresetMode { mode, p, _ -> + val scheme = resolveColorScheme(ThemePreset.valueOf(mode.substringBefore(" ")), mode.endsWith("true")) + if (contrast(scheme.primary, p.card) >= 3f) { + assertTrue("$mode action must be primary", p.action == scheme.primary) + assertTrue( + "$mode glyph must be white or onPrimary", + p.onAction == scheme.onPrimary || p.onAction == GlyphWhite, + ) + } + } + } + @Test fun controlsAndCardEdgeStayVisibleInEveryPresetMode() { forEveryPresetMode { mode, p, background -> diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt index 0df28900..1f4f08bf 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex +import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex import org.junit.Assert.assertEquals import org.junit.Test @@ -24,6 +25,37 @@ class FullBleedItemKeysTest { assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2)) } + @Test + fun searchTargetsResolveHoistedReasoningAndToolRows() { + val messages = + listOf( + ChatMessage("u", MessageRole.USER, "question"), + ChatMessage("t", MessageRole.TOOL, "output", toolName = "terminal"), + ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"), + ) + val turns = groupIntoTurns(messages) + + assertEquals(1, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0, SearchTarget.REASONING))) + assertEquals(2, searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.TOOL))) + assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0))) + } + + @Test + fun secondaryReasoningResolvesToItsProseRow() { + val messages = + listOf( + ChatMessage("a1", MessageRole.ASSISTANT, "one", reasoningText = "first"), + ChatMessage("a2", MessageRole.ASSISTANT, "two", reasoningText = "second"), + ) + val turns = groupIntoTurns(messages) + val keys = fullBleedItemKeys(turns) + + assertEquals( + keys.indexOf("prose-a2"), + searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.REASONING)), + ) + } + @Test fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt deleted file mode 100644 index 4b458a8e..00000000 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ImageBytesResolverTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.m57.hermescontrol.ui.chat - -import android.content.Context -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.util.Base64 - -@OptIn(ExperimentalCoroutinesApi::class) -class ImageBytesResolverTest { - private val context = mockk(relaxed = true) - - @Before - fun setUp() { - // resolve() runs on Dispatchers.IO; bind the main dispatcher for tests. - Dispatchers.setMain(StandardTestDispatcher()) - } - - @Test - fun `extensionForMime maps known types`() { - assertEquals("png", ImageBytesResolver.extensionForMime("image/png")) - assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg")) - assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG")) - assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif")) - assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp")) - assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic")) - assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml")) - assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown")) - } - - @Test - fun `data URL base64 decodes to bytes with mime and extension`() = - runTest { - val raw = "hello-image".toByteArray() - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:image/png;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/*") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertArrayEquals(raw, result.bytes) - assertEquals("image/png", result.mimeType) - assertEquals("png", result.extension) - } - - @Test - fun `data URL falls back to fallbackMime when meta omitted`() = - runTest { - val raw = byteArrayOf(1, 2, 3, 4) - val b64 = Base64.getEncoder().encodeToString(raw) - val model = "data:;base64,$b64" - - val result = ImageBytesResolver.resolve(context, model, "image/webp") - - assertTrue(result is ImageBytesResolver.Result.Bytes) - result as ImageBytesResolver.Result.Bytes - assertEquals("image/webp", result.mimeType) - assertEquals("webp", result.extension) - } - - @Test - fun `malformed data URL (no comma) returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `non-base64 data URL returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } - - @Test - fun `unsupported model source returns Error`() = - runTest { - val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*") - assertTrue(result is ImageBytesResolver.Result.Error) - } -} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt new file mode 100644 index 00000000..73526acd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt @@ -0,0 +1,76 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.StatusBlue +import com.m57.hermescontrol.theme.StatusBlueContainer +import com.m57.hermescontrol.theme.StatusGreen +import com.m57.hermescontrol.theme.StatusGreenContainer +import com.m57.hermescontrol.theme.StatusRed +import com.m57.hermescontrol.theme.StatusRedContainer +import com.m57.hermescontrol.theme.StatusYellow +import com.m57.hermescontrol.theme.StatusYellowContainer +import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import org.junit.Test +import kotlin.system.measureNanoTime + +/** Diagnostic only (opt-in via -PincludeBenchmarks): per-update cost while a list streams from 1 to 1000 bullets. */ +class MarkdownBulletRunBenchmark { + private val highlights = + searchHighlightColors( + HermesStatusColors( + success = StatusGreen, + successContainer = StatusGreenContainer, + onSuccess = Color.White, + warning = StatusYellow, + warningContainer = StatusYellowContainer, + onWarning = Color.White, + error = StatusRed, + errorContainer = StatusRedContainer, + onError = Color.White, + info = StatusBlue, + infoContainer = StatusBlueContainer, + onInfo = Color.White, + ), + ) + + private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md)) + + private fun bullets(n: Int) = (1..n).joinToString("\n") { "- item $it" } + + private fun render(run: BulletRun): AnnotatedString = + buildBulletRunText(run, Density(1f), Color.Black, "", false, Color.Blue, highlights) { (it.length * 7).sp } + + @Test + fun benchmarkStreamingGrowth() { + val sizes = listOf(100, 250, 500, 750, 1000) + val sb = StringBuilder() + var done = 0 + val results = mutableListOf() + // warm the JIT + repeat(30) { coalesce(bullets(1000)) } + for (target in sizes) { + while (done < target) { + sb.append("- item ${++done}\n") + } + val md = sb.toString() + val n = 50 + val parse = measureNanoTime { repeat(n) { parseBlocks(md) } } / n / 1e6 + val full = + measureNanoTime { + repeat(n) { + (coalesce(md).firstOrNull { it is BulletRun } as? BulletRun)?.let { render(it) } + } + } / n / 1e6 + results += "$target bullets: parse=${"%.2f".format(parse)}ms parse+coalesce+build=${"%.2f".format(full)}ms" + } + results.forEach { println("BENCHMARK_RESULT: stream $it") } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt new file mode 100644 index 00000000..f42b8f1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt @@ -0,0 +1,240 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.StatusBlue +import com.m57.hermescontrol.theme.StatusBlueContainer +import com.m57.hermescontrol.theme.StatusGreen +import com.m57.hermescontrol.theme.StatusGreenContainer +import com.m57.hermescontrol.theme.StatusRed +import com.m57.hermescontrol.theme.StatusRedContainer +import com.m57.hermescontrol.theme.StatusYellow +import com.m57.hermescontrol.theme.StatusYellowContainer +import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.ui.chat.markdown.BulletRun +import com.m57.hermescontrol.ui.chat.markdown.MIN_BULLET_RUN +import com.m57.hermescontrol.ui.chat.markdown.MdBlock +import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText +import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns +import com.m57.hermescontrol.ui.chat.markdown.parseBlocks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private val HIGHLIGHTS = + searchHighlightColors( + HermesStatusColors( + success = StatusGreen, + successContainer = StatusGreenContainer, + onSuccess = Color.White, + warning = StatusYellow, + warningContainer = StatusYellowContainer, + onWarning = Color.White, + error = StatusRed, + errorContainer = StatusRedContainer, + onError = Color.White, + info = StatusBlue, + infoContainer = StatusBlueContainer, + onInfo = Color.White, + ), + ) + +private fun bullets( + n: Int, + text: (Int) -> String = { "item $it" }, +) = (1..n).joinToString("\n") { "- ${text(it)}" } + +private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md)) + +/** Stand-in for TextMeasurer: prefix width grows with font size, like the real bullet glyph. */ +private fun fakePrefixWidth(fontSizeSp: Float): (String) -> TextUnit = + { prefix -> (prefix.length * fontSizeSp * 0.5f).sp } + +private fun render( + run: BulletRun, + query: String = "", + density: Density = Density(1f), + fontSizeSp: Float = 14f, +) = buildBulletRunText(run, density, Color.Black, query, false, Color.Blue, HIGHLIGHTS, fakePrefixWidth(fontSizeSp)) + +class MarkdownBulletRunTest { + @Test + fun longPlainRunCoalescesIntoOneBlock() { + val out = coalesce(bullets(1000)) + assertEquals(1, out.size) + assertEquals(1000, (out.single() as BulletRun).items.size) + } + + @Test + fun shortRunStaysUncoalesced() { + val out = coalesce(bullets(MIN_BULLET_RUN - 1)) + assertEquals(MIN_BULLET_RUN - 1, out.size) + assertTrue(out.all { it is MdBlock.Bullet }) + } + + @Test + fun runAtThresholdCoalesces() { + assertTrue(coalesce(bullets(MIN_BULLET_RUN)).single() is BulletRun) + } + + @Test + fun rtlBulletBreaksTheRunAndKeepsOldRenderer() { + val md = bullets(40) { if (it == 20) "مرحبا بالعالم" else "item $it" } + val out = coalesce(md) + assertEquals(3, out.size) + assertEquals(19, (out[0] as BulletRun).items.size) + assertTrue(out[1] is MdBlock.Bullet) + assertEquals(20, (out[2] as BulletRun).items.size) + } + + @Test + fun fullyRtlListNeverCoalesces() { + val out = coalesce(bullets(40) { "مرحبا $it" }) + assertTrue(out.none { it is BulletRun }) + } + + @Test + fun inlineMathBreaksTheRun() { + val out = coalesce(bullets(40) { if (it == 20) "energy \$E=mc^2\$" else "item $it" }) + assertTrue(out[1] is MdBlock.Bullet) + assertEquals(3, out.size) + } + + @Test + fun nestedBulletsStayInTheRunWithLevelGlyphAndIndent() { + val md = bullets(20) + "\n - child\n - grandchild\n" + bullets(20) + val run = coalesce(md).single() as BulletRun + assertEquals(42, run.items.size) + assertEquals( + listOf(0, 1, 2, 0), + listOf(run.items[19], run.items[20], run.items[21], run.items[22]).map { it.level }, + ) + val text = render(run) + val lines = text.text.split("\n") + assertTrue(lines[20].startsWith("\u25E6")) + assertTrue(lines[21].startsWith("\u25AA")) + val indents = + text.paragraphStyles.map { + it.item.textIndent!! + .firstLine.value + } + assertEquals(listOf(0f, 16f, 32f, 0f), listOf(indents[19], indents[20], indents[21], indents[22])) + } + + @Test + fun bulletWithNestedSourceFallsBack() { + val md = bullets(20) + "\n- parent\n ```\n code\n ```\n" + bullets(20) + val out = coalesce(md) + assertTrue(out.any { it is MdBlock.Bullet && it.nestedSource.isNotEmpty() }) + assertTrue(out.filterIsInstance().flatMap { it.items }.all { it.nestedSource.isEmpty() }) + } + + @Test + fun taskAndOrderedItemsNeverJoinARun() { + val md = bullets(20) + "\n- [ ] todo\n1. one\n2. two\n" + bullets(20) + val out = coalesce(md) + assertEquals(2, out.count { it is BulletRun }) + assertTrue(out.any { it is MdBlock.Task }) + assertTrue(out.any { it is MdBlock.Ordered }) + } + + @Test + fun surroundingBlocksAreUntouchedAndOrdered() { + val out = coalesce("# Title\n\n" + bullets(30) + "\n\nTail paragraph") + assertTrue(out.first() is MdBlock.Heading) + assertTrue(out[1] is BulletRun) + assertTrue(out.last() is MdBlock.Paragraph) + } + + @Test + fun mergedTextKeepsEveryLineWithGlyphs() { + val run = coalesce(bullets(20)).single() as BulletRun + val lines = render(run).text.split("\n") + assertEquals(20, lines.size) + assertTrue(lines.all { it.startsWith("\u2022") }) + assertTrue(lines.last().endsWith("item 20")) + } + + @Test + fun linksSurviveMerging() { + val run = + coalesce(bullets(20) { if (it == 5) "see [docs](https://example.com/a) now" else "item $it" }) + .single() as BulletRun + val text = render(run) + val links = text.getLinkAnnotations(0, text.length) + assertEquals(1, links.size) + assertEquals("https://example.com/a", (links.single().item as LinkAnnotation.Url).url) + assertEquals("docs", text.text.substring(links.single().start, links.single().end)) + } + + @Test + fun bareUrlsStayLinks() { + val run = coalesce(bullets(20) { if (it == 3) "https://example.com/x" else "item $it" }).single() as BulletRun + val text = render(run) + assertEquals(1, text.getLinkAnnotations(0, text.length).size) + } + + @Test + fun searchHighlightLandsOnTheMatchOffsets() { + val run = coalesce(bullets(20)).single() as BulletRun + val text = render(run, query = "item 7") + val spans = text.spanStyles.filter { it.item.background != Color.Unspecified } + assertTrue(spans.isNotEmpty()) + spans.forEach { assertEquals("item 7", text.text.substring(it.start, it.end)) } + } + + @Test + fun noHighlightWithoutQuery() { + val run = coalesce(bullets(20)).single() as BulletRun + assertTrue(render(run).spanStyles.none { it.item.background != Color.Unspecified }) + } + + @Test + fun levelOffsetIsPhysicalDpAcrossFontScales() { + val run = coalesce(bullets(20) + "\n - child\n").single() as BulletRun + val normal = render(run, density = Density(2f, fontScale = 1f)) + val big = render(run, density = Density(2f, fontScale = 2f)) + val n = + normal.paragraphStyles[20] + .item.textIndent!! + .firstLine.value + val b = + big.paragraphStyles[20] + .item.textIndent!! + .firstLine.value + // 16dp at density 2 = 32px; sp value shrinks by fontScale so physical size stays 16dp. + assertEquals(n, b * 2f, 0.001f) + } + + @Test + fun hangingIndentIncludesTheFontRelativePrefix() { + val run = coalesce(bullets(20)).single() as BulletRun + val small = + render(run, fontSizeSp = 14f) + .paragraphStyles + .first() + .item.textIndent!! + val large = + render(run, fontSizeSp = 28f) + .paragraphStyles + .first() + .item.textIndent!! + // Wrapped lines must sit right of the first line by the prefix width, which grows with the font. + assertTrue(small.restLine.value > small.firstLine.value) + assertEquals( + (small.restLine.value - small.firstLine.value) * 2f, + large.restLine.value - large.firstLine.value, + 0.001f, + ) + } + + @Test + fun oneParagraphStylePerBullet() { + val run = coalesce(bullets(50)).single() as BulletRun + assertEquals(50, render(run).paragraphStyles.size) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt index cbe5511e..917ae076 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.ui.chat.markdown.parseBlocks import org.junit.Test +import kotlin.system.measureNanoTime import kotlin.system.measureTimeMillis class MarkdownTextBenchmark { @@ -47,4 +48,19 @@ class MarkdownTextBenchmark { println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms") } + + @Test + fun benchmarkParseBlocksThousandBullets() { + val md = (1..1000).joinToString("\n") { "- $it" } + + // Cold: first parse in this JVM, before any warmup. + val cold = measureNanoTime { parseBlocks(md) } + + repeat(50) { parseBlocks(md) } + val iterations = 200 + val warmNanos = measureNanoTime { repeat(iterations) { parseBlocks(md) } } + + println("BENCHMARK_RESULT: parseBlocks 1000 bullets cold=${cold / 1_000_000.0}ms") + println("BENCHMARK_RESULT: parseBlocks 1000 bullets warm avg=${warmNanos / iterations / 1_000_000.0}ms") + } } 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 91bed590..36e8dbfa 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 @@ -459,6 +459,28 @@ class MarkdownTextFeatureTest { assertEquals(0, o16.level) } + @Test + fun quotedBlocksRetainNestedHeadingsListsAndFences() { + val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```" + val quote = parseBlocks(source).single() as MdBlock.Quote + val nested = parseBlocks(quote.text) + assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" }) + val bullet = nested.filterIsInstance().single() + assertEquals("item", bullet.text) + assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code) + } + + @Test + fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() { + val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next" + val bullets = parseBlocks(source).filterIsInstance() + assertEquals(listOf("intro", "next"), bullets.map { it.text }) + val nested = parseBlocks(bullets.first().nestedSource) + assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" }) + assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" }) + assertEquals("", bullets.last().nestedSource) + } + // 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES @Test fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt new file mode 100644 index 00000000..fd1ce63f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaSegmentsTest.kt @@ -0,0 +1,62 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.model.Attachment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaSegmentsTest { + private fun att( + name: String, + offset: Int?, + ) = Attachment(uri = "u/$name", name = name, mimeType = "image/png", contentOffset = offset) + + private fun kinds(segments: List) = + segments.map { + when (it) { + is MessageSegment.Text -> "T:${it.text}" + is MessageSegment.Media -> "M:${it.attachment.name}" + } + } + + @Test + fun extractRecordsOffsetsIntoStrippedText() { + val text = "First: MEDIA:/a/1.png\nthen second: MEDIA:/a/2.png\nend" + val stripped = HostMediaExtractor.strip(text) + val items = HostMediaExtractor.extract(text) + assertEquals(2, items.size) + assertTrue(items[0].offset <= items[1].offset) + assertTrue(items[1].offset <= stripped.length) + } + + @Test + fun mediaLandsBetweenTheParagraphsItSatBetween() { + val text = "Chart one:\nMEDIA:/a/1.png\nChart two:\nMEDIA:/a/2.png\nDone." + val stripped = HostMediaExtractor.strip(text) + val items = HostMediaExtractor.extract(text) + val atts = items.map { att(it.path.substringAfterLast('/'), it.offset) } + val out = kinds(splitByMedia(stripped, atts)) + assertEquals(listOf("T:Chart one:", "M:1.png", "T:Chart two:", "M:2.png", "T:Done."), out) + } + + @Test + fun noOffsetAppendsAfterText() { + val out = kinds(splitByMedia("Hello", listOf(att("x.png", null)))) + assertEquals(listOf("T:Hello", "M:x.png"), out) + } + + @Test + fun mediaOnlyMessageHasNoBlankText() { + val out = kinds(splitByMedia("", listOf(att("x.png", 0)))) + assertEquals(listOf("M:x.png"), out) + } + + @Test + fun mediaDoesNotSplitAFencedCodeBlock() { + val content = "Look:\n```\ncode line\nmore\n```\nafter" + val out = kinds(splitByMedia(content, listOf(att("x.png", content.indexOf("code line"))))) + assertEquals(2, out.count { it.startsWith("T:") }) + assertTrue(out[0].contains("more") && out[0].endsWith("```")) + assertEquals("M:x.png", out[1]) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt index 7060ef35..86fb2a90 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MediaTypesTest.kt @@ -30,6 +30,30 @@ class MediaTypesTest { assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird")) } + @Test + fun `extension for mime uses one table plus aliases`() { + // Table entries, including params and case. + assertEquals("png", extensionForMime("image/png")) + assertEquals("png", extensionForMime(" IMAGE/PNG; charset=binary")) + assertEquals("ogg", extensionForMime("audio/ogg; codecs=opus")) + assertEquals("mkv", extensionForMime("video/x-matroska")) + assertEquals("pdf", extensionForMime("application/pdf")) + // Aliases win over first-table-match. + assertEquals("jpg", extensionForMime("image/jpeg")) + assertEquals("jpg", extensionForMime("image/jpg")) + assertEquals("heic", extensionForMime("image/heif")) + assertEquals("mp3", extensionForMime("audio/mp3")) + assertEquals("m4a", extensionForMime("audio/x-m4a")) + assertEquals("wav", extensionForMime("audio/wave")) + assertEquals("flac", extensionForMime("application/x-flac")) + assertEquals("ogg", extensionForMime("application/ogg")) + // Family fallbacks. + assertEquals("mp4", extensionForMime("video/unknown")) + assertEquals("mp3", extensionForMime("audio/unknown")) + assertEquals("img", extensionForMime("image/unknown")) + assertEquals("bin", extensionForMime("application/unknown")) + } + @Test fun `name extraction handles urls and paths`() { assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf")) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt index b06cbaba..30c3b379 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ReadNotificationReviewRegressionTest.kt @@ -169,11 +169,29 @@ class ReadNotificationReviewRegressionTest { SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")), ) for (latest in listOf(true, false)) { - val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context) + val mapped = + mapServerMessages( + "session", + rows, + 10, + latest, + listOf(live), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals(listOf("comp", null), mapped.map { it.completionId }) val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true) assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId) - assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId) + // Target row absent from this page: the reserved completion must fail closed, not rebind by text. + val targetRowAbsent = + mapServerMessages( + "session", + rows.takeLast(1), + 11, + latest, + listOf(live), + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) + assertNull(targetRowAbsent.single().completionId) } } @@ -214,6 +232,7 @@ class ReadNotificationReviewRegressionTest { 0, true, current, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(), ) val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single() assertEquals("ws", merged.id) @@ -256,7 +275,7 @@ class ReadNotificationReviewRegressionTest { latestPaging = true, liveMessages = current, isPagingOlder = false, - context = context, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), ) val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true) @@ -685,7 +704,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("Older message must have null completionId", null, mapped[0].completionId) assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId) @@ -747,7 +774,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("comp-long-2", mapped[0].completionId) assertEquals(null, mapped[1].completionId) @@ -796,7 +831,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) val row = mapped.single() assertEquals("comp-media-cold", row.completionId) assertEquals("Here is the image", row.content) @@ -839,7 +882,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) val row = mapped.single() assertEquals("comp-media-only-cold", row.completionId) assertEquals("", row.content) @@ -887,7 +938,15 @@ class ReadNotificationReviewRegressionTest { ) val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals("comp-media-dup-2", mapped[0].completionId) assertEquals(null, mapped[1].completionId) @@ -955,7 +1014,15 @@ class ReadNotificationReviewRegressionTest { } val mapped = - mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context) + mapServerMessages( + "session", + history, + 0, + true, + emptyList(), + isPagingOlder = false, + activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context), + ) assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId }) assertFalse( 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 1447a65e..029b9311 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 @@ -12,6 +12,9 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConfigGetParams +import com.m57.hermescontrol.data.ws.contract.ConfigSetParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.coEvery @@ -21,6 +24,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll +import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,8 +36,10 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonPrimitive import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -213,9 +219,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("help", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("help"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -271,35 +277,27 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val setCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_SET) { - kotlinx.serialization.json.buildJsonObject { - put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) - put("value", kotlinx.serialization.json.JsonPrimitive("high")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("high")) + } } vm.sendMessage("/reasoning high --global") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) - assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals("high", params["value"]) - assertEquals(sessionId, params["session_id"]) - assertEquals("global", params["scope"]) + assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size) + val params = setCalls.single() + assertEquals("reasoning", params.key) + assertEquals("high", params.value) + assertEquals(sessionId, params.sessionId) + assertEquals("global", params.scope) assertEquals("high", vm.uiState.value.reasoningLevel) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } @Test @@ -307,33 +305,26 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val setCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_SET) { - kotlinx.serialization.json.buildJsonObject { - put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) - put("value", kotlinx.serialization.json.JsonPrimitive("show")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("key", kotlinx.serialization.json.JsonPrimitive("reasoning")) + put("value", kotlinx.serialization.json.JsonPrimitive("show")) + } } vm.sendMessage("/reasoning show") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET) - assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals("show", params["value"]) - assertEquals(sessionId, params["session_id"]) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size) + val params = setCalls.single() + assertEquals("reasoning", params.key) + assertEquals("show", params.value) + assertEquals(sessionId, params.sessionId) + assertNull(params.scope) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } assertEquals( "reasoning: show", vm.uiState.value.messages @@ -347,32 +338,24 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodCalls = mutableListOf() - val paramsCalls = mutableListOf>() - every { - HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) + val getCalls = mutableListOf() + coEvery { + HermesWsClient.call(RpcMethods.CONFIG_GET, capture(getCalls), any(), any()) } answers { - val result = - if (arg(0) == WsMethods.CONFIG_GET) { - kotlinx.serialization.json.buildJsonObject { - put("value", kotlinx.serialization.json.JsonPrimitive("ultra")) - put("display", kotlinx.serialization.json.JsonPrimitive("hide")) - } - } else { - kotlinx.serialization.json.JsonNull - } - CompletableDeferred(result) + kotlinx.serialization.json.buildJsonObject { + put("value", kotlinx.serialization.json.JsonPrimitive("ultra")) + put("display", kotlinx.serialization.json.JsonPrimitive("hide")) + } } vm.sendMessage("/reasoning") advanceUntilIdle() - val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET) - assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0) - val params = paramsCalls[configIndex] - assertEquals("reasoning", params["key"]) - assertEquals(sessionId, params["session_id"]) - assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls) + assertEquals("expected exactly one config.get, got $getCalls", 1, getCalls.size) + val params = getCalls.single() + assertEquals("reasoning", params.key) + assertEquals(sessionId, params.sessionId) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } assertEquals( "reasoning: ultra · display hide", vm.uiState.value.messages @@ -500,8 +483,8 @@ class SlashCommandDispatchRpcTest { assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0) assertTrue("dispatch must precede fallback", dispatchIndex < execIndex) val params = paramsCalls[execIndex] - assertEquals("/status", params["command"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("/status"), params["command"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) // And the slash.exec output must be surfaced to the user. val last = @@ -647,9 +630,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("init", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("init"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -672,9 +655,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("init", params["name"]) - assertEquals("extra context here", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("init"), params["name"]) + assertEquals(JsonPrimitive("extra context here"), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -700,9 +683,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("focus", params["name"]) - assertEquals("", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("focus"), params["name"]) + assertEquals(JsonPrimitive(""), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test @@ -725,9 +708,9 @@ class SlashCommandDispatchRpcTest { val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) val params = paramsCalls[dispatchIndex] - assertEquals("focus", params["name"]) - assertEquals("on", params["arg"]) - assertEquals(sessionId, params["session_id"]) + assertEquals(JsonPrimitive("focus"), params["name"]) + assertEquals(JsonPrimitive("on"), params["arg"]) + assertEquals(JsonPrimitive(sessionId), params["session_id"]) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index 272ad6ea..da0f7f8c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -20,8 +20,8 @@ class SlashCommandDispatcherTest { private val dispatcher = SlashCommandDispatcher() @Test - fun `stop routes to Interrupt`() { - assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop")) + fun `stop routes to Stop`() { + assertEquals(SlashResult.Stop, dispatcher.dispatch("/stop")) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt new file mode 100644 index 00000000..8d501cac --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/StreamingBufferTest.kt @@ -0,0 +1,56 @@ +package com.m57.hermescontrol.ui.chat + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreamingBufferTest { + @Test + fun channelsThrottleIndependentlyAndFlushCumulativeText() = + runTest { + var now = 100L + val tokens = mutableListOf() + val reasoning = mutableListOf() + val tokenBuffer = StreamingBuffer(this, 33L, { now }, tokens::add) + val reasoningBuffer = StreamingBuffer(this, 33L, { now }, reasoning::add) + + tokenBuffer.append("A", flushImmediately = false) + tokenBuffer.append("B", flushImmediately = false) + reasoningBuffer.append("R", flushImmediately = false) + assertEquals(listOf("A"), tokens) + assertEquals(listOf("R"), reasoning) + + now += 34 + testScheduler.advanceTimeBy(34L) + assertEquals(listOf("A", "AB"), tokens) + assertEquals(listOf("R"), reasoning) + } + + @Test + fun transitionFlushCancelsTrailingJobWithoutDroppingTheNextDelta() = + runTest { + val output = mutableListOf() + val buffer = StreamingBuffer(this, 33L, { 100L }, output::add) + buffer.append("A", flushImmediately = false) + buffer.append("B", flushImmediately = false) + buffer.flushPending() + testScheduler.advanceTimeBy(34L) + assertEquals(listOf("A", "AB"), output) + + buffer.append("C", flushImmediately = true) + assertEquals(listOf("A", "AB", "ABC"), output) + } + + @Test + fun clearCancelsTrailingJobAndStartsFresh() = + runTest { + val output = mutableListOf() + val buffer = StreamingBuffer(this, 33L, { 100L }, output::add) + buffer.append("Old", flushImmediately = false) + buffer.append(" tail", flushImmediately = false) + buffer.clear() + testScheduler.advanceTimeBy(34L) + buffer.append("Fresh", flushImmediately = false) + assertEquals(listOf("Old", "Fresh"), output) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt new file mode 100644 index 00000000..0f675376 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatScrollControllerTest.kt @@ -0,0 +1,248 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatScrollControllerTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun mockLazyListStateAtBottom(): LazyListState { + val listState = mockk(relaxed = true) + val layoutInfo = mockk(relaxed = true) + val itemInfo = mockk(relaxed = true) + + every { layoutInfo.totalItemsCount } returns 5 + every { layoutInfo.viewportEndOffset } returns 1000 + every { layoutInfo.afterContentPadding } returns 0 + every { itemInfo.index } returns 4 + every { itemInfo.offset } returns 800 + every { itemInfo.size } returns 100 + every { layoutInfo.visibleItemsInfo } returns listOf(itemInfo) + every { listState.layoutInfo } returns layoutInfo + + return listState + } + + @Test + fun `initial state has isFollowingBottom true and pendingCount 0`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + } + + @Test + fun `new tail while following keeps pendingCount 0 and requests scroll`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "msg-1", messageCount = 1) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `pauseFollowing followed by new messages increments pendingCount`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + controller.pauseFollowing() + assertFalse(controller.isFollowingBottom) + + controller.onTailChanged(tailKey = "msg-1", messageCount = 1) + assertEquals(1, controller.pendingCount) + + controller.onTailChanged(tailKey = "msg-2", messageCount = 3) + assertEquals(3, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + } + + @Test + fun `resumeFollowing clears pendingCount and restores isFollowingBottom`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "msg-1", messageCount = 2) + assertEquals(2, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + + controller.resumeFollowing() + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.animateScrollToItem(4, any()) } + } + + @Test + fun `jumpToBottom clears pendingCount and restores isFollowingBottom`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "msg-1", messageCount = 4) + assertEquals(4, controller.pendingCount) + assertFalse(controller.isFollowingBottom) + + controller.jumpToBottom(animated = false) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + assertEquals(0, controller.pendingCount) + coVerify(atLeast = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `tail key unchanged causes no change to pendingCount or scroll`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.pauseFollowing() + controller.onTailChanged(tailKey = "same-key", messageCount = 1) + assertEquals(1, controller.pendingCount) + + // Repeating the same tailKey should be a no-op + controller.onTailChanged(tailKey = "same-key", messageCount = 5) + assertEquals(1, controller.pendingCount) + + advanceUntilIdle() + coVerify(exactly = 0) { listState.scrollToItem(any(), any()) } + coVerify(exactly = 0) { listState.animateScrollToItem(any(), any()) } + } + + @Test + fun `layout growth does not pause follow but returning to bottom after user gesture resumes`() = + runTest(testDispatcher) { + var atBottom by mutableStateOf(true) + val listState = mockLazyListStateAtBottom() + val layoutInfo = listState.layoutInfo + val itemInfo = layoutInfo.visibleItemsInfo.single() + every { itemInfo.index } answers { if (atBottom) 4 else 3 } + val controller = ChatScrollController(listState, backgroundScope) + + controller.observeUserScrollPosition() + runCurrent() + atBottom = false // A new row laid out below the viewport, not a user scroll. + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(controller.isFollowingBottom) + + controller.onUserScrollUp() + assertFalse(controller.isFollowingBottom) + atBottom = true + Snapshot.sendApplyNotifications() + runCurrent() + assertTrue(controller.isFollowingBottom) + } + + @Test + fun `user upward gesture cancels in-flight follow and counts new messages`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) } + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "first", messageCount = 1) + runCurrent() + controller.onUserScrollUp() + controller.onTailChanged(tailKey = "second", messageCount = 2) + advanceUntilIdle() + + assertFalse(controller.isFollowingBottom) + assertEquals(1, controller.pendingCount) + coVerify(exactly = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `historical jump replaces an in-flight follow and leaves follow paused`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) } + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "tail", messageCount = 1) + runCurrent() + controller.jumpToHistoryStart() + advanceUntilIdle() + + assertFalse(controller.isFollowingBottom) + coVerify(exactly = 1) { listState.scrollToItem(0, any()) } + } + + @Test + fun `rapid tails replace older jobs rather than queueing scrolls`() = + runTest(testDispatcher) { + val listState = mockLazyListStateAtBottom() + val controller = ChatScrollController(listState, this) + + controller.onTailChanged(tailKey = "one", messageCount = 1) + controller.onTailChanged(tailKey = "two", messageCount = 2) + advanceUntilIdle() + + assertTrue(controller.isFollowingBottom) + coVerify(exactly = 1) { listState.scrollToItem(4, any()) } + } + + @Test + fun `showFab returns true only when paused and content is present`() { + val listState = mockk(relaxed = true) + val scope = TestScope(testDispatcher) + val controller = ChatScrollController(listState, scope) + + // Following bottom -> false regardless of contentPresent + assertFalse(controller.showFab(contentPresent = true)) + assertFalse(controller.showFab(contentPresent = false)) + + controller.pauseFollowing() + // Paused -> true only if contentPresent + assertTrue(controller.showFab(contentPresent = true)) + assertFalse(controller.showFab(contentPresent = false)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt new file mode 100644 index 00000000..5aef459d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TranscriptUiStateTest.kt @@ -0,0 +1,88 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.ChatTimelineState +import com.m57.hermescontrol.ui.chat.ChatUiState +import com.m57.hermescontrol.ui.chat.ClarifyUi +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class TranscriptUiStateTest { + private val live = ChatMessage(id = "live", role = MessageRole.USER, content = "hello") + private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older") + private val clarify = ClarifyUi(text = "choose") + + @Test + fun `live state preserves prompts streaming and paging actions`() { + val stream = StreamingState() + val result = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = listOf(live), + currentSessionId = "session", + isAgentTyping = true, + isLoading = true, + isLoadingOlder = true, + hasOlderMessages = true, + clarifyRequest = clarify, + isCompressing = true, + savingAttachmentPath = "server-path", + ), + timeline = ChatTimelineState(), + streaming = stream, + savingAttachmentPath = "local-path", + speakingMessageId = "live", + ) + assertEquals(listOf(live), result.messages) + assertSame(stream, result.streamingState) + assertEquals("session", result.pagingSessionId) + assertTrue(result.isAgentTyping) + assertTrue(result.isLoading) + assertTrue(result.isLoadingOlder) + assertTrue(result.hasOlderMessages) + assertSame(clarify, result.clarifyRequest) + assertTrue(result.isCompressing) + assertEquals("local-path", result.savingAttachmentPath) + assertEquals("live", result.speakingMessageId) + } + + @Test + fun `historical state isolates live tail prompts loading and compression`() { + val result = + TranscriptUiState.resolve( + chat = + ChatUiState( + messages = listOf(live), + currentSessionId = "session", + isAgentTyping = true, + isLoading = true, + isLoadingOlder = true, + hasOlderMessages = true, + clarifyRequest = clarify, + isCompressing = true, + compressionStatus = "compressing", + ), + timeline = ChatTimelineState(historyMessages = listOf(historical), historyAnchorRowId = 42), + streaming = StreamingState(streamingMessage = live), + savingAttachmentPath = null, + speakingMessageId = null, + ) + assertEquals(listOf(historical), result.messages) + assertNull(result.streamingState.streamingMessage) + assertEquals("session:history:42", result.pagingSessionId) + assertFalse(result.isAgentTyping) + assertFalse(result.isLoading) + assertFalse(result.isLoadingOlder) + assertFalse(result.hasOlderMessages) + assertNull(result.clarifyRequest) + assertFalse(result.isCompressing) + assertNull(result.compressionStatus) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt new file mode 100644 index 00000000..ad2af493 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolCatalogTest.kt @@ -0,0 +1,53 @@ +package com.m57.hermescontrol.ui.chat.tool + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Terminal +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ToolCatalogTest { + @Test + fun `icon and renderer lookups read the same catalog entry`() { + for ((name, def) in ToolCatalog.entries) { + assertSame(name, def.icon, ToolSchemaRegistry.getDisplayConfig(name).icon) + assertSame(name, def.renderer, ToolRendererRegistry.rendererFor(name)) + assertEquals(name, ToolSchemaRegistry.getDisplayConfig(name).name) + } + } + + @Test + fun `tools that drifted now have a real icon`() { + assertSame(Icons.Filled.Edit, ToolSchemaRegistry.getDisplayConfig("edit_file").icon) + assertSame(Icons.Filled.Keyboard, ToolSchemaRegistry.getDisplayConfig("browser_fill").icon) + for (name in listOf("project_list", "project_create", "project_switch")) { + assertSame(name, Icons.Filled.AccountTree, ToolSchemaRegistry.getDisplayConfig(name).icon) + } + } + + @Test + fun `known tools keep their renderers and icons`() { + assertSame(TerminalRenderer, ToolRendererRegistry.rendererFor("terminal")) + assertSame(Icons.Filled.Terminal, ToolSchemaRegistry.getDisplayConfig("terminal").icon) + assertSame(FileEditRenderer, ToolRendererRegistry.rendererFor("edit_file")) + assertSame(BrowserTypeRenderer, ToolRendererRegistry.rendererFor("browser_fill")) + // Icon-only tools still render generically. + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("clarify")) + } + + @Test + fun `unknown tools fall back to build icon and generic renderer`() { + assertSame(Icons.Filled.Build, ToolSchemaRegistry.getDisplayConfig("mystery").icon) + assertEquals("mystery", ToolSchemaRegistry.getDisplayConfig("mystery").name) + assertEquals("tool", ToolSchemaRegistry.getDisplayConfig(null).name) + assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("mystery")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt new file mode 100644 index 00000000..14163bc1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCacheTest.kt @@ -0,0 +1,51 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.parseToolOutput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +class ToolViewCacheTest { + private val payload = """{"name":"terminal","args":{"command":"ls"},"result":{"output":"a","exit_code":0}}""" + + @Before + fun reset() = ToolViewCache.clear() + + @Test + fun `cached view equals a fresh parse`() { + val cached = ToolViewCache.getOrParse(payload, "terminal", false) + assertEquals(parseToolOutput(payload, "terminal", false), cached) + } + + @Test + fun `prewarm makes composition read a hit`() { + ToolViewCache.prewarm(payload, "terminal", false) + val first = ToolViewCache.getOrParse(payload, "terminal", false) + assertSame(first, ToolViewCache.getOrParse(payload, "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `running state and content changes are separate entries`() { + val running = ToolViewCache.getOrParse(payload, "terminal", true) + val done = ToolViewCache.getOrParse(payload, "terminal", false) + assertNotSame(running, done) + assertEquals(2, ToolViewCache.size()) + } + + @Test + fun `non json content is cached as null`() { + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertNull(ToolViewCache.getOrParse("plain text", "terminal", false)) + assertEquals(1, ToolViewCache.size()) + } + + @Test + fun `cache is bounded`() { + repeat(600) { i -> ToolViewCache.getOrParse("""{"i":$i}""", "terminal", false) } + assertEquals(512, ToolViewCache.size()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt index e6ff4c9e..530bb7d1 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connectors/AccountConnectorsViewModelTest.kt @@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.ConnectorPolicy import com.m57.hermescontrol.data.ws.AccountConnectorRepository import com.m57.hermescontrol.data.ws.ConnectionOperationParser import com.m57.hermescontrol.data.ws.WsEvent -import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer +import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams +import com.m57.hermescontrol.data.ws.contract.ConnectorOwner import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest { runTest(dispatcher) { advanceUntilIdle() coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot()) - coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok") + coEvery { repo.operationRespond(any()) } returns mapOf("status" to "ok") coEvery { repo.operationStatus("op-a") } returns AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true)) vm.connect("drive") @@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest { vm.continueOperation() advanceUntilIdle() coVerify { - repo.operationRequest( - WsMethods.CONNECTION_RESPOND, - mapOf( - "owner" to mapOf("type" to "account"), - "op_id" to "op-a", - "result" to mapOf("settled_by" to "continue"), + repo.operationRespond( + ConnectionRespondParams( + owner = ConnectorOwner.account(), + opId = "op-a", + result = ConnectionAnswer(settledBy = "continue"), ), ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index de218f32..050af039 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.kanban +import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.model.BulkTaskResult import com.m57.hermescontrol.data.model.BulkTasksResponse @@ -25,6 +26,7 @@ import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -43,13 +45,19 @@ class KanbanMutationTest { private val mockEventsClient = mockk(relaxed = true) private val preferences = InMemoryKanbanPreferencesStore() + private val createdViewModels = mutableListOf() + private fun createViewModel(): KanbanViewModel = KanbanViewModel( repository = mockRepository, preferences = preferences, eventsClientProvider = { mockEventsClient }, endpointProvider = { "http://127.0.0.1:9119" }, - ) + ).also { + // Keep all VM work on the test scheduler; real Dispatchers.IO races resetMain(). + it.ioDispatcher = testDispatcher + createdViewModels += it + } @Before fun setUp() { @@ -58,6 +66,9 @@ class KanbanMutationTest { @After fun tearDown() { + testDispatcher.scheduler.advanceUntilIdle() + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() Dispatchers.resetMain() } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt index 81c7c7a1..383898b8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpOwnershipTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.mcp import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerTestResponse +import com.m57.hermescontrol.data.model.McpServerUpdateRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService @@ -122,10 +123,10 @@ class McpOwnershipTest { vm.updateEnvValue("value") vm.addEnvVar(config.name) dispatcher.scheduler.advanceUntilIdle() - coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) } + coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = mapOf("KEY" to "value"))) } vm.removeEnvVar(config.name, "KEY") dispatcher.scheduler.advanceUntilIdle() - coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap())) } + coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = emptyMap())) } coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit) vm.deleteServer(config.name) dispatcher.scheduler.advanceUntilIdle() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt index 19455158..fbd39b22 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/process/ProcessesViewModelTest.kt @@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process import com.m57.hermescontrol.data.model.ProcessInfo import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.data.ws.WsMethods -import io.mockk.every +import com.m57.hermescontrol.data.ws.contract.ProcessKillParams +import com.m57.hermescontrol.data.ws.contract.RpcMethods +import com.m57.hermescontrol.data.ws.contract.SessionIdParams +import io.mockk.coEvery import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -42,34 +48,40 @@ class ProcessesViewModelTest { @Test fun `process list parses running and exited entries`() { - val raw = - mapOf( - "processes" to - listOf( - mapOf( - "session_id" to "p1", - "command" to "python train.py --epochs 10", - "pid" to 1234, - "status" to "running", - "uptime_seconds" to 95, - "cwd" to "/work", - ), - mapOf( - "session_id" to "p2", - "command" to "echo done", - "status" to "exited", - "exit_code" to 0, - ), - ), - ) + val raw: JsonElement = + buildJsonObject { + put( + "processes", + buildJsonArray { + add( + buildJsonObject { + put("session_id", "p1") + put("command", "python train.py --epochs 10") + put("pid", 1234) + put("status", "running") + put("uptime_seconds", 95) + put("cwd", "/work") + }, + ) + add( + buildJsonObject { + put("session_id", "p2") + put("command", "echo done") + put("status", "exited") + put("exit_code", 0) + }, + ) + }, + ) + } - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } returns CompletableDeferred(raw) + val listParams = slot() + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw val vm = ProcessesViewModel() vm.load() + assertEquals(SessionIdParams("sess-1"), listParams.captured) val procs = vm.uiState.value.processes assertEquals(2, procs.size) @@ -90,26 +102,20 @@ class ProcessesViewModelTest { @Test fun `kill issues process kill with session-scoped params then refreshes`() { var listCalls = 0 - val killParamsSlot = slot>() + val killParamsSlot = slot() - every { - HermesWsClient.request(WsMethods.PROCESS_LIST, any()) - } answers { + coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers { listCalls++ - CompletableDeferred(mapOf("processes" to emptyList())) + buildJsonObject { put("processes", JsonArray(emptyList())) } } - every { - HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot)) - } answers { - CompletableDeferred(mapOf("killed" to true)) + coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers { + buildJsonObject { put("killed", true) } } val vm = ProcessesViewModel() vm.kill("p1") - val killParams = killParamsSlot.captured - assertEquals("p1", killParams["process_id"]) - assertEquals("sess-1", killParams["session_id"]) + assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured) // list is called once on init (session emitted) + once after kill refresh assertTrue(listCalls >= 2) assertNull(vm.uiState.value.killingId) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt new file mode 100644 index 00000000..333a3c36 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionSourceFilterTest.kt @@ -0,0 +1,75 @@ +package com.m57.hermescontrol.ui.sessions + +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionSearchResult +import com.m57.hermescontrol.ui.sessions.components.sourceLabel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionSourceFilterTest { + private val rows = + listOf( + SessionInfo("a", source = "cli"), + SessionInfo("b", source = "CLI"), + SessionInfo("c", source = "desktop"), + SessionInfo("d", source = "api_server"), + SessionInfo("e", source = "desktop", hidden = true), + SessionInfo("f", source = null), + ) + + @Test + fun availableSources_onlyLoadedVisibleSources_orderedByCountThenName() { + val state = SessionsUiState(sessions = rows) + assertEquals(listOf("cli", "api_server", "desktop"), state.availableSources) + } + + @Test + fun availableSources_includesHiddenOnlyWhenShown() { + val onlyHidden = listOf(SessionInfo("x", source = "tui", hidden = true), SessionInfo("y", source = "cli")) + assertEquals(listOf("cli"), SessionsUiState(sessions = onlyHidden).availableSources) + assertEquals( + listOf("cli", "tui"), + SessionsUiState(sessions = onlyHidden, showHidden = true).availableSources, + ) + } + + @Test + fun filter_isCaseInsensitiveAndNarrowsList() { + val state = SessionsUiState(sessions = rows, sourceFilter = "cli") + assertEquals(listOf("a", "b"), state.displaySessions.map { it.id }) + } + + @Test + fun staleFilter_fallsBackToAll() { + val state = SessionsUiState(sessions = rows, sourceFilter = "telegram") + assertNull(state.activeSourceFilter) + assertEquals(5, state.displaySessions.size) + } + + @Test + fun searchMode_usesSearchResultSources() { + val state = + SessionsUiState( + searchQuery = "q", + sessions = rows, + searchResults = + listOf( + SessionSearchResult("s1", source = "telegram"), + SessionSearchResult("s2", source = "cli"), + ), + sourceFilter = "telegram", + ) + assertEquals(listOf("cli", "telegram"), state.availableSources) + assertEquals(listOf("s1"), displayedSessions(state).map { it.session.id }) + } + + @Test + fun sourceLabel_mapsKnownAndTitleCasesUnknown() { + assertEquals("API", sourceLabel("api_server")) + assertEquals("Desktop", sourceLabel("desktop")) + assertEquals("TUI", sourceLabel("tui")) + assertEquals("Whatsapp cloud", sourceLabel("whatsapp_cloud")) + assertEquals("Unknown", sourceLabel(null)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 2d678c93..c35f6770 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 @@ -93,6 +93,8 @@ class SettingsViewModelTest { every { AuthManager.isTokensPerSecondEnabled() } returns true every { AuthManager.isModelProviderShown() } returns false every { AuthManager.isKeepConnectedInBackground() } returns false + every { AuthManager.isNotifySessionCompletions() } returns false + every { AuthManager.setNotifySessionCompletions(any()) } returns Unit every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" diff --git a/app/src/test/resources/gateway-contract/SOURCE b/app/src/test/resources/gateway-contract/SOURCE new file mode 100644 index 00000000..4b0fb58e --- /dev/null +++ b/app/src/test/resources/gateway-contract/SOURCE @@ -0,0 +1,5 @@ +repo: NousResearch/hermes-agent +ref: main +sha: ea114c3e98c3339e13004adfc6098cf28ed7d754 +date: 2026-09-29 +path: apps/shared/src/gateway-contract.openrpc.json diff --git a/app/src/test/resources/gateway-contract/openrpc.json b/app/src/test/resources/gateway-contract/openrpc.json new file mode 100644 index 00000000..7a63156f --- /dev/null +++ b/app/src/test/resources/gateway-contract/openrpc.json @@ -0,0 +1,40113 @@ +{ + "openrpc": "1.3.2", + "info": { + "title": "Hermes TUI/Desktop gateway", + "version": "1", + "description": "Generated from tui_gateway/contracts by scripts/gen_gateway_contracts.py." + }, + "methods": [ + { + "name": "agents.list", + "summary": "Registry-wide background process summary for ``/agents``.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/AgentsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AgentsListResult" + } + } + }, + { + "name": "approval.pending", + "summary": "Replay the approvals still waiting on this session (reconnect / polling).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalPendingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalPendingResult" + } + } + }, + { + "name": "approval.received", + "summary": "Tell the backend the card is on screen, so its timeout clock starts.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalReceivedParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalReceivedResult" + } + } + }, + { + "name": "approval.respond", + "summary": "Deliver the user's decision on a dangerous command (falls back to durable identity on a stale sid).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalRespondParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalRespondResult" + } + } + }, + { + "name": "billing.auto_reload", + "summary": "Enable/disable auto top-up with its threshold and reload amount (billing:manage).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingAutoReloadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingMutationResult" + } + } + }, + { + "name": "billing.charge", + "summary": "Start a one-off top-up charge (billing:manage, idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingChargeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingChargeResult" + } + } + }, + { + "name": "billing.charge_status", + "summary": "Poll one charge by id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingChargeStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingChargeStatusResult" + } + } + }, + { + "name": "billing.state", + "summary": "Read-only billing view (no scope); the Nous free tier is answered locally without a portal call.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingStateResult" + } + } + }, + { + "name": "billing.step_up", + "summary": "Run the billing:manage device flow; the URL/code arrive via billing.step_up.verification.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BillingStepUpParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingStepUpResult" + } + } + }, + { + "name": "bot_relay.deliver", + "summary": "Deliver a relayed DM into a Bot Chat on this gateway and return the one-turn reply (blocking).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayDeliverParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayDeliverResult" + } + } + }, + { + "name": "bot_relay.outbox.drain", + "summary": "Atomically claim every pending cross-connection envelope queued on this gateway.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayOutboxDrainParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayOutboxDrainResult" + } + } + }, + { + "name": "bot_relay.reply", + "summary": "Write a relayed reply and/or typed error for an envelope so the sender-side waiter resolves.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayReplyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "bot_relay.roster.sync", + "summary": "Replace this gateway's view of agents on other connections; answers the accepted row count.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BotRelayRosterSyncParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BotRelayRosterSyncResult" + } + } + }, + { + "name": "browser.controller.detach", + "summary": "Hard-detach only the controller owned by this authenticated transport.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerDetachResult" + } + } + }, + { + "name": "browser.controller.heartbeat", + "summary": "Acknowledge a heartbeat only for this transport's own attached controller.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "browser.controller.register", + "summary": "Attach this connection as the browser controller for one session; fails closed (4403).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerRegisterParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerRegisterResult" + } + } + }, + { + "name": "browser.controller.result", + "summary": "Deliver one command result to the broker; accepted is false for unknown or settled command ids.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserControllerResultParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserControllerResultResult" + } + } + }, + { + "name": "browser.manage", + "summary": "Inspect, attach to, or drop the CDP browser the tools use; ``messages`` narrate a connect.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/BrowserManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BrowserManageResult" + } + } + }, + { + "name": "clarify.lock", + "summary": "Lock one answer of a batch clarify request (editable until every question is locked).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClarifyLockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClarifyLockResult" + } + } + }, + { + "name": "cli.exec", + "summary": "Run ``hermes `` non-interactively and capture its output; ``blocked`` explains a refusal.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CliExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CliExecResult" + } + } + }, + { + "name": "client.capabilities", + "summary": "What the calling client handles, sent once per connection (after gateway.ready); returns the server\u2192client request methods this backend may send.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClientCapabilitiesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClientCapabilitiesResult" + } + } + }, + { + "name": "clipboard.paste", + "summary": "Save the host clipboard image into the session and queue it for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClipboardPasteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "command.dispatch", + "summary": "Run a quick/plugin/bundle/skill/built-in slash command and answer a structured directive.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandDispatchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandDispatchResult" + } + } + }, + { + "name": "command.resolve", + "summary": "Canonical registry command for a name or alias.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandResolveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandResolveResult" + } + } + }, + { + "name": "commands.catalog", + "summary": "Categorized slash metadata (registry, quick, plugin, skill) for completion menus.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CommandsCatalogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CommandsCatalogResult" + } + } + }, + { + "name": "complete.path", + "summary": "Path / @-reference completions for the composer (files, folders, profiles, plugin providers).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CompletePathParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CompletionItemsResult" + } + } + }, + { + "name": "complete.slash", + "summary": "Ranked slash-command / skill completions for a ``/`` token.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CompleteSlashParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CompleteSlashResult" + } + } + }, + { + "name": "config.get", + "summary": "Read one normalised config value (or the whole effective config) the way the UIs render it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigGetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigGetResult" + } + } + }, + { + "name": "config.set", + "summary": "Change one config key (persisted or session-scoped) and read back the normalised value.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigSetResult" + } + } + }, + { + "name": "config.show", + "summary": "Masked, display-ready config summary (model / agent / environment rows).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConfigShowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConfigShowResult" + } + } + }, + { + "name": "connection.respond", + "summary": "Per-target outcomes from the card, and an optional Continue.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionRespondParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionRespondResult" + } + } + }, + { + "name": "connectors.accounts", + "summary": "The scoped member's hosted connector accounts, optionally filtered by connector slug.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsResult" + } + } + }, + { + "name": "connectors.accounts.remove", + "summary": "Remove one hosted connector account owned by the scoped member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsRemoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorAccountsRemoveResult" + } + } + }, + { + "name": "connectors.catalog", + "summary": "The hosted connector catalog available to the scoped member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsCatalogResult" + } + } + }, + { + "name": "connectors.connect", + "summary": "Start or re-initiate authorization for named connectors on a session or account operation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorsConnectParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsConnectResult" + } + } + }, + { + "name": "connectors.list", + "summary": "Connector catalog + connection state for one session or profile account owner.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorsListResult" + } + } + }, + { + "name": "connectors.operation.status", + "summary": "The current snapshot of one open session or account operation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationStatus" + } + } + }, + { + "name": "connectors.operation.wake", + "summary": "The browser leg came back (hermes://connections/done): read the accounts now, not at the next tick.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectionOperationParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectionWakeResult" + } + } + }, + { + "name": "connectors.policy.get", + "summary": "Policy layers for the scoped member, from organization to member scope.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicyGetResult" + } + } + }, + { + "name": "connectors.policy.set", + "summary": "Apply one scoped member connector or tool-list policy change.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicySetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorPolicySetResult" + } + } + }, + { + "name": "connectors.tools", + "summary": "The scoped profile's cached or current tool list for one connector.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ConnectorToolsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ConnectorToolsResult" + } + } + }, + { + "name": "cron.manage", + "summary": "List/add/remove/pause/resume cron jobs in the (optionally profile-scoped) cron store.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/CronManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/CronManageResult" + } + } + }, + { + "name": "delegation.pause", + "summary": "Block/unblock NEW spawns globally (active children keep running); returns the new state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DelegationPauseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DelegationPauseResult" + } + } + }, + { + "name": "delegation.status", + "summary": "Running subagent tree plus the spawn pause flag and limits.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DelegationStatusResult" + } + } + }, + { + "name": "diagnostics.share_nous", + "summary": "Upload a force-redacted debug bundle to Nous-internal diagnostics storage.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DiagnosticsShareNousParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DiagnosticsShareNousResult" + } + } + }, + { + "name": "display.install", + "summary": "Run the distro package install on the gateway host; progress streams as display.install.log/.done.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayInstallResult" + } + } + }, + { + "name": "display.lease.acquire", + "summary": "Take over: the human named by a viewer id this connection minted controls the screen.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseAcquireParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseResult" + } + } + }, + { + "name": "display.lease.release", + "summary": "Hand back. Without a viewer id the release is refused while a human holds unless force.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseReleaseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayLeaseResult" + } + } + }, + { + "name": "display.observe", + "summary": "Mint a single-use ticket for /api/display/ws and the server-minted viewer id for this connection.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayObserveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayObserveResult" + } + } + }, + { + "name": "display.start", + "summary": "Start this profile's Xvnc + Xfce (idempotent); blocks until the display is published.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStatus" + } + } + }, + { + "name": "display.status", + "summary": "Runtime + lease snapshot for this profile's screen.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStatus" + } + } + }, + { + "name": "display.stop", + "summary": "Stop the screen. Refused (5300, code viewer_mismatch) while a human holds unless force.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayStopResult" + } + } + }, + { + "name": "display.switchSandboxImage", + "summary": "Decide the pending default sandbox image switch for this profile; refused when none is pending.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplaySwitchSandboxImageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplaySwitchSandboxImageResult" + } + } + }, + { + "name": "display.thumbnail", + "summary": "One JPEG grab of the bot's screen; read-only, never changes the lease.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DisplayThumbnailResult" + } + } + }, + { + "name": "file.attach", + "summary": "Stage a non-image file into the session workspace and hand back its @file: ref.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/FileAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FileAttachResult" + } + } + }, + { + "name": "free_tier.ack_notice", + "summary": "Mark the one-time availability notice as shown on the free-tier identity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierAckNoticeResult" + } + } + }, + { + "name": "free_tier.provision", + "summary": "Explicit retry of the free-tier identity mint when the boot bootstrap could not create it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierProvisionResult" + } + } + }, + { + "name": "free_tier.status", + "summary": "Pure read of the focused profile's free-tier identity state (no network, no side effects).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/FreeTierStatusResult" + } + } + }, + { + "name": "gateway.capabilities", + "summary": "What THIS build enforces (a client withholds a feature unless advertised).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GatewayCapabilitiesResult" + } + } + }, + { + "name": "groups.approve", + "summary": "Resolve one exact pending approval raised by a local or peer room member.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsApproveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsApproveResult" + } + } + }, + { + "name": "groups.capabilities", + "summary": "Describe the hosted-room protocol implemented by this gateway.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsCapabilitiesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsCapabilitiesResult" + } + } + }, + { + "name": "groups.create", + "summary": "Create a hosted room idempotently; authority is this gateway's stable install identity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsCreateResult" + } + } + }, + { + "name": "groups.demote", + "summary": "Fence this gateway's stale room authority against a proven newer epoch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsDemoteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsDemoteResult" + } + } + }, + { + "name": "groups.disband", + "summary": "Permanently tombstone a hosted room id after stopping its work and revoking peer routes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsDisbandParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsDisbandResult" + } + } + }, + { + "name": "groups.list", + "summary": "List rooms hosted by this gateway, most recently changed first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsListResult" + } + } + }, + { + "name": "groups.log", + "summary": "A monotonic room-log delta after since_seq, bounded by count and page bytes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsLogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsLogResult" + } + } + }, + { + "name": "groups.peer.invite", + "summary": "Mint one target-issued room/profile grant for a prospective room home.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerInviteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerInviteResult" + } + } + }, + { + "name": "groups.peer.register", + "summary": "Register and probe one scoped peer route on the room home.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRegisterParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRegisterResult" + } + } + }, + { + "name": "groups.peer.revoke", + "summary": "Revoke one target-issued grant using its exact profile scope.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRevokeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPeerRevokeResult" + } + } + }, + { + "name": "groups.promote", + "summary": "Continue a replicated room on this gateway at epoch + 1; requires confirm=true.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsPromoteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsPromoteResult" + } + } + }, + { + "name": "groups.rename", + "summary": "Rename one hosted room atomically with its replay event.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsRenameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsRenameResult" + } + } + }, + { + "name": "groups.replica_state", + "summary": "The local replica's coverage and authority lineage for one room.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsReplicaStateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsReplicaStateResult" + } + } + }, + { + "name": "groups.replicate", + "summary": "Persist one authority-stamped replay page into the local replica store; idempotent.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsReplicateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsReplicateResult" + } + } + }, + { + "name": "groups.retry", + "summary": "Retry one indeterminate room task after explicit user confirmation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsRetryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsRetryResult" + } + } + }, + { + "name": "groups.send", + "summary": "Append one inert message.user event idempotently; the actor is server-owned.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsSendParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsSendResult" + } + } + }, + { + "name": "groups.state", + "summary": "One hosted room's replay cursor and fenced authority state, plus live driver status.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsStateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsStateResult" + } + } + }, + { + "name": "groups.stop", + "summary": "Durably cancel queued or running work for one hosted room.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/GroupsStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/GroupsStopResult" + } + } + }, + { + "name": "handoff.fail", + "summary": "Fail a not-yet-claimed handoff (client poll timeout); CAS against the watcher.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/HandoffFailParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffFailResult" + } + } + }, + { + "name": "handoff.request", + "summary": "Queue a handoff to a messaging platform's home channel; the gateway watcher claims it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/HandoffRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffRequestResult" + } + } + }, + { + "name": "handoff.state", + "summary": "Poll the handoff row for this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/HandoffStateResult" + } + } + }, + { + "name": "i18n.catalog", + "summary": "Pack + overlay messages for one language and surface; the renderer merges them over its bundled catalog.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/I18nCatalogParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/I18nCatalogResult" + } + } + }, + { + "name": "i18n.languages", + "summary": "Every language some layer supplies (bundled \u222a user overlay \u222a plugin packs), en first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/I18nLanguagesResult" + } + } + }, + { + "name": "image.attach", + "summary": "Queue a gateway-visible image file for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "image.attach_bytes", + "summary": "Queue an image uploaded as base64 (remote client); reply mirrors image.attach.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageAttachBytesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AttachedImageResult" + } + } + }, + { + "name": "image.detach", + "summary": "Drop a queued image before the turn is sent.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageDetachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ImageDetachResult" + } + } + }, + { + "name": "image.generate", + "summary": "Generate an image through the tool's provider dispatcher and hand the renderer a data URL.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ImageGenerateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ImageGenerateResult" + } + } + }, + { + "name": "input.detect_drop", + "summary": "Recognise a terminal file drop pasted into the composer and turn it into an attachment.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/InputDetectDropParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InputDetectDropResult" + } + } + }, + { + "name": "insights.get", + "summary": "Session/message counts over the last ``days`` for the (optionally scoped) profile store.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/InsightsGetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/InsightsGetResult" + } + } + }, + { + "name": "learning.delete", + "summary": "Archive a skill (restorable via curator) or remove a memory chunk.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningNodeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningMutationResult" + } + } + }, + { + "name": "learning.detail", + "summary": "Node content (SKILL.md or memory chunk) for an edit prefill.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningNodeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningDetailResult" + } + } + }, + { + "name": "learning.edit", + "summary": "Rewrite a node's content (SKILL.md or memory chunk).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningEditParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningMutationResult" + } + } + }, + { + "name": "learning.frames", + "summary": "Pre-render the /journey timeline (frames + legend/summary) so the TUI walks it locally.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LearningFramesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LearningFramesResult" + } + } + }, + { + "name": "llm.oneshot", + "summary": "Stateless one-shot LLM completion (titles, ideas) on the session's or the task backend.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/LlmOneshotParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/LlmOneshotResult" + } + } + }, + { + "name": "mcp.catalog", + "summary": "Curated MCP presets with per-profile installed/enabled state and the env keys each needs.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpCatalogResult" + } + } + }, + { + "name": "mcp.servers.add", + "summary": "Add a server to the profile's config from a catalog preset and/or an explicit config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServersAddParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersAddResult" + } + } + }, + { + "name": "mcp.servers.list", + "summary": "Configured MCP servers for the (scoped) profile, secrets redacted to env-key names.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersListResult" + } + } + }, + { + "name": "mcp.servers.oauth.callback", + "summary": "Relay a client-captured redirect into a client_redirect_uri flow.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthCallbackParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthCallbackResult" + } + } + }, + { + "name": "mcp.servers.oauth.cancel", + "summary": "Cancel a flow owned by the resolved profile, waking its callback worker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthFlowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthCancelResult" + } + } + }, + { + "name": "mcp.servers.oauth.poll", + "summary": "Poll a flow; approved persists tokens for the profile and returns the probed tools.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthFlowParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthPollResult" + } + } + }, + { + "name": "mcp.servers.oauth.start", + "summary": "Begin a PKCE OAuth flow; the client opens auth_url and polls mcp.servers.oauth.poll.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpOauthStartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpOauthStartResult" + } + } + }, + { + "name": "mcp.servers.remove", + "summary": "Drop a server from the profile's config.yaml.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServerNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersRemoveResult" + } + } + }, + { + "name": "mcp.servers.set_api_key", + "summary": "Store a credential in the profile's .env and reference it from the server config (header or env).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServersSetApiKeyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersSetApiKeyResult" + } + } + }, + { + "name": "mcp.servers.status", + "summary": "Cached runtime state per configured server; never connects, probes, or starts auth.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersStatusResult" + } + } + }, + { + "name": "mcp.servers.test", + "summary": "Connect, list tools, disconnect \u2014 an OAuth server with no token on disk is reported as not ok.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/McpServerNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/McpServersTestResult" + } + } + }, + { + "name": "message.react", + "summary": "Set/clear one author's emoji reaction on a message; returns the row's full reaction list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/MessageReactParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/MessageReactResult" + } + } + }, + { + "name": "model.disconnect", + "summary": "Remove every credential (env keys and OAuth state) for a provider.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelDisconnectParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelDisconnectResult" + } + } + }, + { + "name": "model.options", + "summary": "Provider/model inventory for the picker, layered over the session's live provider when given.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelOptionsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelOptionsResult" + } + } + }, + { + "name": "model.save_key", + "summary": "Save an API key for a provider and return its refreshed inventory row.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ModelSaveKeyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ModelSaveKeyResult" + } + } + }, + { + "name": "onboarding.ensure_setup_profile", + "summary": "Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingEnsureSetupProfileResult" + } + } + }, + { + "name": "onboarding.reset_setup_profile", + "summary": "Restore the setup profile to its created state in place (soul, memories, skills, sessions).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingResetSetupProfileResult" + } + } + }, + { + "name": "paste.collapse", + "summary": "Spill a large paste to a file and hand back the inline placeholder.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PasteCollapseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PasteCollapseResult" + } + } + }, + { + "name": "pdf.attach", + "summary": "Render a PDF's pages to PNG and queue them as images for the next turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PdfAttachParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PdfAttachResult" + } + } + }, + { + "name": "pet.cancel", + "summary": "Stop an in-flight pet generate/hatch by token (idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetCancelParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetCancelResult" + } + } + }, + { + "name": "pet.cells", + "summary": "Half-block cell frames (or a kitty placement) for one pet state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetCellsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetCellsResult" + } + } + }, + { + "name": "pet.disable", + "summary": "Turn the pet display off from the desktop picker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "pet.export", + "summary": "Export an installed pet as a re-importable .zip.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetExportResult" + } + } + }, + { + "name": "pet.gallery", + "summary": "Petdex gallery + local install state (installed-only offline); localOnly skips the remote manifest.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetGalleryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGalleryResult" + } + } + }, + { + "name": "pet.generate", + "summary": "Candidate base looks for a new pet (draft step); drafts also stream via pet.generate.progress.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetGenerateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGenerateResult" + } + } + }, + { + "name": "pet.generate.status", + "summary": "Whether pet generation is possible (a reference-capable image backend) and which providers.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetGenerateStatusResult" + } + } + }, + { + "name": "pet.hatch", + "summary": "Turn a base draft into a full spritesheet pet; progress streams via pet.hatch.progress.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetHatchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetHatchResult" + } + } + }, + { + "name": "pet.info", + "summary": "Active pet for sprite renderers: spritesheet (base64) + frame geometry + state-row taxonomy.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetInfoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetInfoResult" + } + } + }, + { + "name": "pet.info.meta", + "summary": "Cheap active-pet metadata used to avoid full payload refreshes.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetInfoMetaResult" + } + } + }, + { + "name": "pet.remove", + "summary": "Uninstall a pet (delete its directory); if it was active, turn the display off.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.rename", + "summary": "Rename a pet's display name + realign its slug/dir; follows the active slug in config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetRenameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.scale", + "summary": "Persist display.pet.scale (clamped to engine bounds) from the desktop slider.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetScaleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetScaleResult" + } + } + }, + { + "name": "pet.select", + "summary": "Adopt a pet: install (if needed) + activate; writes display.pet.* to config.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetSlugParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetSlugResult" + } + } + }, + { + "name": "pet.thumb", + "summary": "Idle-frame PNG data URI for the picker (desktop CSP breaks CDN ).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PetThumbParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PetThumbResult" + } + } + }, + { + "name": "ping", + "summary": "Cheapest liveness probe; answered on the WS reader thread even while every agent is mid-turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PingResult" + } + } + }, + { + "name": "plugins.list", + "summary": "Loaded plugin manager entries (legacy flat view); the Plugins Hub uses plugins.manage list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PluginsListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PluginsListResult" + } + } + }, + { + "name": "plugins.manage", + "summary": "Plugins Hub backend: list installed plugins, toggle, git-install, re-pin a catalog install, or remove a user install.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PluginsManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PluginsManageResult" + } + } + }, + { + "name": "preview.restart", + "summary": "Spawn a hidden agent that brings the desktop preview's dev server back up.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PreviewRestartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "process.kill", + "summary": "Kill one background process the caller's session owns and return its output snapshot.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessKillParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessKillResult" + } + } + }, + { + "name": "process.list", + "summary": "Background processes owned by the caller's session (desktop status stack poll).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessListResult" + } + } + }, + { + "name": "process.stop", + "summary": "Kill every background process in the registry (``/stop``), answering the count killed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProcessStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProcessStopResult" + } + } + }, + { + "name": "profiles.configure", + "summary": "Editor Save: apply any subset of a profile's sections and report each one.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesConfigureParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesConfigureResult" + } + } + }, + { + "name": "profiles.create", + "summary": "Create a profile (ws twin of POST /api/profiles), mirroring launch credentials by default.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesCreateResult" + } + } + }, + { + "name": "profiles.describe", + "summary": "Everything the profile editor shows: soul, model pin, skills, toolsets, MCP servers.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileNameParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesDescribeResult" + } + } + }, + { + "name": "profiles.get_asset", + "summary": "A profile asset as a data URL.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesGetAssetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesGetAssetResult" + } + } + }, + { + "name": "profiles.list", + "summary": "Roster of profiles with previews so a client paints without N follow-up calls.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesListResult" + } + } + }, + { + "name": "profiles.remember_onboarding", + "summary": "Write the onboarding facts into the default profile's user memory and confirm they landed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesRememberOnboardingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesRememberOnboardingResult" + } + } + }, + { + "name": "profiles.set_asset", + "summary": "Store or clear a profile asset (avatar) atomically.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfilesSetAssetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProfilesSetAssetResult" + } + } + }, + { + "name": "project.facts", + "summary": "Structured project facts for a cwd so UIs don't re-sniff the workspace.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFactsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectFactsResult" + } + } + }, + { + "name": "projects.add_folder", + "summary": "Attach a folder to a project (optionally as its primary path).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsAddFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.archive", + "summary": "Archive (or with ``restore`` un-archive) a project; answers the full listing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsArchiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.create", + "summary": "Create a project from a name + folders; duplicate primary paths are refused (5063).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OptionalProjectResult" + } + } + }, + { + "name": "projects.delete", + "summary": "Delete a project and its folders; answers the full listing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.discover_repos", + "summary": "Repos for the desktop overview: scanned-from-disk (cached) \u222a session-derived.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsDiscoverReposParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsDiscoverReposResult" + } + } + }, + { + "name": "projects.for_cwd", + "summary": "Which project (if any) owns a directory, plus the resolved cwd and its git branch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsForCwdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsForCwdResult" + } + } + }, + { + "name": "projects.get", + "summary": "One stored project with its folders.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.list", + "summary": "Every project of the profile (archived included) plus which one is active.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsPayload" + } + } + }, + { + "name": "projects.project_sessions", + "summary": "Fully hydrated lanes for one project, from the same grouping as projects.tree.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsProjectSessionsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsProjectSessionsResult" + } + } + }, + { + "name": "projects.record_repos", + "summary": "Persist repo roots found by the client's (desktop-side) scan; return the merged list.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsRecordReposParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsRecordReposResult" + } + } + }, + { + "name": "projects.remove_folder", + "summary": "Detach a folder from a project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.set_active", + "summary": "Switch (or clear) the active project for the profile.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsSetActiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ActiveIdResult" + } + } + }, + { + "name": "projects.set_primary", + "summary": "Make one attached folder the project's primary path.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectFolderParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "projects.tree", + "summary": "Project \u2192 repo \u2192 lane overview with counts and a few preview sessions per project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsTreeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectsTreeResult" + } + } + }, + { + "name": "projects.update", + "summary": "Patch a project's display fields; answers the refreshed project.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProjectsUpdateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ProjectResult" + } + } + }, + { + "name": "prompt.background", + "summary": "Run a task on a fresh agent in the background; the answer arrives as background.complete.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SideAgentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "prompt.btw", + "summary": "Side question over a snapshot of the live conversation; the answer arrives as btw.complete.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SideAgentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TaskIdResult" + } + } + }, + { + "name": "prompt.submit", + "summary": "Send a user turn to a live session; busy sessions queue / steer / redirect instead of refusing.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PromptSubmitParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PromptSubmitResult" + } + } + }, + { + "name": "reload.env", + "summary": "Re-read ~/.hermes/.env (CLI /reload parity); built agents keep their pool until /new.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReloadEnvParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReloadEnvResult" + } + } + }, + { + "name": "reload.mcp", + "summary": "Tear down and rediscover MCP servers for every live session (prompt cache is invalidated).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReloadMcpParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ReloadMcpResult" + } + } + }, + { + "name": "request.answer", + "summary": "Answer an open server\u2192client request from a client that never received the frame.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RequestAnswerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RequestAnswerResult" + } + } + }, + { + "name": "rollback.diff", + "summary": "Diff between a checkpoint and the working tree, with an ANSI rendering sized to the TUI.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackDiffParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackDiffResult" + } + } + }, + { + "name": "rollback.list", + "summary": "Checkpoints for the session's cwd; ``enabled: false`` when checkpointing is off.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackListResult" + } + } + }, + { + "name": "rollback.restore", + "summary": "Restore the working tree (or one file) to a checkpoint by hash or 1-based index.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/RollbackRestoreParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/RollbackRestoreResult" + } + } + }, + { + "name": "session.activate", + "summary": "Attach the frontend to a live session without closing the previously focused one.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionActivateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionActivateResult" + } + } + }, + { + "name": "session.active_list", + "summary": "Live sessions in this process, insertion order (not a DB browser).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionActiveListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionActiveListResult" + } + } + }, + { + "name": "session.archive", + "summary": "Set/clear archived (soft-hide, messages kept) on a session + lineage; Desktop PATCH parity.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionArchiveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionArchiveResult" + } + } + }, + { + "name": "session.branch", + "summary": "Fork a live session into a new stored child that shares the parent's history so far.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchResult" + } + } + }, + { + "name": "session.branch_stored", + "summary": "Whole-session branch of a stored parent: the owning backend reads and copies the transcript, which never crosses the wire (a separate method so an older gateway fails loudly, not with an empty branch).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchStoredParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchStoredResult" + } + } + }, + { + "name": "session.branch_whole", + "summary": "session.branch of the whole history without echoing the copied transcript back.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionBranchWholeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionBranchWholeResult" + } + } + }, + { + "name": "session.close", + "summary": "Tear down a live session (its stored row stays resumable).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCloseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCloseResult" + } + } + }, + { + "name": "session.compress", + "summary": "Manual /compress of an idle session, optionally focused on a topic.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCompressParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCompressResult" + } + } + }, + { + "name": "session.context_breakdown", + "summary": "Cursor-style split of the context window by category.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionContextBreakdownParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionContextBreakdownResult" + } + } + }, + { + "name": "session.control", + "summary": "Run one allowlisted goal / loop / subgoal / heartbeat action and return the exact resulting snapshot.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionControlResult" + } + } + }, + { + "name": "session.control.read", + "summary": "Stable, allowlisted snapshot of one live session's goal / loop / heartbeat state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionControlReadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionControlReadResult" + } + } + }, + { + "name": "session.create", + "summary": "Mint a live session (agent builds after the reply); a DB row appears on the first prompt unless seeded.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCreateParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCreateResult" + } + } + }, + { + "name": "session.cwd.set", + "summary": "Change a live, idle session's working directory.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCwdSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCwdSetResult" + } + } + }, + { + "name": "session.delete", + "summary": "Delete a stored session + transcripts; refused while it is live here.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionDeleteParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionDeleteResult" + } + } + }, + { + "name": "session.events.since", + "summary": "Replay events after a seq watermark on WS reconnect; truncated means refetch state.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionEventsSinceParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionEventsSinceResult" + } + } + }, + { + "name": "session.events.stats", + "summary": "Replay-buffer occupancy telemetry (ops/debug).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionEventsStatsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionEventsStatsResult" + } + } + }, + { + "name": "session.foreign.import", + "summary": "Import a foreign session into this profile's history (idempotent per origin).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignImportResult" + } + } + }, + { + "name": "session.foreign.list", + "summary": "One page of Claude Code / Codex sessions found on the serving backend.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignListResult" + } + } + }, + { + "name": "session.foreign.preview", + "summary": "Preview a foreign session's tail before importing it.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionForeignIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionForeignPreviewResult" + } + } + }, + { + "name": "session.history", + "summary": "The durable display transcript (ancestors included, row ids attached).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionHistoryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionHistoryResult" + } + } + }, + { + "name": "session.interrupt", + "summary": "Stop the running turn (and streaming TTS); retires the crash-recovery marker.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionInterruptParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionInterruptResult" + } + } + }, + { + "name": "session.list", + "summary": "Human-facing stored sessions, most recent first (sub-agent / kanban sources denied).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionListResult" + } + } + }, + { + "name": "session.most_recent", + "summary": "Most recent human-facing session; errors fold into a null session_id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionMostRecentParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionMostRecentResult" + } + } + }, + { + "name": "session.redirect", + "summary": "Redirect the active turn (queued for the next turn while the agent is still building).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionResult" + } + } + }, + { + "name": "session.resume", + "summary": "Attach to a stored session: reuse it if live here, else lazy / deferred / cold / eager rebuild.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionResumeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionResumeResult" + } + } + }, + { + "name": "session.save", + "summary": "Export the transcript to ~/.hermes/sessions/saved (classic /save).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionSaveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionSaveResult" + } + } + }, + { + "name": "session.set_hidden", + "summary": "Set/clear hidden (out of the default list, still resumable by its owner) on a session + lineage.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionSetHiddenParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionSetHiddenResult" + } + } + }, + { + "name": "session.status", + "summary": "Rendered /status text for the session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionStatusResult" + } + } + }, + { + "name": "session.steer", + "summary": "Inject text into the next tool result without interrupting the turn.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionCorrectionResult" + } + } + }, + { + "name": "session.title", + "summary": "Read or set a live session's title; a title set before the row exists is queued.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionTitleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionTitleResult" + } + } + }, + { + "name": "session.undo", + "summary": "Drop the last user turn (and everything after it) from an idle session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionUndoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionUndoResult" + } + } + }, + { + "name": "session.usage", + "summary": "Token / context / cost counters for the session (+ Nous credit lines when available).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionUsageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionUsageResult" + } + } + }, + { + "name": "session.workspace.move", + "summary": "Re-home a stored session's workspace; git identity is replaced and a live agent follows.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionWorkspaceMoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SessionWorkspaceMoveResult" + } + } + }, + { + "name": "setup.runtime_check", + "summary": "Strict provider check through the same runtime resolution the agent uses on session creation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SetupRuntimeCheckParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SetupRuntimeCheckResult" + } + } + }, + { + "name": "setup.status", + "summary": "Loose provider check: is ANY provider auth state discoverable for the (launch or named) profile.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SetupStatusResult" + } + } + }, + { + "name": "shared_metrics.desktop_daily", + "summary": "Record one finished Desktop day (mode use + button presses); recorded=false keeps it for a retry.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDailyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDailyResult" + } + } + }, + { + "name": "shared_metrics.desktop_dislike", + "summary": "Count one Desktop dislike signal (fire-and-forget; capped per signal per day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopDislikeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_feature_use", + "summary": "Count one Desktop area used today (fire-and-forget; once per area per UTC day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopFeatureUseParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_friction", + "summary": "Count one Desktop friction event (fire-and-forget; capped per day; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopFrictionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.desktop_onboarding", + "summary": "Count one Desktop first-run step transition (fire-and-forget; once per step+event; a no-op unless on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsDesktopOnboardingParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OkResult" + } + } + }, + { + "name": "shared_metrics.set", + "summary": "Write both shared-metrics opt-ins at once (send requires collection) and reconcile consent windows.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsConsentResult" + } + } + }, + { + "name": "shared_metrics.slash_command", + "summary": "Count one user-typed slash command (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSlashCommandParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsSlashCommandResult" + } + } + }, + { + "name": "shared_metrics.startup_latency", + "summary": "Record one client launch-to-ready latency (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsStartupLatencyParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsStartupLatencyResult" + } + } + }, + { + "name": "shared_metrics.status", + "summary": "Pure read of the focused profile's shared-metrics opt-ins (collection, upload, answered).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsConsentResult" + } + } + }, + { + "name": "shared_metrics.update_run", + "summary": "Count one Desktop packaged self-update outcome (fire-and-forget; a no-op unless shared metrics are on).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SharedMetricsUpdateRunParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedMetricsUpdateRunResult" + } + } + }, + { + "name": "shell.exec", + "summary": "Run a safe (non-dangerous) shell command captured for ``!cmd`` / inline substitution.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ShellExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ShellExecResult" + } + } + }, + { + "name": "skills.manage", + "summary": "Skills hub backend: list the profile's skills or search / browse / inspect / install from the hub.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SkillsManageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SkillsManageResult" + } + } + }, + { + "name": "skills.reload", + "summary": "Re-scan skill dirs; the pre-rendered ``output`` is what /reload-skills prints.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SkillsReloadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SkillsReloadResult" + } + } + }, + { + "name": "slash.exec", + "summary": "Execute a slash command against the session's slash worker (or a live/plugin shortcut).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SlashExecParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SlashExecResult" + } + } + }, + { + "name": "spawn_tree.list", + "summary": "Saved spawn-tree snapshots, newest first.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeListResult" + } + } + }, + { + "name": "spawn_tree.load", + "summary": "Read one saved spawn-tree snapshot (path must be under the spawn-trees root).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeLoadParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeLoadResult" + } + } + }, + { + "name": "spawn_tree.save", + "summary": "Persist a finished delegation tree snapshot under the session's spawn-trees dir.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SpawnTreeSaveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SpawnTreeSaveResult" + } + } + }, + { + "name": "subagent.interrupt", + "summary": "Hard-interrupt one owned child; ``found`` is false when it already finished.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentInterruptResult" + } + } + }, + { + "name": "subagent.list", + "summary": "Live children owned by this session (other sessions' children never leak).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SessionParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentListResult" + } + } + }, + { + "name": "subagent.steer", + "summary": "Queue steering text into a live delegated child owned by this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentSteerParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentSteerResult" + } + } + }, + { + "name": "subagent.tail", + "summary": "Last 16KB of an owned child's live transcript.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubagentIdParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubagentTailResult" + } + } + }, + { + "name": "subscription.change", + "summary": "Schedule a downgrade / same-price change or a period-end cancellation.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionChangeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingPendingChangeResult" + } + } + }, + { + "name": "subscription.preview", + "summary": "Chargeless quote of what a plan change would do (billing:manage).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionPreviewParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionPreviewResult" + } + } + }, + { + "name": "subscription.resume", + "summary": "Clear a scheduled downgrade / cancellation (re-enables recurring spend).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/BillingPendingChangeResult" + } + } + }, + { + "name": "subscription.state", + "summary": "Current plan, tier catalog and usage for the picker; fail-open when logged out.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionStateResult" + } + } + }, + { + "name": "subscription.upgrade", + "summary": "Prorate, charge and flip the plan (billing:manage, idempotent).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SubscriptionUpgradeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SubscriptionUpgradeResult" + } + } + }, + { + "name": "system.battery", + "summary": "Host battery for the status bar; always resolves, ``available: false`` when unreadable.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SystemBatteryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SystemBatteryResult" + } + } + }, + { + "name": "terminal.resize", + "summary": "Record the client's column width for server-side rendering.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/TerminalResizeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/TerminalResizeResult" + } + } + }, + { + "name": "tools.configure", + "summary": "Persist a toolset / MCP enable-disable change and rebuild the session agent so it takes effect now.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ToolsConfigureParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsConfigureResult" + } + } + }, + { + "name": "tools.list", + "summary": "Every toolset with its resolved tool names, flagged against the session's (or config's) enabled set.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsetsListResult" + } + } + }, + { + "name": "tools.show", + "summary": "The /tools listing grouped by toolset, including tools deferred behind the tool_search bridge.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsShowResult" + } + } + }, + { + "name": "toolsets.list", + "summary": "Toolset summaries (no tool names) for the desktop Toolsets tab.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/_SessionScoped" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ToolsetsListResult" + } + } + }, + { + "name": "usage.bars", + "summary": "Two-bar dollar usage view shared by /usage, /topup and /subscription; fail-open to unavailable.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/UsageModel" + } + } + }, + { + "name": "vault.add", + "summary": "Add a login / payment / address item to the local vault.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultAddParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultAddResult" + } + } + }, + { + "name": "vault.list", + "summary": "Metadata-only listing across the local vault and every unlocked password manager.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultListResult" + } + } + }, + { + "name": "vault.lock", + "summary": "Forget a manager's session token (every manager when no name is given).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultLockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultLockResult" + } + } + }, + { + "name": "vault.remove", + "summary": "Remove a local vault item by id.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultRemoveParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultRemoveResult" + } + } + }, + { + "name": "vault.source.set", + "summary": "Enable or disable an external password manager (disabling also locks it).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultSourceSetParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultSourceSetResult" + } + } + }, + { + "name": "vault.sources", + "summary": "Status of every login source (local vault + detected password managers).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ProfileParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultSourcesResult" + } + } + }, + { + "name": "vault.unlock", + "summary": "Unlock a password manager for this session with its master password.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultUnlockParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VaultUnlockResult" + } + } + }, + { + "name": "verification.status", + "summary": "Best known verification evidence for a cwd/session; read-only, never runs checks.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VerificationStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VerificationStatusResult" + } + } + }, + { + "name": "voice.record", + "summary": "VAD-bounded push-to-talk; the transcript arrives as a voice.transcript event.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceRecordParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceRecordResult" + } + } + }, + { + "name": "voice.toggle", + "summary": "/voice parity: report, flip voice mode on/off, or toggle speech output.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceToggleParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceToggleResult" + } + } + }, + { + "name": "voice.tts", + "summary": "Speak text through the backend TTS engine (barge-in aware).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VoiceTtsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/VoiceTtsResult" + } + } + }, + { + "name": "wake.feed", + "summary": "Push client-captured PCM into the armed detector (mic-less remote backends).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeFeedParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeFeedResult" + } + } + }, + { + "name": "wake.pause", + "summary": "Release the mic (e.g. while the desktop's browser captures audio).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakePauseResult" + } + } + }, + { + "name": "wake.resume", + "summary": "Reclaim the mic after a pause; no-op if the listener isn't armed.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeControlParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeResumeResult" + } + } + }, + { + "name": "wake.start", + "summary": "Arm the wake-word listener for the calling surface; refusals explain why.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStartResult" + } + } + }, + { + "name": "wake.status", + "summary": "Everything a client needs to draw the wake-word state and decide whether to (re)arm.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStatusParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStatusResult" + } + } + }, + { + "name": "wake.stop", + "summary": "Stop this surface's listener; persist also writes wake_word.enabled: false.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/WakeStopParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/WakeStopResult" + } + } + } + ], + "components": { + "schemas": { + "AccountOwner": { + "additionalProperties": false, + "properties": { + "type": { + "const": "account", + "title": "Type", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AccountOwner", + "type": "object" + }, + "ActiveIdResult": { + "additionalProperties": false, + "properties": { + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + } + }, + "title": "ActiveIdResult", + "type": "object" + }, + "ActiveSubagent": { + "additionalProperties": true, + "description": "One live child from ``tools/delegate_tool_registry.py::list_active_subagents`` (the record\nis extended by the child runner \u2014 ``missed_steer`` etc. \u2014 so it stays open).", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "goal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Goal" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "owner_agent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Agent Session Id" + } + }, + "required": [ + "subagent_id" + ], + "title": "ActiveSubagent", + "type": "object" + }, + "AgentPluginRow": { + "additionalProperties": false, + "description": "``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "portable": { + "title": "Portable", + "type": "boolean" + }, + "install_dir": { + "title": "Install Dir", + "type": "string" + }, + "has_desktop_half": { + "title": "Has Desktop Half", + "type": "boolean" + }, + "servers": { + "items": { + "$ref": "#/components/schemas/PluginServerRow" + }, + "title": "Servers", + "type": "array" + }, + "catalog_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Name" + }, + "catalog_tier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Tier" + }, + "installed_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Installed Sha" + }, + "catalog_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Sha" + }, + "catalog_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Version" + }, + "update_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Update Available" + }, + "pinned_sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pinned Sha" + }, + "settings_schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PluginSettingField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settings Schema" + } + }, + "required": [ + "name", + "key", + "version", + "description", + "source", + "status", + "portable", + "install_dir", + "has_desktop_half", + "servers" + ], + "title": "AgentPluginRow", + "type": "object" + }, + "AgentProcessRow": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "title": "Command", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "uptime": { + "title": "Uptime", + "type": "integer" + } + }, + "required": [ + "session_id", + "command", + "status", + "uptime" + ], + "title": "AgentProcessRow", + "type": "object" + }, + "AgentsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "AgentsListParams", + "type": "object" + }, + "AgentsListResult": { + "additionalProperties": false, + "properties": { + "processes": { + "items": { + "$ref": "#/components/schemas/AgentProcessRow" + }, + "title": "Processes", + "type": "array" + } + }, + "title": "AgentsListResult", + "type": "object" + }, + "ApprovalChoice": { + "enum": [ + "once", + "session", + "always", + "deny" + ], + "title": "ApprovalChoice", + "type": "string" + }, + "ApprovalPendingParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ApprovalPendingParams", + "type": "object" + }, + "ApprovalPendingResult": { + "additionalProperties": false, + "properties": { + "approvals": { + "items": { + "$ref": "#/components/schemas/PendingApproval" + }, + "title": "Approvals", + "type": "array" + } + }, + "required": [ + "approvals" + ], + "title": "ApprovalPendingResult", + "type": "object" + }, + "ApprovalReceivedParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "request_id": { + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "session_id", + "request_id" + ], + "title": "ApprovalReceivedParams", + "type": "object" + }, + "ApprovalReceivedResult": { + "additionalProperties": false, + "properties": { + "acknowledged": { + "title": "Acknowledged", + "type": "boolean" + } + }, + "required": [ + "acknowledged" + ], + "title": "ApprovalReceivedResult", + "type": "object" + }, + "ApprovalRequestParams": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_approval_request_payload`` \u2014 the command is redacted server-side.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "request_id": { + "title": "Request Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "choices": { + "items": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "title": "Choices", + "type": "array" + }, + "allow_permanent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Permanent" + }, + "allow_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Session" + }, + "smart_denied": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Smart Denied" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + }, + "gateway_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Session Id" + } + }, + "required": [ + "session_id", + "request_id" + ], + "title": "ApprovalRequestParams", + "type": "object" + }, + "ApprovalRespondParams": { + "additionalProperties": false, + "description": "``choice`` is one of the offered ``approval`` choices (once / session / always / deny); ``all``\nresolves every pending approval, ``request_id`` a specific one, neither the oldest.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choice" + }, + "all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "All" + }, + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + } + }, + "required": [ + "session_id" + ], + "title": "ApprovalRespondParams", + "type": "object" + }, + "ApprovalRespondResult": { + "additionalProperties": false, + "properties": { + "resolved": { + "title": "Resolved", + "type": "integer" + } + }, + "required": [ + "resolved" + ], + "title": "ApprovalRespondResult", + "type": "object" + }, + "ApprovalResult": { + "additionalProperties": false, + "properties": { + "choice": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "All" + } + }, + "required": [ + "choice" + ], + "title": "ApprovalResult", + "type": "object" + }, + "ArgumentMode": { + "enum": [ + "options", + "text", + "mixed" + ], + "title": "ArgumentMode", + "type": "string" + }, + "AttachedImageResult": { + "additionalProperties": false, + "description": "``methods_prompt.py::_attached_image_result``: the image is queued for the next turn.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "attached": { + "title": "Attached", + "type": "boolean" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "remainder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Remainder" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bytes" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "attached" + ], + "title": "AttachedImageResult", + "type": "object" + }, + "AutoContinue": { + "additionalProperties": false, + "description": "A crash-interrupted turn was scheduled to continue right after this resume.", + "properties": { + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "interrupted_at": { + "title": "Interrupted At", + "type": "number" + } + }, + "required": [ + "attempt", + "interrupted_at" + ], + "title": "AutoContinue", + "type": "object" + }, + "AutoReloadCardKind": { + "enum": [ + "canonical", + "distinct", + "none" + ], + "title": "AutoReloadCardKind", + "type": "string" + }, + "BatteryCategory": { + "description": "``agent/battery.py::battery_category`` colour bucket.", + "enum": [ + "good", + "warn", + "bad", + "critical", + "dim" + ], + "title": "BatteryCategory", + "type": "string" + }, + "BillingAutoReload": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "threshold_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Threshold Usd" + }, + "threshold_display": { + "title": "Threshold Display", + "type": "string" + }, + "reload_to_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reload To Usd" + }, + "reload_to_display": { + "title": "Reload To Display", + "type": "string" + }, + "card": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingAutoReloadCard" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "enabled", + "threshold_display", + "reload_to_display" + ], + "title": "BillingAutoReload", + "type": "object" + }, + "BillingAutoReloadCard": { + "additionalProperties": false, + "description": "Only ``distinct`` carries the payment-method identity.", + "properties": { + "kind": { + "$ref": "#/components/schemas/AutoReloadCardKind" + }, + "payment_method_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payment Method Id" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Brand" + }, + "last4": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last4" + } + }, + "required": [ + "kind" + ], + "title": "BillingAutoReloadCard", + "type": "object" + }, + "BillingAutoReloadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Threshold" + }, + "top_up_amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top Up Amount" + } + }, + "title": "BillingAutoReloadParams", + "type": "object" + }, + "BillingBlock": { + "additionalProperties": false, + "description": "``agent/billing_links.py::BillingBlock.to_dict`` (+ ``unverified`` from conversation_loop).", + "properties": { + "provider": { + "title": "Provider", + "type": "string" + }, + "provider_label": { + "title": "Provider Label", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "billing_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Billing Url" + }, + "is_nous": { + "title": "Is Nous", + "type": "boolean" + }, + "message": { + "title": "Message", + "type": "string" + }, + "unverified": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unverified" + } + }, + "required": [ + "provider", + "provider_label", + "model", + "billing_url", + "is_nous", + "message" + ], + "title": "BillingBlock", + "type": "object" + }, + "BillingCardInfo": { + "additionalProperties": false, + "properties": { + "brand": { + "title": "Brand", + "type": "string" + }, + "last4": { + "title": "Last4", + "type": "string" + }, + "masked": { + "title": "Masked", + "type": "string" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "resolved_via": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Via" + } + }, + "required": [ + "brand", + "last4", + "masked" + ], + "title": "BillingCardInfo", + "type": "object" + }, + "BillingChargeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "amount_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Usd" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "title": "BillingChargeParams", + "type": "object" + }, + "BillingChargeResult": { + "additionalProperties": false, + "description": "``202 {chargeId}`` \u2014 money is not confirmed yet; poll ``billing.charge_status``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "charge_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Id" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "required": [ + "ok" + ], + "title": "BillingChargeResult", + "type": "object" + }, + "BillingChargeStatusParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "charge_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Id" + } + }, + "title": "BillingChargeStatusParams", + "type": "object" + }, + "BillingChargeStatusResult": { + "additionalProperties": false, + "description": "Single status read (pending | settled | failed); the caller drives the poll cadence.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "amount_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Usd" + }, + "settled_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "ok" + ], + "title": "BillingChargeStatusResult", + "type": "object" + }, + "BillingMonthlyCap": { + "additionalProperties": false, + "properties": { + "limit_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit Usd" + }, + "limit_display": { + "title": "Limit Display", + "type": "string" + }, + "spent_this_month_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spent This Month Usd" + }, + "spent_display": { + "title": "Spent Display", + "type": "string" + }, + "is_default_ceiling": { + "title": "Is Default Ceiling", + "type": "boolean" + } + }, + "required": [ + "limit_display", + "spent_display", + "is_default_ceiling" + ], + "title": "BillingMonthlyCap", + "type": "object" + }, + "BillingMutationResult": { + "additionalProperties": false, + "description": "A write with no success payload beyond ``ok``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + } + }, + "required": [ + "ok" + ], + "title": "BillingMutationResult", + "type": "object" + }, + "BillingPaymentMethod": { + "additionalProperties": false, + "description": "``_serialize_payment_method``: each kind emits only its own fields (a ``card`` never carries\n``email``; ``unknown`` carries what the server called it in ``raw_kind``).", + "properties": { + "kind": { + "$ref": "#/components/schemas/PaymentMethodKind" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Brand" + }, + "last4": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last4" + }, + "wallet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Wallet" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Email" + }, + "raw_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Raw Kind" + }, + "resolved_via": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Via" + } + }, + "required": [ + "kind" + ], + "title": "BillingPaymentMethod", + "type": "object" + }, + "BillingPendingChangeResult": { + "additionalProperties": false, + "description": "``_billing_pending_change``: ``message`` + the raw NAS body in ``payload`` on success.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + } + }, + "required": [ + "ok" + ], + "title": "BillingPendingChangeResult", + "type": "object" + }, + "BillingStateResult": { + "additionalProperties": false, + "description": "``_serialize_billing_state`` (money as strings); the ``except`` fallback emits only\n``ok / logged_in / free_tier_account / error``, so everything else is optional.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "logged_in": { + "title": "Logged In", + "type": "boolean" + }, + "free_tier_account": { + "default": false, + "title": "Free Tier Account", + "type": "boolean" + }, + "free_tier_model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Model" + }, + "org_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Name" + }, + "org_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Slug" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "is_admin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Admin" + }, + "can_change_plan": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Change Plan" + }, + "can_charge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Charge" + }, + "balance_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Balance Usd" + }, + "balance_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Balance Display" + }, + "cli_billing_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cli Billing Enabled" + }, + "charge_presets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Presets" + }, + "charge_presets_display": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Charge Presets Display" + }, + "min_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Usd" + }, + "max_usd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Usd" + }, + "card": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingCardInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "payment_method": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingPaymentMethod" + }, + { + "type": "null" + } + ], + "default": null + }, + "monthly_cap": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingMonthlyCap" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_reload": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingAutoReload" + }, + { + "type": "null" + } + ], + "default": null + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "ok", + "logged_in" + ], + "title": "BillingStateResult", + "type": "object" + }, + "BillingStepUpParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "BillingStepUpParams", + "type": "object" + }, + "BillingStepUpResult": { + "additionalProperties": false, + "description": "``granted`` false when the server downscopes (also on every error envelope).", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "granted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Granted" + } + }, + "required": [ + "ok" + ], + "title": "BillingStepUpResult", + "type": "object" + }, + "BillingStepUpVerificationPayload": { + "additionalProperties": false, + "description": "``methods_session`` billing.step_up on_verification.", + "properties": { + "verification_url": { + "title": "Verification Url", + "type": "string" + }, + "user_code": { + "title": "User Code", + "type": "string" + } + }, + "required": [ + "verification_url", + "user_code" + ], + "title": "BillingStepUpVerificationPayload", + "type": "object" + }, + "BotRelayDeliverParams": { + "additionalProperties": false, + "description": "``profile`` here is the TARGET profile on this gateway (also what the desktop route wrapper adds).", + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "from_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Profile" + }, + "from_handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Handle" + }, + "from_connection": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Connection" + } + }, + "required": [ + "profile", + "message" + ], + "title": "BotRelayDeliverParams", + "type": "object" + }, + "BotRelayDeliverResult": { + "additionalProperties": false, + "properties": { + "reply": { + "title": "Reply", + "type": "string" + } + }, + "required": [ + "reply" + ], + "title": "BotRelayDeliverResult", + "type": "object" + }, + "BotRelayOutboxDrainParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "BotRelayOutboxDrainParams", + "type": "object" + }, + "BotRelayOutboxDrainResult": { + "additionalProperties": false, + "properties": { + "envelopes": { + "items": { + "$ref": "#/components/schemas/RelayEnvelope" + }, + "title": "Envelopes", + "type": "array" + } + }, + "required": [ + "envelopes" + ], + "title": "BotRelayOutboxDrainResult", + "type": "object" + }, + "BotRelayReplyParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "reply": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reply" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "id" + ], + "title": "BotRelayReplyParams", + "type": "object" + }, + "BotRelayRosterSyncParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "agents": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/RelayAgentRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Agents" + } + }, + "title": "BotRelayRosterSyncParams", + "type": "object" + }, + "BotRelayRosterSyncResult": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "count" + ], + "title": "BotRelayRosterSyncResult", + "type": "object" + }, + "BrowserAction": { + "enum": [ + "status", + "connect", + "disconnect" + ], + "title": "BrowserAction", + "type": "string" + }, + "BrowserControllerCancelPayload": { + "additionalProperties": false, + "description": "``gateway/browser_control_broker.py::_cancel_frame``.", + "properties": { + "command_id": { + "title": "Command Id", + "type": "string" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "command_id" + ], + "title": "BrowserControllerCancelPayload", + "type": "object" + }, + "BrowserControllerCommandPayload": { + "additionalProperties": false, + "description": "``gateway/browser_control_broker.py`` FRAME_COMMAND params.", + "properties": { + "command_id": { + "title": "Command Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "arguments": { + "additionalProperties": true, + "title": "Arguments", + "type": "object" + }, + "controller_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Controller Id" + }, + "browser_profile_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser Profile Id" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "command_id", + "action", + "arguments" + ], + "title": "BrowserControllerCommandPayload", + "type": "object" + }, + "BrowserControllerDetachResult": { + "additionalProperties": false, + "properties": { + "detached": { + "default": true, + "title": "Detached", + "type": "boolean" + } + }, + "title": "BrowserControllerDetachResult", + "type": "object" + }, + "BrowserControllerParams": { + "additionalProperties": false, + "description": "Every controller call names the session the controller is attached to.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "BrowserControllerParams", + "type": "object" + }, + "BrowserControllerRegisterParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "controller_id": { + "title": "Controller Id", + "type": "string" + }, + "browser_profile_id": { + "title": "Browser Profile Id", + "type": "string" + }, + "capabilities": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capabilities" + }, + "protocol_version": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Protocol Version" + }, + "principal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal Id" + } + }, + "required": [ + "session_id", + "controller_id", + "browser_profile_id" + ], + "title": "BrowserControllerRegisterParams", + "type": "object" + }, + "BrowserControllerRegisterResult": { + "additionalProperties": false, + "properties": { + "scope": { + "$ref": "#/components/schemas/ControllerScope" + } + }, + "required": [ + "scope" + ], + "title": "BrowserControllerRegisterResult", + "type": "object" + }, + "BrowserControllerResultParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command_id": { + "title": "Command Id", + "type": "string" + }, + "ok": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "result": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Result" + }, + "error": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "session_id", + "command_id" + ], + "title": "BrowserControllerResultParams", + "type": "object" + }, + "BrowserControllerResultResult": { + "additionalProperties": false, + "properties": { + "accepted": { + "title": "Accepted", + "type": "boolean" + } + }, + "required": [ + "accepted" + ], + "title": "BrowserControllerResultResult", + "type": "object" + }, + "BrowserManageParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/BrowserAction", + "default": "status" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "BrowserManageParams", + "type": "object" + }, + "BrowserManageResult": { + "additionalProperties": false, + "properties": { + "connected": { + "title": "Connected", + "type": "boolean" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "messages": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + } + }, + "required": [ + "connected" + ], + "title": "BrowserManageResult", + "type": "object" + }, + "BrowserProgressPayload": { + "additionalProperties": false, + "description": "``methods_browser`` announce(); ``level``: info | warn | error.", + "properties": { + "message": { + "title": "Message", + "type": "string" + }, + "level": { + "title": "Level", + "type": "string" + } + }, + "required": [ + "message", + "level" + ], + "title": "BrowserProgressPayload", + "type": "object" + }, + "CapabilityEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "CapabilityEntry", + "type": "object" + }, + "CatalogAppState": { + "description": "The desktop app a catalog plugin drives, from its ``hermes_platform`` declaration.", + "enum": [ + "present", + "missing_app", + "app_not_running", + "unknown" + ], + "title": "CatalogAppState", + "type": "string" + }, + "CatalogScan": { + "additionalProperties": false, + "description": "The catalog's security scan of the pinned commit; read-only on the card.", + "properties": { + "status": { + "$ref": "#/components/schemas/CatalogScanStatus" + }, + "summary": { + "title": "Summary", + "type": "string" + } + }, + "required": [ + "status", + "summary" + ], + "title": "CatalogScan", + "type": "object" + }, + "CatalogScanStatus": { + "enum": [ + "passed", + "warnings", + "failed" + ], + "title": "CatalogScanStatus", + "type": "string" + }, + "CatalogTier": { + "enum": [ + "official", + "community" + ], + "title": "CatalogTier", + "type": "string" + }, + "ChangeSignalPayload": { + "additionalProperties": true, + "description": "``change_watcher._CHANGE_WATCHES`` payload fn \u2014 ``{}`` for every watch except pet.changed.", + "properties": {}, + "title": "ChangeSignalPayload", + "type": "object" + }, + "ClarifyLockParams": { + "additionalProperties": false, + "properties": { + "request_id": { + "title": "Request Id", + "type": "string" + }, + "question_id": { + "title": "Question Id", + "type": "string" + }, + "answer": { + "default": "", + "title": "Answer" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "request_id", + "question_id" + ], + "title": "ClarifyLockParams", + "type": "object" + }, + "ClarifyLockResult": { + "additionalProperties": false, + "description": "``remaining`` lists the qids still unanswered; the lock that empties it resolves the request.\n``expired``: the wait already ended (timeout / cancel) \u2014 not an error.", + "properties": { + "status": { + "$ref": "#/components/schemas/ClarifyLockStatus" + }, + "remaining": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Remaining" + } + }, + "required": [ + "status" + ], + "title": "ClarifyLockResult", + "type": "object" + }, + "ClarifyLockStatus": { + "enum": [ + "ok", + "expired" + ], + "title": "ClarifyLockStatus", + "type": "string" + }, + "ClarifyQuestion": { + "additionalProperties": false, + "properties": { + "qid": { + "title": "Qid", + "type": "string" + }, + "question": { + "title": "Question", + "type": "string" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "multi_select": { + "default": false, + "title": "Multi Select", + "type": "boolean" + } + }, + "required": [ + "qid", + "question" + ], + "title": "ClarifyQuestion", + "type": "object" + }, + "ClarifyRequestParams": { + "additionalProperties": false, + "description": "Single question: ``question`` / ``choices`` (/ ``multi_select``); batch: ``questions``.\n``answers`` rides only on a reconnect replay (locks the server already accepted).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "question": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Question" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "multi_select": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Multi Select" + }, + "questions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ClarifyQuestion" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Questions" + }, + "answers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answers" + } + }, + "required": [ + "session_id" + ], + "title": "ClarifyRequestParams", + "type": "object" + }, + "ClarifyResult": { + "additionalProperties": false, + "description": "Single: ``{answer}`` ('' = skip). Batch: ``{answers}`` for the whole set (early locks go through\nthe ``clarify.lock`` RPC); a response with neither is cancel-all.", + "properties": { + "answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answer" + }, + "answers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Answers" + } + }, + "title": "ClarifyResult", + "type": "object" + }, + "CliExecParams": { + "additionalProperties": false, + "properties": { + "argv": { + "items": { + "type": "string" + }, + "title": "Argv", + "type": "array" + }, + "timeout": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Timeout" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "argv" + ], + "title": "CliExecParams", + "type": "object" + }, + "CliExecResult": { + "additionalProperties": false, + "properties": { + "blocked": { + "title": "Blocked", + "type": "boolean" + }, + "code": { + "title": "Code", + "type": "integer" + }, + "output": { + "title": "Output", + "type": "string" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + } + }, + "required": [ + "blocked", + "code", + "output" + ], + "title": "CliExecResult", + "type": "object" + }, + "ClientCapabilitiesParams": { + "additionalProperties": false, + "properties": { + "server_requests": { + "default": false, + "title": "Server Requests", + "type": "boolean" + } + }, + "title": "ClientCapabilitiesParams", + "type": "object" + }, + "ClientCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "server_requests": { + "items": { + "type": "string" + }, + "title": "Server Requests", + "type": "array" + }, + "declines_not_shown": { + "default": false, + "title": "Declines Not Shown", + "type": "boolean" + } + }, + "required": [ + "server_requests" + ], + "title": "ClientCapabilitiesResult", + "type": "object" + }, + "ClipboardPasteParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ClipboardPasteParams", + "type": "object" + }, + "CommandCatalogMeta": { + "additionalProperties": false, + "properties": { + "argument_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArgumentMode" + }, + { + "type": "null" + } + ], + "default": null + }, + "desktop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop" + } + }, + "title": "CommandCatalogMeta", + "type": "object" + }, + "CommandCategory": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "pairs": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Pairs", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "CommandCategory", + "type": "object" + }, + "CommandDispatchParams": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "arg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Arg" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "name" + ], + "title": "CommandDispatchParams", + "type": "object" + }, + "CommandDispatchResult": { + "additionalProperties": false, + "description": "One structured directive: ``exec``/``plugin`` carry ``output``; ``alias`` a ``target``;\n``send``/``prefill``/``skill`` a ``message`` (UIs render ``display``, never ``message``).", + "properties": { + "type": { + "$ref": "#/components/schemas/DispatchType" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notice" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + } + }, + "required": [ + "type" + ], + "title": "CommandDispatchResult", + "type": "object" + }, + "CommandResolveParams": { + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CommandResolveParams", + "type": "object" + }, + "CommandResolveResult": { + "additionalProperties": false, + "properties": { + "canonical": { + "title": "Canonical", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + } + }, + "required": [ + "canonical", + "description", + "category" + ], + "title": "CommandResolveResult", + "type": "object" + }, + "CommandsCatalogParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CommandsCatalogParams", + "type": "object" + }, + "CommandsCatalogResult": { + "additionalProperties": false, + "properties": { + "pairs": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Pairs", + "type": "array" + }, + "sub": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Sub", + "type": "object" + }, + "canon": { + "additionalProperties": { + "type": "string" + }, + "title": "Canon", + "type": "object" + }, + "commands": { + "additionalProperties": { + "$ref": "#/components/schemas/CommandCatalogMeta" + }, + "title": "Commands", + "type": "object" + }, + "categories": { + "items": { + "$ref": "#/components/schemas/CommandCategory" + }, + "title": "Categories", + "type": "array" + }, + "skills": { + "additionalProperties": { + "$ref": "#/components/schemas/SkillCatalogEntry" + }, + "title": "Skills", + "type": "object" + }, + "skill_count": { + "default": 0, + "title": "Skill Count", + "type": "integer" + }, + "warning": { + "default": "", + "title": "Warning", + "type": "string" + } + }, + "title": "CommandsCatalogResult", + "type": "object" + }, + "CompletePathParams": { + "additionalProperties": false, + "description": "``word`` is the token under the cursor (``@`` prefix = context reference); ``cwd`` /\n``session_id`` pick the directory the listing resolves against.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "word": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Word" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "CompletePathParams", + "type": "object" + }, + "CompleteSlashParams": { + "additionalProperties": false, + "description": "``session_id`` binds skill completions to that session's profile and workspace (project skills);\n``profile`` scopes a session-less request (a new-chat draft).", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CompleteSlashParams", + "type": "object" + }, + "CompleteSlashResult": { + "additionalProperties": false, + "description": "``replace_from`` is the column the accepted item replaces from.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CompletionItem" + }, + "title": "Items", + "type": "array" + }, + "replace_from": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Replace From" + } + }, + "title": "CompleteSlashResult", + "type": "object" + }, + "CompletionItem": { + "additionalProperties": false, + "description": "One popover row; ``kind`` rides only on slash completions (command vs skill).", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "display": { + "default": "", + "title": "Display", + "type": "string" + }, + "meta": { + "default": "", + "title": "Meta", + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + } + }, + "required": [ + "text" + ], + "title": "CompletionItem", + "type": "object" + }, + "CompletionItemsResult": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CompletionItem" + }, + "title": "Items", + "type": "array" + } + }, + "title": "CompletionItemsResult", + "type": "object" + }, + "CompressionSummary": { + "additionalProperties": true, + "description": "``agent.manual_compression_feedback.summarize_manual_compression``.", + "properties": { + "noop": { + "default": false, + "title": "Noop", + "type": "boolean" + }, + "aborted": { + "default": false, + "title": "Aborted", + "type": "boolean" + }, + "refused_would_grow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refused Would Grow" + }, + "fallback_used": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fallback Used" + }, + "headline": { + "default": "", + "title": "Headline", + "type": "string" + }, + "token_line": { + "default": "", + "title": "Token Line", + "type": "string" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Note" + } + }, + "title": "CompressionSummary", + "type": "object" + }, + "ConfigGetParams": { + "additionalProperties": false, + "description": "``key`` selects one getter from ``_CONFIG_GETTERS``; ``cwd`` feeds the ``project`` getter,\n``session_id`` lets ``reasoning`` / ``fast`` answer with the session's live pin.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "key": { + "title": "Key", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "key" + ], + "title": "ConfigGetParams", + "type": "object" + }, + "ConfigGetResult": { + "additionalProperties": false, + "description": "Union of every getter's payload: ``value`` for the simple words, ``config`` for ``full``,\n``mtime`` / ``mcp_rev`` for the poller, ``model`` / ``provider`` / ``providers`` for ``provider``,\n``home`` / ``display`` for ``profile``, ``cwd`` / ``branch`` for ``project``, ``prompt``.", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "tool_progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Progress" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "providers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ConfigProviderRef" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Providers" + }, + "home": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Home" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "mtime": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mtime" + }, + "mcp_rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mcp Rev" + } + }, + "title": "ConfigGetResult", + "type": "object" + }, + "ConfigProviderRef": { + "additionalProperties": true, + "description": "``hermes_cli/models.py::list_available_providers`` row.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "aliases": { + "items": { + "type": "string" + }, + "title": "Aliases", + "type": "array" + }, + "authenticated": { + "default": false, + "title": "Authenticated", + "type": "boolean" + } + }, + "required": [ + "id", + "label" + ], + "title": "ConfigProviderRef", + "type": "object" + }, + "ConfigSection": { + "additionalProperties": false, + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "rows": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Rows", + "type": "array" + } + }, + "required": [ + "title" + ], + "title": "ConfigSection", + "type": "object" + }, + "ConfigSetParams": { + "additionalProperties": false, + "description": "``key`` picks the setter (``_CONFIG_SETTERS``, ``details_mode.

``, display toggles);\n``value`` is the raw word/string the setter normalises (falsy non-strings are reported back in\nthe error). ``scope`` applies to ``yolo`` / ``reasoning``; ``confirm_expensive_model`` to ``model``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "default": "", + "title": "Value" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "confirm_expensive_model": { + "default": false, + "title": "Confirm Expensive Model", + "type": "boolean" + } + }, + "required": [ + "key" + ], + "title": "ConfigSetParams", + "type": "object" + }, + "ConfigSetResult": { + "additionalProperties": false, + "description": "``{key, value}`` plus the setter's extras: model switches add ``warning`` /\n``confirm_required`` / ``confirm_message`` / ``scope`` / ``deferred``; ``focus`` adds\n``tool_progress``; ``cwd`` adds ``cwd`` / ``branch``; ``personality`` adds ``history_reset`` /\n``info``; ``yolo`` reports its ``scope``. ``value`` is a bool only for the display toggles.", + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "confirm_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Required" + }, + "confirm_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Message" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "deferred": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deferred" + }, + "tool_progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Progress" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "history_reset": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Reset" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "key" + ], + "title": "ConfigSetResult", + "type": "object" + }, + "ConfigShowParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ConfigShowParams", + "type": "object" + }, + "ConfigShowResult": { + "additionalProperties": true, + "properties": { + "sections": { + "items": { + "$ref": "#/components/schemas/ConfigSection" + }, + "title": "Sections", + "type": "array" + } + }, + "title": "ConfigShowResult", + "type": "object" + }, + "ConnectionActor": { + "description": "``tools/connectors/contract.py::Actor``.", + "enum": [ + "user", + "backend_watcher", + "clock" + ], + "title": "ConnectionActor", + "type": "string" + }, + "ConnectionAnswer": { + "additionalProperties": false, + "description": "The card's answer: per-target outcomes and an optional Continue\n(``settled_by: \"continue\"``). Settlement is derived from target states afterwards.", + "properties": { + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionAnswerTarget" + }, + "title": "Targets", + "type": "array" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ConnectionAnswer", + "type": "object" + }, + "ConnectionAnswerStatus": { + "description": "What the card says about one row: ``tools/connectors/mcp.py::apply_answer``.", + "enum": [ + "approved", + "skipped" + ], + "title": "ConnectionAnswerStatus", + "type": "string" + }, + "ConnectionAnswerTarget": { + "additionalProperties": false, + "description": "One row's answer from the card. ``env`` carries the credential values an install asked for\nthrough ``required_env``.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ConnectionAnswerStatus" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + } + }, + "required": [ + "name", + "status" + ], + "title": "ConnectionAnswerTarget", + "type": "object" + }, + "ConnectionOperationParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "op_id": { + "title": "Op Id", + "type": "string" + } + }, + "required": [ + "owner", + "op_id" + ], + "title": "ConnectionOperationParams", + "type": "object" + }, + "ConnectionOperationStatus": { + "additionalProperties": false, + "description": "``methods_connectors._operation_view``: the operation's full snapshot.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets" + ], + "title": "ConnectionOperationStatus", + "type": "object" + }, + "ConnectionOperationTarget": { + "additionalProperties": false, + "description": "``Target.snapshot``: the link minted up front rides here, never in the model result. ``extra``\nkeys a leg records (``tools``, ``hint``) are typed here as they appear.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/ConnectionTargetKind" + }, + "action": { + "$ref": "#/components/schemas/ConnectionTargetAction" + }, + "state": { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Instructions" + }, + "discovery_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Discovery Error" + }, + "connect_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connect Url" + }, + "connection_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Id" + }, + "attempt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attempt" + }, + "required_env": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ConnectionTargetEnvField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Required Env" + }, + "tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "tier": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogTier" + }, + { + "type": "null" + } + ], + "default": null + }, + "platforms": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Platforms" + }, + "repo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo" + }, + "sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha" + }, + "subdir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subdir" + }, + "scan": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogScan" + }, + { + "type": "null" + } + ], + "default": null + }, + "requirements": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Requirements" + }, + "has_desktop_half": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Desktop Half" + }, + "target_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Profile" + }, + "app_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/CatalogAppState" + }, + { + "type": "null" + } + ], + "default": null + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + } + }, + "required": [ + "name", + "kind", + "action", + "state" + ], + "title": "ConnectionOperationTarget", + "type": "object" + }, + "ConnectionRequestPayload": { + "additionalProperties": false, + "description": "``ConnectionOperation.request_payload``: opens the card; also the ``pending_connection`` resume\nsnapshot so a client that missed the event restores the card with the server's deadline.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "timeout_seconds": { + "title": "Timeout Seconds", + "type": "number" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "timeout_seconds", + "targets" + ], + "title": "ConnectionRequestPayload", + "type": "object" + }, + "ConnectionRespondParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "op_id": { + "title": "Op Id", + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/ConnectionAnswer" + } + }, + "required": [ + "owner", + "op_id", + "result" + ], + "title": "ConnectionRespondParams", + "type": "object" + }, + "ConnectionRespondResult": { + "additionalProperties": false, + "properties": { + "status": { + "const": "ok", + "title": "Status", + "type": "string" + }, + "settled": { + "title": "Settled", + "type": "boolean" + } + }, + "required": [ + "status", + "settled" + ], + "title": "ConnectionRespondResult", + "type": "object" + }, + "ConnectionSettleReason": { + "description": "``tools/connectors/contract.py::SettleReason``.", + "enum": [ + "all_resolved", + "continue", + "deadline", + "interrupt" + ], + "title": "ConnectionSettleReason", + "type": "string" + }, + "ConnectionTargetAction": { + "enum": [ + "authorize", + "connect", + "enable", + "install", + "reconnect" + ], + "title": "ConnectionTargetAction", + "type": "string" + }, + "ConnectionTargetEnvField": { + "additionalProperties": false, + "description": "One credential an MCP install still needs; the card renders a field per entry and sends the\nvalues back with the approval.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "secret": { + "title": "Secret", + "type": "boolean" + }, + "default": { + "type": "string", + "title": "Default" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + } + }, + "required": [ + "name", + "required", + "secret", + "default" + ], + "title": "ConnectionTargetEnvField", + "type": "object" + }, + "ConnectionTargetKind": { + "enum": [ + "connector", + "mcp", + "plugin", + "skill" + ], + "title": "ConnectionTargetKind", + "type": "string" + }, + "ConnectionTargetState": { + "description": "``tools/connectors/contract.py::TargetState``.", + "enum": [ + "pending", + "initiated", + "connected", + "skipped", + "failed", + "expired", + "not_connected" + ], + "title": "ConnectionTargetState", + "type": "string" + }, + "ConnectionUpdatePayload": { + "additionalProperties": false, + "description": "``methods_connectors._connection_update``: one target transition (``target``/``from``/``to``/\n``actor``) or the settlement (none of those), with the full snapshot.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "from": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + { + "type": "null" + } + ], + "default": null + }, + "to": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionTargetState" + }, + { + "type": "null" + } + ], + "default": null + }, + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionActor" + }, + { + "type": "null" + } + ], + "default": null + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets", + "owner" + ], + "title": "ConnectionUpdatePayload", + "type": "object" + }, + "ConnectionWakeResult": { + "additionalProperties": false, + "properties": { + "status": { + "const": "ok", + "title": "Status", + "type": "string" + } + }, + "required": [ + "status" + ], + "title": "ConnectionWakeResult", + "type": "object" + }, + "ConnectorAccountRow": { + "additionalProperties": false, + "properties": { + "connection_id": { + "title": "Connection Id", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ConnectorAccountStatus" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status Reason" + }, + "label": { + "title": "Label", + "type": "string" + }, + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Alias" + }, + "active": { + "title": "Active", + "type": "boolean" + }, + "created_at": { + "title": "Created At", + "type": "string" + }, + "updated_at": { + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "connection_id", + "connector", + "status", + "label", + "active", + "created_at", + "updated_at" + ], + "title": "ConnectorAccountRow", + "type": "object" + }, + "ConnectorAccountStatus": { + "enum": [ + "pending", + "active", + "failed", + "expired", + "revoked", + "inactive" + ], + "title": "ConnectorAccountStatus", + "type": "string" + }, + "ConnectorAccountsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "connector": { + "anyOf": [ + { + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connector" + } + }, + "title": "ConnectorAccountsParams", + "type": "object" + }, + "ConnectorAccountsRemoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "connection_id": { + "minLength": 1, + "title": "Connection Id", + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "title": "ConnectorAccountsRemoveParams", + "type": "object" + }, + "ConnectorAccountsRemoveResult": { + "additionalProperties": false, + "properties": { + "connection_id": { + "title": "Connection Id", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "status": { + "const": "removed", + "title": "Status", + "type": "string" + } + }, + "required": [ + "connection_id", + "connector", + "status" + ], + "title": "ConnectorAccountsRemoveResult", + "type": "object" + }, + "ConnectorAccountsResult": { + "additionalProperties": false, + "properties": { + "accounts": { + "items": { + "$ref": "#/components/schemas/ConnectorAccountRow" + }, + "title": "Accounts", + "type": "array" + } + }, + "required": [ + "accounts" + ], + "title": "ConnectorAccountsResult", + "type": "object" + }, + "ConnectorCatalogRow": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "logo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Logo Url" + } + }, + "required": [ + "slug", + "name", + "description", + "category" + ], + "title": "ConnectorCatalogRow", + "type": "object" + }, + "ConnectorChange": { + "additionalProperties": false, + "properties": { + "type": { + "const": "connector", + "title": "Type", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "type", + "connector", + "enabled" + ], + "title": "ConnectorChange", + "type": "object" + }, + "ConnectorPolicyAllowBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "allow", + "title": "Mode", + "type": "string" + }, + "connectors": { + "items": { + "type": "string" + }, + "title": "Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "mode", + "connectors", + "tools" + ], + "title": "ConnectorPolicyAllowBody", + "type": "object" + }, + "ConnectorPolicyDenyAllBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "deny-all", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "mode" + ], + "title": "ConnectorPolicyDenyAllBody", + "type": "object" + }, + "ConnectorPolicyDenyBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "deny", + "title": "Mode", + "type": "string" + }, + "disabled_connectors": { + "items": { + "type": "string" + }, + "title": "Disabled Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "mode", + "disabled_connectors", + "tools" + ], + "title": "ConnectorPolicyDenyBody", + "type": "object" + }, + "ConnectorPolicyEffectiveAllow": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "allow", + "title": "Mode", + "type": "string" + }, + "connectors": { + "items": { + "type": "string" + }, + "title": "Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode", + "connectors", + "tools" + ], + "title": "ConnectorPolicyEffectiveAllow", + "type": "object" + }, + "ConnectorPolicyEffectiveDeny": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "deny", + "title": "Mode", + "type": "string" + }, + "disabled_connectors": { + "items": { + "type": "string" + }, + "title": "Disabled Connectors", + "type": "array" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "tags": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyTags" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode", + "disabled_connectors", + "tools" + ], + "title": "ConnectorPolicyEffectiveDeny", + "type": "object" + }, + "ConnectorPolicyEffectiveDenyAll": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "deny-all", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode" + ], + "title": "ConnectorPolicyEffectiveDenyAll", + "type": "object" + }, + "ConnectorPolicyEffectiveUnrestricted": { + "additionalProperties": false, + "properties": { + "version": { + "const": 1, + "title": "Version", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "issued_at_ms": { + "title": "Issued At Ms", + "type": "integer" + }, + "mode": { + "const": "unrestricted", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "version", + "revision", + "issued_at_ms", + "mode" + ], + "title": "ConnectorPolicyEffectiveUnrestricted", + "type": "object" + }, + "ConnectorPolicyGetResult": { + "additionalProperties": false, + "properties": { + "layers": { + "items": { + "$ref": "#/components/schemas/ConnectorPolicyLayer" + }, + "title": "Layers", + "type": "array" + }, + "effective": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyEffectiveAllow", + "deny": "#/components/schemas/ConnectorPolicyEffectiveDeny", + "deny-all": "#/components/schemas/ConnectorPolicyEffectiveDenyAll", + "unrestricted": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDenyAll" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveAllow" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDeny" + } + ], + "title": "Effective" + } + }, + "required": [ + "layers", + "effective" + ], + "title": "ConnectorPolicyGetResult", + "type": "object" + }, + "ConnectorPolicyLayer": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/components/schemas/ConnectorPolicyLayerKind" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "body": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyAllowBody", + "deny": "#/components/schemas/ConnectorPolicyDenyBody", + "deny-all": "#/components/schemas/ConnectorPolicyDenyAllBody", + "unrestricted": "#/components/schemas/ConnectorPolicyUnrestrictedBody" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyUnrestrictedBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyDenyAllBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyAllowBody" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyDenyBody" + } + ], + "title": "Body" + } + }, + "required": [ + "kind", + "revision", + "body" + ], + "title": "ConnectorPolicyLayer", + "type": "object" + }, + "ConnectorPolicyLayerKind": { + "enum": [ + "org", + "role", + "member" + ], + "title": "ConnectorPolicyLayerKind", + "type": "string" + }, + "ConnectorPolicySetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "change": { + "discriminator": { + "mapping": { + "connector": "#/components/schemas/ConnectorChange", + "tools": "#/components/schemas/ToolsChange" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ToolsChange" + }, + { + "$ref": "#/components/schemas/ConnectorChange" + } + ], + "title": "Change" + }, + "expected_revision": { + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + "title": "Expected Revision", + "type": "string" + } + }, + "required": [ + "change", + "expected_revision" + ], + "title": "ConnectorPolicySetParams", + "type": "object" + }, + "ConnectorPolicySetResult": { + "additionalProperties": false, + "properties": { + "revision": { + "title": "Revision", + "type": "string" + }, + "effective": { + "discriminator": { + "mapping": { + "allow": "#/components/schemas/ConnectorPolicyEffectiveAllow", + "deny": "#/components/schemas/ConnectorPolicyEffectiveDeny", + "deny-all": "#/components/schemas/ConnectorPolicyEffectiveDenyAll", + "unrestricted": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveUnrestricted" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDenyAll" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveAllow" + }, + { + "$ref": "#/components/schemas/ConnectorPolicyEffectiveDeny" + } + ], + "title": "Effective" + } + }, + "required": [ + "revision", + "effective" + ], + "title": "ConnectorPolicySetResult", + "type": "object" + }, + "ConnectorPolicyTags": { + "additionalProperties": false, + "properties": { + "enable": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enable" + }, + "disable": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disable" + } + }, + "title": "ConnectorPolicyTags", + "type": "object" + }, + "ConnectorPolicyUnrestrictedBody": { + "additionalProperties": false, + "properties": { + "mode": { + "const": "unrestricted", + "title": "Mode", + "type": "string" + } + }, + "required": [ + "mode" + ], + "title": "ConnectorPolicyUnrestrictedBody", + "type": "object" + }, + "ConnectorRow": { + "additionalProperties": false, + "properties": { + "connector": { + "title": "Connector", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "connection_status": { + "anyOf": [ + { + "enum": [ + "pending", + "active", + "failed", + "expired", + "revoked", + "inactive" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connection Status" + }, + "status_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Reason" + }, + "gateway_disabled_tools": { + "items": { + "type": "string" + }, + "title": "Gateway Disabled Tools", + "type": "array" + } + }, + "required": [ + "connector", + "enabled", + "connected", + "connection_status", + "status_reason", + "gateway_disabled_tools" + ], + "title": "ConnectorRow", + "type": "object" + }, + "ConnectorToolFacet": { + "enum": [ + "read", + "write", + "destructive", + "unclassified" + ], + "title": "ConnectorToolFacet", + "type": "string" + }, + "ConnectorToolRow": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "facet": { + "$ref": "#/components/schemas/ConnectorToolFacet" + }, + "hints": { + "items": { + "type": "string" + }, + "title": "Hints", + "type": "array" + }, + "categories": { + "items": { + "type": "string" + }, + "title": "Categories", + "type": "array" + }, + "no_auth": { + "title": "No Auth", + "type": "boolean" + }, + "deprecated": { + "title": "Deprecated", + "type": "boolean" + } + }, + "required": [ + "slug", + "name", + "description", + "facet", + "hints", + "categories", + "no_auth", + "deprecated" + ], + "title": "ConnectorToolRow", + "type": "object" + }, + "ConnectorToolsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "refresh": { + "default": false, + "title": "Refresh", + "type": "boolean" + } + }, + "required": [ + "slug" + ], + "title": "ConnectorToolsParams", + "type": "object" + }, + "ConnectorToolsResult": { + "additionalProperties": false, + "properties": { + "connector": { + "title": "Connector", + "type": "string" + }, + "toolkit_version": { + "title": "Toolkit Version", + "type": "string" + }, + "etag": { + "title": "Etag", + "type": "string" + }, + "fetched_at": { + "title": "Fetched At", + "type": "number" + }, + "source": { + "$ref": "#/components/schemas/ConnectorToolsSource" + }, + "stale": { + "title": "Stale", + "type": "boolean" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/ConnectorToolRow" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "connector", + "toolkit_version", + "etag", + "fetched_at", + "source", + "stale", + "tools" + ], + "title": "ConnectorToolsResult", + "type": "object" + }, + "ConnectorToolsSource": { + "enum": [ + "cache", + "network", + "revalidated" + ], + "title": "ConnectorToolsSource", + "type": "string" + }, + "ConnectorsCatalogResult": { + "additionalProperties": false, + "properties": { + "connectors": { + "items": { + "$ref": "#/components/schemas/ConnectorCatalogRow" + }, + "title": "Connectors", + "type": "array" + } + }, + "required": [ + "connectors" + ], + "title": "ConnectorsCatalogResult", + "type": "object" + }, + "ConnectorsConnectParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + }, + "connectors": { + "items": { + "pattern": "^[a-z0-9][a-z0-9_-]*$", + "type": "string" + }, + "minItems": 1, + "title": "Connectors", + "type": "array" + }, + "reconnect": { + "default": false, + "title": "Reconnect", + "type": "boolean" + } + }, + "required": [ + "owner", + "connectors" + ], + "title": "ConnectorsConnectParams", + "type": "object" + }, + "ConnectorsConnectResult": { + "additionalProperties": false, + "description": "``methods_connectors._reissue`` / ``managed._off_desktop_result``: the operation the connect opened; ``status``/``note`` ride along from the tool result.", + "properties": { + "op_id": { + "title": "Op Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "deadline_at": { + "title": "Deadline At", + "type": "number" + }, + "settled": { + "title": "Settled", + "type": "boolean" + }, + "settled_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Settled At" + }, + "settled_by": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionSettleReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "targets": { + "items": { + "$ref": "#/components/schemas/ConnectionOperationTarget" + }, + "title": "Targets", + "type": "array" + }, + "status": { + "anyOf": [ + { + "enum": [ + "initiated", + "settled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Note" + } + }, + "required": [ + "op_id", + "seq", + "deadline_at", + "settled", + "targets" + ], + "title": "ConnectorsConnectResult", + "type": "object" + }, + "ConnectorsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "owner": { + "discriminator": { + "mapping": { + "account": "#/components/schemas/AccountOwner", + "session": "#/components/schemas/SessionOwner" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/SessionOwner" + }, + { + "$ref": "#/components/schemas/AccountOwner" + } + ], + "title": "Owner" + } + }, + "required": [ + "owner" + ], + "title": "ConnectorsListParams", + "type": "object" + }, + "ConnectorsListResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "connectors": { + "items": { + "$ref": "#/components/schemas/ConnectorRow" + }, + "title": "Connectors", + "type": "array" + } + }, + "required": [ + "available", + "connectors" + ], + "title": "ConnectorsListResult", + "type": "object" + }, + "ContextCategory": { + "additionalProperties": false, + "properties": { + "color": { + "title": "Color", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "tokens": { + "title": "Tokens", + "type": "integer" + } + }, + "required": [ + "color", + "id", + "label", + "tokens" + ], + "title": "ContextCategory", + "type": "object" + }, + "ContextFileSource": { + "additionalProperties": false, + "description": "One row of ``agent.context_file_sources.list_context_file_sources``.", + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "chars": { + "title": "Chars", + "type": "integer" + }, + "est_tokens": { + "title": "Est Tokens", + "type": "integer" + }, + "loaded": { + "title": "Loaded", + "type": "boolean" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "label", + "path", + "chars", + "est_tokens", + "loaded", + "status" + ], + "title": "ContextFileSource", + "type": "object" + }, + "ControllerScope": { + "additionalProperties": false, + "properties": { + "principal_id": { + "title": "Principal Id", + "type": "string" + }, + "profile_id": { + "title": "Profile Id", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "controller_id": { + "title": "Controller Id", + "type": "string" + }, + "browser_profile_id": { + "title": "Browser Profile Id", + "type": "string" + }, + "transport_family": { + "title": "Transport Family", + "type": "string" + }, + "capabilities": { + "items": { + "type": "string" + }, + "title": "Capabilities", + "type": "array" + } + }, + "required": [ + "principal_id", + "profile_id", + "session_id", + "controller_id", + "browser_profile_id", + "transport_family", + "capabilities" + ], + "title": "ControllerScope", + "type": "object" + }, + "CorrectionStatus": { + "enum": [ + "queued", + "redirected", + "rejected" + ], + "title": "CorrectionStatus", + "type": "string" + }, + "CronAction": { + "enum": [ + "list", + "add", + "remove", + "pause", + "resume" + ], + "title": "CronAction", + "type": "string" + }, + "CronJobRow": { + "additionalProperties": true, + "description": "``tools/cronjob_job_args.py::_format_job``.", + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + }, + "skills": { + "items": { + "type": "string" + }, + "title": "Skills", + "type": "array" + }, + "prompt_preview": { + "default": "", + "title": "Prompt Preview", + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Base Url" + }, + "schedule": { + "default": "?", + "title": "Schedule", + "type": "string" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Run At" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Run At" + }, + "last_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Status" + }, + "last_delivery_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Delivery Error" + }, + "last_delivery_unverified": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Delivery Unverified" + }, + "last_fire_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Fire Error" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Error" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "paused_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused At" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "workdir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Workdir" + }, + "script": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Script" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Effort" + }, + "monitor_script": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor Script" + }, + "monitor_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor Url" + }, + "monitor_state": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Monitor State" + }, + "no_agent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Agent" + }, + "enabled_toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Toolsets" + }, + "continuity": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuity" + }, + "context_from": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context From" + }, + "attach_to_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Attach To Session" + } + }, + "required": [ + "job_id" + ], + "title": "CronJobRow", + "type": "object" + }, + "CronManageParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/CronAction", + "default": "list" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "include_disabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disabled" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "continuity": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuity" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "CronManageParams", + "type": "object" + }, + "CronManageResult": { + "additionalProperties": true, + "description": "Pass-through of ``tools/cronjob_tools.py::cronjob`` JSON: ``list`` \u2192 ``jobs``/``count``\n(+ ``scoped`` when profile-scoped); ``add`` \u2192 the created job's summary + ``job``; ``remove`` \u2192\n``removed_job``; ``pause``/``resume`` \u2192 ``job``. A tool-level failure lands in ``error``.", + "properties": { + "success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Success" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "jobs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CronJobRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Jobs" + }, + "scoped": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scoped" + }, + "gateway_running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Running" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "job_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Job Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "skill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill" + }, + "skills": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + }, + "repeat": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repeat" + }, + "deliver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Deliver" + }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Run At" + }, + "job": { + "anyOf": [ + { + "$ref": "#/components/schemas/CronJobRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "guidance": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Guidance" + }, + "removed_job": { + "anyOf": [ + { + "$ref": "#/components/schemas/CronRemovedJob" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "CronManageResult", + "type": "object" + }, + "CronRemovedJob": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schedule" + } + }, + "required": [ + "id" + ], + "title": "CronRemovedJob", + "type": "object" + }, + "CurrentSubscription": { + "additionalProperties": false, + "properties": { + "tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tier Id" + }, + "tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tier Name" + }, + "monthly_credits": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits" + }, + "credits_remaining": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credits Remaining" + }, + "cycle_ends_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cycle Ends At" + }, + "pending_downgrade_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade Tier Name" + }, + "pending_downgrade_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade At" + }, + "pending_downgrade_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending Downgrade Display" + }, + "cancel_at_period_end": { + "title": "Cancel At Period End", + "type": "boolean" + }, + "cancellation_effective_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Effective At" + }, + "cancellation_effective_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Effective Display" + } + }, + "required": [ + "cancel_at_period_end" + ], + "title": "CurrentSubscription", + "type": "object" + }, + "DelegationPauseParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "paused": { + "default": true, + "title": "Paused", + "type": "boolean" + } + }, + "title": "DelegationPauseParams", + "type": "object" + }, + "DelegationPauseResult": { + "additionalProperties": false, + "properties": { + "paused": { + "title": "Paused", + "type": "boolean" + } + }, + "required": [ + "paused" + ], + "title": "DelegationPauseResult", + "type": "object" + }, + "DelegationStatusResult": { + "additionalProperties": false, + "properties": { + "active": { + "items": { + "$ref": "#/components/schemas/ActiveSubagent" + }, + "title": "Active", + "type": "array" + }, + "paused": { + "title": "Paused", + "type": "boolean" + }, + "max_spawn_depth": { + "title": "Max Spawn Depth", + "type": "integer" + }, + "max_concurrent_children": { + "title": "Max Concurrent Children", + "type": "integer" + } + }, + "required": [ + "active", + "paused", + "max_spawn_depth", + "max_concurrent_children" + ], + "title": "DelegationStatusResult", + "type": "object" + }, + "DiagnosticsShareNousParams": { + "additionalProperties": false, + "properties": { + "error_context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Context" + }, + "extra_files": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extra Files" + }, + "log_lines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Log Lines" + } + }, + "title": "DiagnosticsShareNousParams", + "type": "object" + }, + "DiagnosticsShareNousResult": { + "additionalProperties": false, + "description": "Structured envelope: ``ok=False`` + ``error`` renders inline instead of failing the RPC.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "view_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "View Url" + }, + "upload_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Upload Id" + }, + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "ok" + ], + "title": "DiagnosticsShareNousResult", + "type": "object" + }, + "DiscoveredRepo": { + "additionalProperties": false, + "description": "``methods_projects._discover_repos_payload`` row: a git root with session totals.", + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "sessions": { + "default": 0, + "title": "Sessions", + "type": "integer" + }, + "last_active": { + "default": 0.0, + "title": "Last Active", + "type": "number" + } + }, + "required": [ + "root" + ], + "title": "DiscoveredRepo", + "type": "object" + }, + "DispatchType": { + "description": "``apps/shared/src/slash.ts::parseCommandDispatch`` branches on this.", + "enum": [ + "exec", + "alias", + "plugin", + "send", + "skill", + "prefill" + ], + "title": "DispatchType", + "type": "string" + }, + "DisplayImageSwitch": { + "additionalProperties": false, + "description": "A persisted Docker sandbox kept on the previous default image; the user decides the switch.", + "properties": { + "current_image": { + "title": "Current Image", + "type": "string" + }, + "target_image": { + "title": "Target Image", + "type": "string" + }, + "containers": { + "title": "Containers", + "type": "integer" + } + }, + "required": [ + "current_image", + "target_image", + "containers" + ], + "title": "DisplayImageSwitch", + "type": "object" + }, + "DisplayInstallDonePayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "code": { + "title": "Code", + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/DisplayStatus" + } + }, + "required": [ + "profile_key", + "code", + "status" + ], + "title": "DisplayInstallDonePayload", + "type": "object" + }, + "DisplayInstallLogPayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "line": { + "title": "Line", + "type": "string" + } + }, + "required": [ + "profile_key", + "line" + ], + "title": "DisplayInstallLogPayload", + "type": "object" + }, + "DisplayInstallResult": { + "additionalProperties": false, + "properties": { + "started": { + "title": "Started", + "type": "boolean" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "started", + "profile_key" + ], + "title": "DisplayInstallResult", + "type": "object" + }, + "DisplayInstallSudoParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "session_id", + "profile_key" + ], + "title": "DisplayInstallSudoParams", + "type": "object" + }, + "DisplayLease": { + "additionalProperties": false, + "description": "``tools/bot_desktop/lease.py::Lease`` as clients may see it: the holder's viewer id is a\ncapability and never leaves the gateway; ``viewer_hash`` lets the holder recognise itself.", + "properties": { + "holder": { + "$ref": "#/components/schemas/LeaseHolder" + }, + "viewer_id": { + "default": null, + "title": "Viewer Id", + "type": "null" + }, + "viewer_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Hash" + }, + "since": { + "title": "Since", + "type": "number" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "holder", + "since", + "epoch" + ], + "title": "DisplayLease", + "type": "object" + }, + "DisplayLeaseAcquireParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "title": "Viewer Id", + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "viewer_id" + ], + "title": "DisplayLeaseAcquireParams", + "type": "object" + }, + "DisplayLeasePayload": { + "additionalProperties": false, + "properties": { + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + } + }, + "required": [ + "profile_key", + "lease" + ], + "title": "DisplayLeasePayload", + "type": "object" + }, + "DisplayLeaseReleaseParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Id" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + } + }, + "title": "DisplayLeaseReleaseParams", + "type": "object" + }, + "DisplayLeaseResult": { + "additionalProperties": false, + "properties": { + "lease": { + "$ref": "#/components/schemas/DisplayLease" + } + }, + "required": [ + "lease" + ], + "title": "DisplayLeaseResult", + "type": "object" + }, + "DisplayObserveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "viewer_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Viewer Id" + } + }, + "title": "DisplayObserveParams", + "type": "object" + }, + "DisplayObserveResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "ticket": { + "title": "Ticket", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "viewer_id": { + "title": "Viewer Id", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "ticket", + "path", + "viewer_id" + ], + "title": "DisplayObserveResult", + "type": "object" + }, + "DisplayStatus": { + "additionalProperties": false, + "description": "``tools/bot_desktop/runtime.py::DesktopStatus`` plus the lease and the profile it speaks for.", + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key" + ], + "title": "DisplayStatus", + "type": "object" + }, + "DisplayStatusPayload": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key" + ], + "title": "DisplayStatusPayload", + "type": "object" + }, + "DisplayStopParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + } + }, + "title": "DisplayStopParams", + "type": "object" + }, + "DisplayStopResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "stopped": { + "title": "Stopped", + "type": "boolean" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "stopped" + ], + "title": "DisplayStopResult", + "type": "object" + }, + "DisplaySwitchSandboxImageParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "approve": { + "default": true, + "title": "Approve", + "type": "boolean" + } + }, + "title": "DisplaySwitchSandboxImageParams", + "type": "object" + }, + "DisplaySwitchSandboxImageResult": { + "additionalProperties": false, + "properties": { + "profile": { + "title": "Profile", + "type": "string" + }, + "supported": { + "title": "Supported", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "missing": { + "items": { + "type": "string" + }, + "title": "Missing", + "type": "array" + }, + "running": { + "title": "Running", + "type": "boolean" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Socket" + }, + "geometry": { + "title": "Geometry", + "type": "string" + }, + "install_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Install Command" + }, + "browser": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Browser" + }, + "blocker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Blocker" + }, + "memory_available_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Available Mb" + }, + "memory_limit_mb": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Memory Limit Mb" + }, + "placement": { + "default": "gateway", + "title": "Placement", + "type": "string" + }, + "image_switch": { + "anyOf": [ + { + "$ref": "#/components/schemas/DisplayImageSwitch" + }, + { + "type": "null" + } + ], + "default": null + }, + "lease": { + "$ref": "#/components/schemas/DisplayLease" + }, + "profile_key": { + "title": "Profile Key", + "type": "string" + }, + "docker_image": { + "title": "Docker Image", + "type": "string" + } + }, + "required": [ + "profile", + "supported", + "installed", + "missing", + "running", + "geometry", + "lease", + "profile_key", + "docker_image" + ], + "title": "DisplaySwitchSandboxImageResult", + "type": "object" + }, + "DisplayThumbnailResult": { + "additionalProperties": false, + "description": "``data_url`` is null while the screen is stopped or while a human holds the lease\n(``suppressed``): the frame may show what they are typing.", + "properties": { + "data_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Url" + }, + "suppressed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suppressed" + } + }, + "title": "DisplayThumbnailResult", + "type": "object" + }, + "EmptyRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "EmptyRequestParams", + "type": "object" + }, + "ErrorPayload": { + "additionalProperties": false, + "description": "Every ``_emit(\"error\", \u2026)`` site sets exactly ``message``.", + "properties": { + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "ErrorPayload", + "type": "object" + }, + "ErrorSurface": { + "additionalProperties": true, + "description": "``agent/error_surface.py::_surface`` \u2014 advisory {layer, code, retryable} (+ identity, + auth hint,\n+ ``resets_at`` epoch seconds when the provider named when its limit lifts).", + "properties": { + "layer": { + "title": "Layer", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "retryable": { + "title": "Retryable", + "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "resets_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resets At" + } + }, + "required": [ + "layer", + "code", + "retryable" + ], + "title": "ErrorSurface", + "type": "object" + }, + "FailedDelegation": { + "additionalProperties": false, + "description": "``async_delegation.failed_delegations_for_session`` row: one failed task of an async delegation.", + "properties": { + "delegation_id": { + "title": "Delegation Id", + "type": "string" + }, + "task_index": { + "default": 0, + "title": "Task Index", + "type": "integer" + }, + "status": { + "title": "Status", + "type": "string" + }, + "goal": { + "default": "", + "title": "Goal", + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "dispatched_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dispatched At" + }, + "completed_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completed At" + } + }, + "required": [ + "delegation_id", + "status" + ], + "title": "FailedDelegation", + "type": "object" + }, + "FileAttachParams": { + "additionalProperties": false, + "description": "``path`` when the file is gateway-visible, else ``data_url`` carries the bytes; ``name`` labels\nan uploaded file.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "data_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Url" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "required": [ + "session_id" + ], + "title": "FileAttachParams", + "type": "object" + }, + "FileAttachResult": { + "additionalProperties": false, + "properties": { + "attached": { + "title": "Attached", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "ref_path": { + "title": "Ref Path", + "type": "string" + }, + "ref_text": { + "title": "Ref Text", + "type": "string" + }, + "uploaded": { + "title": "Uploaded", + "type": "boolean" + } + }, + "required": [ + "attached", + "name", + "path", + "ref_path", + "ref_text", + "uploaded" + ], + "title": "FileAttachResult", + "type": "object" + }, + "ForeignSessionRow": { + "additionalProperties": false, + "description": "``hermes_cli/foreign_sessions_browser.py::list_foreign_sessions`` \u2014 ``id`` is an opaque\nhandle, never a path.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/ForeignSource" + }, + "label": { + "title": "Label", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "mtime": { + "title": "Mtime", + "type": "number" + }, + "turn_count": { + "default": 0, + "title": "Turn Count", + "type": "integer" + }, + "excerpt": { + "default": "", + "title": "Excerpt", + "type": "string" + } + }, + "required": [ + "id", + "source", + "label", + "mtime" + ], + "title": "ForeignSessionRow", + "type": "object" + }, + "ForeignSource": { + "enum": [ + "claude", + "codex" + ], + "title": "ForeignSource", + "type": "string" + }, + "ForeignTurn": { + "additionalProperties": false, + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + } + }, + "required": [ + "role", + "content" + ], + "title": "ForeignTurn", + "type": "object" + }, + "FreeTierAckNoticeResult": { + "additionalProperties": false, + "properties": { + "acked": { + "title": "Acked", + "type": "boolean" + } + }, + "required": [ + "acked" + ], + "title": "FreeTierAckNoticeResult", + "type": "object" + }, + "FreeTierProvisionResult": { + "additionalProperties": false, + "properties": { + "has_guest": { + "title": "Has Guest", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "required": [ + "has_guest", + "enabled" + ], + "title": "FreeTierProvisionResult", + "type": "object" + }, + "FreeTierStatusResult": { + "additionalProperties": false, + "description": "``available`` = an identity exists AND the tier is on; whether inference runs on it is\n``setup.runtime_check.free_tier_route``'s question.", + "properties": { + "has_guest": { + "title": "Has Guest", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "notice_pending": { + "title": "Notice Pending", + "type": "boolean" + }, + "model": { + "title": "Model", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "required": [ + "has_guest", + "enabled", + "available", + "notice_pending", + "model", + "label" + ], + "title": "FreeTierStatusResult", + "type": "object" + }, + "GatewayCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "per_session_exclusive_submit": { + "title": "Per Session Exclusive Submit", + "type": "boolean" + } + }, + "required": [ + "per_session_exclusive_submit" + ], + "title": "GatewayCapabilitiesResult", + "type": "object" + }, + "GatewayReadyPayload": { + "additionalProperties": false, + "description": "``tui_gateway/entry.py`` (stdio) / ``tui_gateway/ws.py`` (WebSocket) first frame.", + "properties": { + "skin": { + "$ref": "#/components/schemas/SkinPayload" + }, + "change_events": { + "title": "Change Events", + "type": "boolean" + }, + "replay_epoch": { + "title": "Replay Epoch", + "type": "string" + }, + "heartbeat": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heartbeat" + } + }, + "required": [ + "skin", + "change_events", + "replay_epoch" + ], + "title": "GatewayReadyPayload", + "type": "object" + }, + "GoalContractSnapshot": { + "additionalProperties": false, + "description": "``hermes_cli/goals.py::GoalContract.to_dict``.", + "properties": { + "outcome": { + "default": "", + "title": "Outcome", + "type": "string" + }, + "verification": { + "default": "", + "title": "Verification", + "type": "string" + }, + "constraints": { + "default": "", + "title": "Constraints", + "type": "string" + }, + "boundaries": { + "default": "", + "title": "Boundaries", + "type": "string" + }, + "stop_when": { + "default": "", + "title": "Stop When", + "type": "string" + } + }, + "title": "GoalContractSnapshot", + "type": "object" + }, + "GoalGateSnapshot": { + "additionalProperties": false, + "properties": { + "command": { + "title": "Command", + "type": "string" + }, + "timeout_seconds": { + "title": "Timeout Seconds", + "type": "integer" + }, + "max_retries": { + "title": "Max Retries", + "type": "integer" + }, + "attempts": { + "title": "Attempts", + "type": "integer" + }, + "last_exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Exit Code" + } + }, + "required": [ + "command", + "timeout_seconds", + "max_retries", + "attempts" + ], + "title": "GoalGateSnapshot", + "type": "object" + }, + "GoalSnapshot": { + "additionalProperties": false, + "description": "``methods_session_control.py::_safe_goal_snapshot`` \u2014 the frontend-safe GoalState subset.", + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "turns_used": { + "title": "Turns Used", + "type": "integer" + }, + "max_turns": { + "title": "Max Turns", + "type": "integer" + }, + "contract": { + "$ref": "#/components/schemas/GoalContractSnapshot" + }, + "subgoals": { + "items": { + "type": "string" + }, + "title": "Subgoals", + "type": "array" + }, + "gates": { + "items": { + "$ref": "#/components/schemas/GoalGateSnapshot" + }, + "title": "Gates", + "type": "array" + }, + "created_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Updated At" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "last_verdict": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Verdict" + }, + "last_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Reason" + }, + "wait_barrier": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "pid": "#/components/schemas/WaitBarrierTarget", + "session": "#/components/schemas/WaitBarrierTarget", + "until": "#/components/schemas/WaitBarrierUntil" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/WaitBarrierUntil" + }, + { + "$ref": "#/components/schemas/WaitBarrierTarget" + } + ] + }, + { + "type": "null" + } + ], + "default": null, + "title": "Wait Barrier" + } + }, + "required": [ + "title", + "status", + "turns_used", + "max_turns", + "contract", + "subgoals", + "gates" + ], + "title": "GoalSnapshot", + "type": "object" + }, + "GroupsApproveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + }, + "execution_generation": { + "title": "Execution Generation", + "type": "integer" + }, + "choice": { + "$ref": "#/components/schemas/ApprovalChoice" + }, + "request_id": { + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "room_id", + "member_id", + "task_id", + "execution_generation", + "choice", + "request_id" + ], + "title": "GroupsApproveParams", + "type": "object" + }, + "GroupsApproveResult": { + "additionalProperties": false, + "description": "``result`` is the local ``approval.respond`` answer or the peer's run-action receipt.", + "properties": { + "approved": { + "default": true, + "title": "Approved", + "type": "boolean" + }, + "result": { + "additionalProperties": true, + "title": "Result", + "type": "object" + } + }, + "required": [ + "result" + ], + "title": "GroupsApproveResult", + "type": "object" + }, + "GroupsCapabilitiesParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "GroupsCapabilitiesParams", + "type": "object" + }, + "GroupsCapabilitiesResult": { + "additionalProperties": false, + "properties": { + "protocol_version": { + "title": "Protocol Version", + "type": "integer" + }, + "driver": { + "title": "Driver", + "type": "boolean" + }, + "persistent_process": { + "title": "Persistent Process", + "type": "boolean" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "room_link": { + "$ref": "#/components/schemas/RoomLinkStatus" + }, + "features": { + "items": { + "type": "string" + }, + "title": "Features", + "type": "array" + }, + "methods": { + "items": { + "type": "string" + }, + "title": "Methods", + "type": "array" + }, + "max_log_limit": { + "title": "Max Log Limit", + "type": "integer" + } + }, + "required": [ + "protocol_version", + "driver", + "persistent_process", + "authority_gateway_id", + "room_link", + "features", + "methods", + "max_log_limit" + ], + "title": "GroupsCapabilitiesResult", + "type": "object" + }, + "GroupsCreateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMemberInput" + }, + "title": "Members", + "type": "array" + }, + "authority_gateway_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Gateway Id" + } + }, + "required": [ + "room_id", + "name", + "members" + ], + "title": "GroupsCreateParams", + "type": "object" + }, + "GroupsCreateResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + } + }, + "required": [ + "room" + ], + "title": "GroupsCreateResult", + "type": "object" + }, + "GroupsDemoteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "observed_gateway_id": { + "title": "Observed Gateway Id", + "type": "string" + }, + "observed_epoch": { + "title": "Observed Epoch", + "type": "integer" + } + }, + "required": [ + "room_id", + "observed_gateway_id", + "observed_epoch" + ], + "title": "GroupsDemoteParams", + "type": "object" + }, + "GroupsDemoteResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "idempotent": { + "title": "Idempotent", + "type": "boolean" + } + }, + "required": [ + "room_id", + "authority_gateway_id", + "authority_epoch", + "idempotent" + ], + "title": "GroupsDemoteResult", + "type": "object" + }, + "GroupsDisbandParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "cancel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancel Id" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsDisbandParams", + "type": "object" + }, + "GroupsDisbandResult": { + "additionalProperties": false, + "properties": { + "tombstone": { + "$ref": "#/components/schemas/RoomTombstone" + } + }, + "required": [ + "tombstone" + ], + "title": "GroupsDisbandResult", + "type": "object" + }, + "GroupsListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Offset" + } + }, + "title": "GroupsListParams", + "type": "object" + }, + "GroupsListResult": { + "additionalProperties": false, + "properties": { + "rooms": { + "items": { + "$ref": "#/components/schemas/Room" + }, + "title": "Rooms", + "type": "array" + }, + "next_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Offset" + } + }, + "required": [ + "rooms" + ], + "title": "GroupsListResult", + "type": "object" + }, + "GroupsLogParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "since_seq": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Since Seq" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsLogParams", + "type": "object" + }, + "GroupsLogResult": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::read_events`` page \u2014 also the ``page`` ``groups.replicate`` ingests.", + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/RoomEvent" + }, + "title": "Events", + "type": "array" + }, + "cursor": { + "title": "Cursor", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + } + }, + "required": [ + "events", + "cursor", + "latest_seq", + "has_more", + "authority" + ], + "title": "GroupsLogResult", + "type": "object" + }, + "GroupsPeerInviteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Room Id" + }, + "home_install_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Home Install Id" + }, + "authority_gateway_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Gateway Id" + }, + "authority_epoch": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Epoch" + }, + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "grant_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Grant Id" + }, + "ttl_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ttl Seconds" + } + }, + "title": "GroupsPeerInviteParams", + "type": "object" + }, + "GroupsPeerInviteResult": { + "additionalProperties": false, + "properties": { + "grant": { + "title": "Grant", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "catalog": { + "$ref": "#/components/schemas/RoomLinkCatalog" + }, + "endpoint": { + "$ref": "#/components/schemas/RoomLinkEndpoint" + } + }, + "required": [ + "grant", + "target_profile", + "catalog", + "endpoint" + ], + "title": "GroupsPeerInviteResult", + "type": "object" + }, + "GroupsPeerRegisterParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "target_url": { + "title": "Target Url", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "grant": { + "title": "Grant", + "type": "string" + }, + "catalog": { + "additionalProperties": true, + "title": "Catalog", + "type": "object" + }, + "cancellation_scope_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancellation Scope Id" + }, + "trace_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trace Id" + } + }, + "required": [ + "room_id", + "member_id", + "target_url", + "target_profile", + "grant", + "catalog" + ], + "title": "GroupsPeerRegisterParams", + "type": "object" + }, + "GroupsPeerRegisterResult": { + "additionalProperties": false, + "properties": { + "registered": { + "default": true, + "title": "Registered", + "type": "boolean" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "transport_security": { + "title": "Transport Security", + "type": "string" + }, + "target_install_id": { + "title": "Target Install Id", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + } + }, + "required": [ + "mode", + "transport_security", + "target_install_id", + "target_profile" + ], + "title": "GroupsPeerRegisterResult", + "type": "object" + }, + "GroupsPeerRevokeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "grant": { + "title": "Grant", + "type": "string" + } + }, + "required": [ + "grant" + ], + "title": "GroupsPeerRevokeParams", + "type": "object" + }, + "GroupsPeerRevokeResult": { + "additionalProperties": false, + "properties": { + "revoked": { + "default": true, + "title": "Revoked", + "type": "boolean" + } + }, + "title": "GroupsPeerRevokeResult", + "type": "object" + }, + "GroupsPromoteParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "confirm": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsPromoteParams", + "type": "object" + }, + "GroupsPromoteResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "previous_gateway_id": { + "title": "Previous Gateway Id", + "type": "string" + }, + "previous_epoch": { + "title": "Previous Epoch", + "type": "integer" + }, + "claim_seq": { + "title": "Claim Seq", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + } + }, + "required": [ + "room_id", + "authority_gateway_id", + "authority_epoch", + "previous_gateway_id", + "previous_epoch", + "claim_seq", + "latest_seq" + ], + "title": "GroupsPromoteResult", + "type": "object" + }, + "GroupsRenameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "room_id", + "event_id", + "name" + ], + "title": "GroupsRenameParams", + "type": "object" + }, + "GroupsRenameResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + } + }, + "required": [ + "room" + ], + "title": "GroupsRenameResult", + "type": "object" + }, + "GroupsReplicaStateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsReplicaStateParams", + "type": "object" + }, + "GroupsReplicaStateResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMember" + }, + "title": "Members", + "type": "array" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + }, + "last_seq": { + "title": "Last Seq", + "type": "integer" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "event_bytes": { + "title": "Event Bytes", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + } + }, + "required": [ + "room_id", + "name", + "members", + "authority", + "last_seq", + "latest_seq", + "event_bytes", + "created_at", + "updated_at" + ], + "title": "GroupsReplicaStateResult", + "type": "object" + }, + "GroupsReplicateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "room_name": { + "title": "Room Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMemberInput" + }, + "title": "Members", + "type": "array" + }, + "page": { + "additionalProperties": true, + "title": "Page", + "type": "object" + } + }, + "required": [ + "room_id", + "room_name", + "members", + "page" + ], + "title": "GroupsReplicateParams", + "type": "object" + }, + "GroupsReplicateResult": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "stored_seq": { + "title": "Stored Seq", + "type": "integer" + }, + "ingested": { + "title": "Ingested", + "type": "integer" + }, + "authority": { + "$ref": "#/components/schemas/RoomAuthority" + }, + "caught_up": { + "title": "Caught Up", + "type": "boolean" + } + }, + "required": [ + "room_id", + "stored_seq", + "ingested", + "authority", + "caught_up" + ], + "title": "GroupsReplicateResult", + "type": "object" + }, + "GroupsRetryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + } + }, + "required": [ + "room_id", + "task_id" + ], + "title": "GroupsRetryParams", + "type": "object" + }, + "GroupsRetryResult": { + "additionalProperties": false, + "properties": { + "retried": { + "default": true, + "title": "Retried", + "type": "boolean" + }, + "task": { + "$ref": "#/components/schemas/RoomTaskReceipt" + } + }, + "required": [ + "task" + ], + "title": "GroupsRetryResult", + "type": "object" + }, + "GroupsSendParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Event Id" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + } + }, + "required": [ + "room_id", + "payload" + ], + "title": "GroupsSendParams", + "type": "object" + }, + "GroupsSendResult": { + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/components/schemas/RoomEvent" + }, + "client_event_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Event Id" + }, + "accepted": { + "default": true, + "title": "Accepted", + "type": "boolean" + }, + "driver_started": { + "default": true, + "title": "Driver Started", + "type": "boolean" + } + }, + "required": [ + "event" + ], + "title": "GroupsSendResult", + "type": "object" + }, + "GroupsStateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "include_disbanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Disbanded" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsStateParams", + "type": "object" + }, + "GroupsStateResult": { + "additionalProperties": false, + "properties": { + "room": { + "$ref": "#/components/schemas/Room" + }, + "driver_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomDriverStatus" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room" + ], + "title": "GroupsStateResult", + "type": "object" + }, + "GroupsStopParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "room_id": { + "title": "Room Id", + "type": "string" + }, + "cancel_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cancel Id" + } + }, + "required": [ + "room_id" + ], + "title": "GroupsStopParams", + "type": "object" + }, + "GroupsStopResult": { + "additionalProperties": false, + "properties": { + "cancelled": { + "title": "Cancelled", + "type": "integer" + } + }, + "required": [ + "cancelled" + ], + "title": "GroupsStopResult", + "type": "object" + }, + "HandoffFailParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "session_id" + ], + "title": "HandoffFailParams", + "type": "object" + }, + "HandoffFailResult": { + "additionalProperties": false, + "description": "``failed`` false when the watcher already claimed the row; ``state`` is what it is now.", + "properties": { + "failed": { + "title": "Failed", + "type": "boolean" + }, + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "failed", + "state" + ], + "title": "HandoffFailResult", + "type": "object" + }, + "HandoffRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "platform": { + "title": "Platform", + "type": "string" + } + }, + "required": [ + "session_id", + "platform" + ], + "title": "HandoffRequestParams", + "type": "object" + }, + "HandoffRequestResult": { + "additionalProperties": false, + "properties": { + "queued": { + "title": "Queued", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "home_name": { + "title": "Home Name", + "type": "string" + } + }, + "required": [ + "queued", + "session_key", + "platform", + "home_name" + ], + "title": "HandoffRequestResult", + "type": "object" + }, + "HandoffStateResult": { + "additionalProperties": false, + "description": "``state`` is pending | running | completed | failed, or '' when nothing was requested.", + "properties": { + "state": { + "title": "State", + "type": "string" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "error": { + "title": "Error", + "type": "string" + } + }, + "required": [ + "state", + "platform", + "error" + ], + "title": "HandoffStateResult", + "type": "object" + }, + "HeartbeatSnapshot": { + "additionalProperties": false, + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "interval_seconds": { + "title": "Interval Seconds", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "last_fired_at": { + "title": "Last Fired At", + "type": "number" + }, + "fire_count": { + "title": "Fire Count", + "type": "integer" + } + }, + "required": [ + "prompt", + "status", + "interval_seconds", + "created_at", + "last_fired_at", + "fire_count" + ], + "title": "HeartbeatSnapshot", + "type": "object" + }, + "I18nCatalogParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "lang": { + "title": "Lang", + "type": "string" + }, + "surface": { + "$ref": "#/components/schemas/LocaleSurface", + "default": "core" + } + }, + "required": [ + "lang" + ], + "title": "I18nCatalogParams", + "type": "object" + }, + "I18nCatalogResult": { + "additionalProperties": false, + "description": "``messages`` is ONLY the pack + user-overlay layer for that surface (flat dotted keys); the\nclient merges it over its bundled ``en``/````. ``lang`` is the canonical id the request\nresolved to (``pt-BR`` \u2192 ``pt-br``; an unknown id resolves to ``en`` with an empty layer).", + "properties": { + "lang": { + "title": "Lang", + "type": "string" + }, + "surface": { + "$ref": "#/components/schemas/LocaleSurface" + }, + "messages": { + "additionalProperties": { + "type": "string" + }, + "title": "Messages", + "type": "object" + } + }, + "required": [ + "lang", + "surface", + "messages" + ], + "title": "I18nCatalogResult", + "type": "object" + }, + "I18nLanguagesResult": { + "additionalProperties": false, + "properties": { + "languages": { + "items": { + "$ref": "#/components/schemas/LanguageOption" + }, + "title": "Languages", + "type": "array" + } + }, + "required": [ + "languages" + ], + "title": "I18nLanguagesResult", + "type": "object" + }, + "ImageAttachBytesParams": { + "additionalProperties": false, + "description": "``content_base64`` (or the ``data`` alias) carries the bytes; ``filename`` / ``ext`` only hint\nthe extension \u2014 magic bytes decide.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "content_base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Base64" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Filename" + }, + "ext": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ext" + } + }, + "required": [ + "session_id" + ], + "title": "ImageAttachBytesParams", + "type": "object" + }, + "ImageAttachParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "session_id", + "path" + ], + "title": "ImageAttachParams", + "type": "object" + }, + "ImageDetachParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "session_id", + "path" + ], + "title": "ImageDetachParams", + "type": "object" + }, + "ImageDetachResult": { + "additionalProperties": false, + "properties": { + "detached": { + "title": "Detached", + "type": "boolean" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "detached", + "count" + ], + "title": "ImageDetachResult", + "type": "object" + }, + "ImageGenerateParams": { + "additionalProperties": false, + "properties": { + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "aspect_ratio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aspect Ratio" + }, + "probe": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Probe" + }, + "max_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Bytes" + } + }, + "title": "ImageGenerateParams", + "type": "object" + }, + "ImageGenerateResult": { + "additionalProperties": false, + "description": "``probe`` answers ``{available}`` alone; ``image_data`` (data URL) is omitted when the\ndownload failed or exceeded ``max_bytes`` so callers fall back to ``image``.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Success" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image" + }, + "image_data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Image Data" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "available" + ], + "title": "ImageGenerateResult", + "type": "object" + }, + "InflightTurn": { + "additionalProperties": false, + "description": "``session_auto_continue._inflight_snapshot``: the live (or retained failed) turn a reconnecting\nclient rebuilds its bubbles from.", + "properties": { + "assistant": { + "default": "", + "title": "Assistant", + "type": "string" + }, + "streaming": { + "default": false, + "title": "Streaming", + "type": "boolean" + }, + "user": { + "default": "", + "title": "User", + "type": "string" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "display_metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Metadata" + }, + "corrections": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Corrections" + }, + "correction_offsets": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correction Offsets" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "recoverable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recoverable" + }, + "error_surface": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Surface" + } + }, + "title": "InflightTurn", + "type": "object" + }, + "InputDetectDropParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "session_id" + ], + "title": "InputDetectDropParams", + "type": "object" + }, + "InputDetectDropResult": { + "additionalProperties": false, + "description": "``matched: false`` alone when the text is not a drop; an image drop is queued immediately\n(``is_image`` + ``count``), a file drop only yields the ``text`` to insert.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "matched": { + "title": "Matched", + "type": "boolean" + }, + "is_image": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Image" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "matched" + ], + "title": "InputDetectDropResult", + "type": "object" + }, + "InsightsGetParams": { + "additionalProperties": false, + "properties": { + "days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Days" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "InsightsGetParams", + "type": "object" + }, + "InsightsGetResult": { + "additionalProperties": false, + "properties": { + "days": { + "title": "Days", + "type": "integer" + }, + "sessions": { + "title": "Sessions", + "type": "integer" + }, + "messages": { + "title": "Messages", + "type": "integer" + } + }, + "required": [ + "days", + "sessions", + "messages" + ], + "title": "InsightsGetResult", + "type": "object" + }, + "InterruptStatus": { + "enum": [ + "interrupted", + "not_interrupted" + ], + "title": "InterruptStatus", + "type": "string" + }, + "LanguageOption": { + "additionalProperties": false, + "description": "``agent.i18n_languages.language_options`` row. ``source`` is ``bundled``, ``overlay`` or\n``plugin:`` \u2014 the highest layer that supplies the language.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "endonym": { + "title": "Endonym", + "type": "string" + }, + "rtl": { + "title": "Rtl", + "type": "boolean" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "id", + "endonym", + "rtl", + "source" + ], + "title": "LanguageOption", + "type": "object" + }, + "LayoutApplyPayload": { + "additionalProperties": true, + "description": "``tools/apply_layout_tool.py``.", + "properties": { + "preset": { + "title": "Preset", + "type": "string" + } + }, + "required": [ + "preset" + ], + "title": "LayoutApplyPayload", + "type": "object" + }, + "LearningAxis": { + "additionalProperties": false, + "properties": { + "start": { + "title": "Start", + "type": "string" + }, + "end": { + "title": "End", + "type": "string" + } + }, + "required": [ + "start", + "end" + ], + "title": "LearningAxis", + "type": "object" + }, + "LearningBucketRow": { + "additionalProperties": false, + "properties": { + "index": { + "title": "Index", + "type": "integer" + }, + "label": { + "title": "Label", + "type": "string" + }, + "date": { + "title": "Date", + "type": "string" + }, + "skills": { + "title": "Skills", + "type": "integer" + }, + "memories": { + "title": "Memories", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Category" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/LearningNodeRow" + }, + "title": "Nodes", + "type": "array" + } + }, + "required": [ + "index", + "label", + "date", + "skills", + "memories", + "total", + "nodes" + ], + "title": "LearningBucketRow", + "type": "object" + }, + "LearningDetailResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + } + }, + "required": [ + "ok" + ], + "title": "LearningDetailResult", + "type": "object" + }, + "LearningEditParams": { + "additionalProperties": false, + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + } + }, + "title": "LearningEditParams", + "type": "object" + }, + "LearningFrame": { + "additionalProperties": false, + "description": "``agent.learning_graph_render.render_graph`` projection; ``grid`` rows are lists of\n``[text, styleKey, alpha?, hexOverride?]`` runs.", + "properties": { + "reveal": { + "title": "Reveal", + "type": "number" + }, + "date": { + "title": "Date", + "type": "string" + }, + "visible": { + "title": "Visible", + "type": "integer" + }, + "grid": { + "items": { + "items": {}, + "type": "array" + }, + "title": "Grid", + "type": "array" + }, + "labels": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Labels", + "type": "array" + } + }, + "required": [ + "reveal", + "date", + "visible", + "grid" + ], + "title": "LearningFrame", + "type": "object" + }, + "LearningFramesParams": { + "additionalProperties": false, + "properties": { + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "rows": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rows" + }, + "frames": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frames" + } + }, + "title": "LearningFramesParams", + "type": "object" + }, + "LearningFramesResult": { + "additionalProperties": false, + "properties": { + "frames": { + "items": { + "$ref": "#/components/schemas/LearningFrame" + }, + "title": "Frames", + "type": "array" + }, + "legend": { + "items": { + "$ref": "#/components/schemas/LearningLegendItem" + }, + "title": "Legend", + "type": "array" + }, + "categories": { + "items": { + "$ref": "#/components/schemas/LearningLegendItem" + }, + "title": "Categories", + "type": "array" + }, + "buckets": { + "items": { + "$ref": "#/components/schemas/LearningBucketRow" + }, + "title": "Buckets", + "type": "array" + }, + "summary": { + "items": { + "type": "string" + }, + "title": "Summary", + "type": "array" + }, + "axis": { + "$ref": "#/components/schemas/LearningAxis" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "cols": { + "title": "Cols", + "type": "integer" + }, + "rows": { + "title": "Rows", + "type": "integer" + } + }, + "required": [ + "frames", + "legend", + "categories", + "buckets", + "summary", + "axis", + "count", + "cols", + "rows" + ], + "title": "LearningFramesResult", + "type": "object" + }, + "LearningLegendItem": { + "additionalProperties": false, + "properties": { + "glyph": { + "title": "Glyph", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + } + }, + "required": [ + "glyph", + "label" + ], + "title": "LearningLegendItem", + "type": "object" + }, + "LearningMutationResult": { + "additionalProperties": false, + "description": "``agent.learning_mutations`` \u2014 ``ok: false`` carries the reason in ``message``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "ok" + ], + "title": "LearningMutationResult", + "type": "object" + }, + "LearningNodeParams": { + "additionalProperties": false, + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "LearningNodeParams", + "type": "object" + }, + "LearningNodeRow": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "glyph": { + "title": "Glyph", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "fullLabel": { + "title": "Fulllabel", + "type": "string" + }, + "meta": { + "title": "Meta", + "type": "string" + }, + "body": { + "title": "Body", + "type": "string" + }, + "style": { + "title": "Style", + "type": "string" + } + }, + "required": [ + "id", + "glyph", + "label", + "fullLabel", + "meta", + "body", + "style" + ], + "title": "LearningNodeRow", + "type": "object" + }, + "LeaseHolder": { + "enum": [ + "agent", + "human" + ], + "title": "LeaseHolder", + "type": "string" + }, + "LegacyPluginRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name", + "version", + "enabled" + ], + "title": "LegacyPluginRow", + "type": "object" + }, + "LiveSessionStatus": { + "enum": [ + "idle", + "starting", + "waiting", + "working", + "streaming", + "resuming" + ], + "title": "LiveSessionStatus", + "type": "string" + }, + "LlmOneshotParams": { + "additionalProperties": false, + "description": "Needs a ``template`` or ``instructions`` / ``input``; a live ``session_id`` lends its model.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Template" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Instructions" + }, + "input": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input" + }, + "variables": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Variables" + }, + "task": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Task" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "LlmOneshotParams", + "type": "object" + }, + "LlmOneshotResult": { + "additionalProperties": false, + "properties": { + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "LlmOneshotResult", + "type": "object" + }, + "LocaleSurface": { + "enum": [ + "core", + "tui", + "desktop" + ], + "title": "LocaleSurface", + "type": "string" + }, + "LoopSnapshot": { + "additionalProperties": false, + "description": "``_safe_loop_snapshot`` \u2014 persisted LoopState fields, never its route.", + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "mode": { + "title": "Mode", + "type": "string" + }, + "interval_seconds": { + "title": "Interval Seconds", + "type": "number" + }, + "current_delay": { + "title": "Current Delay", + "type": "number" + }, + "times": { + "title": "Times", + "type": "integer" + }, + "until": { + "title": "Until", + "type": "string" + }, + "max_ticks": { + "title": "Max Ticks", + "type": "integer" + }, + "ticks_fired": { + "title": "Ticks Fired", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "last_fired_at": { + "title": "Last Fired At", + "type": "number" + }, + "next_due_at": { + "title": "Next Due At", + "type": "number" + }, + "awaiting_response": { + "title": "Awaiting Response", + "type": "boolean" + }, + "deferred_by_goal": { + "title": "Deferred By Goal", + "type": "boolean" + }, + "paused_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Paused Reason" + }, + "last_stop_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Stop Reason" + } + }, + "required": [ + "prompt", + "status", + "mode", + "interval_seconds", + "current_delay", + "times", + "until", + "max_ticks", + "ticks_fired", + "created_at", + "last_fired_at", + "next_due_at", + "awaiting_response", + "deferred_by_goal" + ], + "title": "LoopSnapshot", + "type": "object" + }, + "McpCatalogEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "connector_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connector Slug" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "requires": { + "items": { + "type": "string" + }, + "title": "Requires", + "type": "array" + }, + "transport": { + "title": "Transport", + "type": "string" + } + }, + "required": [ + "name", + "description", + "installed", + "enabled", + "requires", + "transport" + ], + "title": "McpCatalogEntry", + "type": "object" + }, + "McpCatalogResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpCatalogEntry" + }, + "title": "Servers", + "type": "array" + } + }, + "required": [ + "servers" + ], + "title": "McpCatalogResult", + "type": "object" + }, + "McpOauthCallbackParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "iss": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Iss" + } + }, + "required": [ + "name", + "session_id" + ], + "title": "McpOauthCallbackParams", + "type": "object" + }, + "McpOauthCallbackResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + } + }, + "required": [ + "ok" + ], + "title": "McpOauthCallbackResult", + "type": "object" + }, + "McpOauthCancelResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + } + }, + "required": [ + "ok" + ], + "title": "McpOauthCancelResult", + "type": "object" + }, + "McpOauthFlowParams": { + "additionalProperties": false, + "description": "``session_id`` is the OAuth flow id returned by ``oauth.start`` (not a gateway session).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "name", + "session_id" + ], + "title": "McpOauthFlowParams", + "type": "object" + }, + "McpOauthPollResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/McpOauthPollStatus" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Message" + }, + "auth_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Url" + }, + "tools": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/McpProbeTool" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + } + }, + "required": [ + "ok", + "status" + ], + "title": "McpOauthPollResult", + "type": "object" + }, + "McpOauthPollStatus": { + "enum": [ + "pending", + "approved", + "error" + ], + "title": "McpOauthPollStatus", + "type": "string" + }, + "McpOauthStartParams": { + "additionalProperties": false, + "description": "With ``client_redirect_uri`` the CLIENT hosts the loopback and relays the code via\n``mcp.servers.oauth.callback``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "client_redirect_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Redirect Uri" + } + }, + "required": [ + "name" + ], + "title": "McpOauthStartParams", + "type": "object" + }, + "McpOauthStartResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "auth_url": { + "title": "Auth Url", + "type": "string" + }, + "flow": { + "title": "Flow", + "type": "string" + } + }, + "required": [ + "ok", + "session_id", + "auth_url", + "flow" + ], + "title": "McpOauthStartResult", + "type": "object" + }, + "McpProbeTool": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "McpProbeTool", + "type": "object" + }, + "McpRuntimeStatus": { + "enum": [ + "connected", + "disabled", + "connecting", + "failed", + "lazy", + "configured" + ], + "title": "McpRuntimeStatus", + "type": "string" + }, + "McpServerEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "transport": { + "default": "stdio", + "title": "Transport", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "McpServerEntry", + "type": "object" + }, + "McpServerNameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "McpServerNameParams", + "type": "object" + }, + "McpServerRuntimeRow": { + "additionalProperties": false, + "description": "Safe projection of ``tools.mcp_tool_discovery.get_mcp_status`` rows.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + }, + "tools": { + "title": "Tools", + "type": "integer" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "disabled": { + "title": "Disabled", + "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/McpRuntimeStatus" + }, + "source": { + "$ref": "#/components/schemas/McpServerSource" + }, + "plugin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin" + } + }, + "required": [ + "name", + "transport", + "tools", + "connected", + "disabled", + "status", + "source" + ], + "title": "McpServerRuntimeRow", + "type": "object" + }, + "McpServerSource": { + "enum": [ + "config", + "plugin" + ], + "title": "McpServerSource", + "type": "string" + }, + "McpServerStatus": { + "additionalProperties": true, + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "title": "McpServerStatus", + "type": "object" + }, + "McpServerSummary": { + "additionalProperties": false, + "description": "``tui_gateway/mcp_rpc_helpers.summarize_server`` \u2014 a server's config without secret values.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "transport": { + "title": "Transport", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "args": { + "items": { + "type": "string" + }, + "title": "Args", + "type": "array" + }, + "env": { + "items": { + "type": "string" + }, + "title": "Env", + "type": "array" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth" + }, + "oauth_tokens_present": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Oauth Tokens Present" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "tools": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + }, + "source": { + "$ref": "#/components/schemas/McpServerSource" + }, + "plugin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin" + } + }, + "required": [ + "name", + "transport", + "args", + "env", + "enabled", + "source" + ], + "title": "McpServerSummary", + "type": "object" + }, + "McpServersAddParams": { + "additionalProperties": false, + "description": "``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/tools); a\n``bearer_token`` is written to the profile's .env, only the header template persists.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "preset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preset" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config" + }, + "bearer_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bearer Token" + } + }, + "required": [ + "name" + ], + "title": "McpServersAddParams", + "type": "object" + }, + "McpServersAddResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "server": { + "$ref": "#/components/schemas/McpServerSummary" + } + }, + "required": [ + "ok", + "name", + "server" + ], + "title": "McpServersAddResult", + "type": "object" + }, + "McpServersListResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpServerSummary" + }, + "title": "Servers", + "type": "array" + } + }, + "required": [ + "servers" + ], + "title": "McpServersListResult", + "type": "object" + }, + "McpServersRemoveResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "removed": { + "title": "Removed", + "type": "boolean" + } + }, + "required": [ + "ok", + "removed" + ], + "title": "McpServersRemoveResult", + "type": "object" + }, + "McpServersSetApiKeyParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + }, + "env_var": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env Var" + } + }, + "required": [ + "name", + "value" + ], + "title": "McpServersSetApiKeyParams", + "type": "object" + }, + "McpServersSetApiKeyResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "env_var": { + "title": "Env Var", + "type": "string" + }, + "server": { + "$ref": "#/components/schemas/McpServerSummary" + } + }, + "required": [ + "ok", + "name", + "env_var", + "server" + ], + "title": "McpServersSetApiKeyResult", + "type": "object" + }, + "McpServersStatusResult": { + "additionalProperties": false, + "properties": { + "servers": { + "items": { + "$ref": "#/components/schemas/McpServerRuntimeRow" + }, + "title": "Servers", + "type": "array" + }, + "checked_at": { + "title": "Checked At", + "type": "integer" + } + }, + "required": [ + "servers", + "checked_at" + ], + "title": "McpServersStatusResult", + "type": "object" + }, + "McpServersTestResult": { + "additionalProperties": false, + "description": "``ok: false`` carries ``error``; ``prompts`` / ``resources`` are only counted on success.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/McpProbeTool" + }, + "title": "Tools", + "type": "array" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "prompts": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompts" + }, + "resources": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resources" + }, + "oauth_needed": { + "title": "Oauth Needed", + "type": "boolean" + }, + "oauth_tokens_present": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Oauth Tokens Present" + } + }, + "required": [ + "ok", + "tools", + "oauth_needed" + ], + "title": "McpServersTestResult", + "type": "object" + }, + "MessageCompletePayload": { + "additionalProperties": false, + "description": "``prompt_turn._complete_turn_payload`` / ``session_auto_continue._emit_terminal_turn_error`` /\n``agent_callbacks._mirror_subagent_to_child`` (child watch mirror: ``text`` only) /\n``compute_host_bridge`` (``text`` + ``status``).", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + {} + ], + "default": "", + "title": "Text" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/TurnStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "reasoning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "response_previewed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Previewed" + }, + "response_transformed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Response Transformed" + }, + "billing": { + "anyOf": [ + { + "$ref": "#/components/schemas/BillingBlock" + }, + { + "type": "null" + } + ], + "default": null + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failure Reason" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "recoverable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recoverable" + }, + "error_surface": { + "anyOf": [ + { + "$ref": "#/components/schemas/ErrorSurface" + }, + { + "type": "null" + } + ], + "default": null + }, + "partial": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Partial" + }, + "persisted_turn": { + "anyOf": [ + { + "$ref": "#/components/schemas/PersistedTurn" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "MessageCompletePayload", + "type": "object" + }, + "MessageInterimPayload": { + "additionalProperties": false, + "description": "``prompt_turn._interim_assistant_cb`` / ``agent_callbacks`` interim_assistant_callback.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "already_streamed": { + "title": "Already Streamed", + "type": "boolean" + } + }, + "required": [ + "text", + "already_streamed" + ], + "title": "MessageInterimPayload", + "type": "object" + }, + "MessageReactParams": { + "additionalProperties": false, + "description": "``row_id`` is ``messages.id``; a not-yet-persisted live message names ``newest_role`` instead.\n``emoji`` null clears; the same emoji again retracts.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Row Id" + }, + "newest_role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Newest Role" + }, + "emoji": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Emoji" + }, + "author": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReactionAuthor" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id" + ], + "title": "MessageReactParams", + "type": "object" + }, + "MessageReactResult": { + "additionalProperties": false, + "properties": { + "row_id": { + "title": "Row Id", + "type": "integer" + }, + "reactions": { + "items": { + "$ref": "#/components/schemas/MessageReaction" + }, + "title": "Reactions", + "type": "array" + } + }, + "required": [ + "row_id", + "reactions" + ], + "title": "MessageReactResult", + "type": "object" + }, + "MessageReaction": { + "additionalProperties": true, + "description": "One persisted reaction row (``hermes_state_messages.set_message_reaction``); ``seen`` is\nstamped once announced.", + "properties": { + "emoji": { + "title": "Emoji", + "type": "string" + }, + "author": { + "title": "Author", + "type": "string" + }, + "at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "At" + }, + "seen": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Seen" + } + }, + "required": [ + "emoji", + "author" + ], + "title": "MessageReaction", + "type": "object" + }, + "MessageReactionPayload": { + "additionalProperties": false, + "description": "``tools/react_to_message_tool.py``.", + "properties": { + "row_id": { + "title": "Row Id", + "type": "integer" + }, + "reactions": { + "items": { + "$ref": "#/components/schemas/MessageReaction" + }, + "title": "Reactions", + "type": "array" + }, + "role": { + "title": "Role", + "type": "string" + } + }, + "required": [ + "row_id", + "reactions", + "role" + ], + "title": "MessageReactionPayload", + "type": "object" + }, + "MoaAggregatingPayload": { + "additionalProperties": false, + "properties": { + "aggregator": { + "title": "Aggregator", + "type": "string" + } + }, + "required": [ + "aggregator" + ], + "title": "MoaAggregatingPayload", + "type": "object" + }, + "MoaPhasePayload": { + "additionalProperties": false, + "properties": { + "phase": { + "title": "Phase", + "type": "string" + }, + "refs_done": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refs Done" + }, + "refs_total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refs Total" + }, + "aggregator": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aggregator" + } + }, + "required": [ + "phase" + ], + "title": "MoaPhasePayload", + "type": "object" + }, + "MoaProgressPayload": { + "additionalProperties": false, + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "refs_done": { + "title": "Refs Done", + "type": "integer" + }, + "refs_total": { + "title": "Refs Total", + "type": "integer" + } + }, + "required": [ + "label", + "refs_done", + "refs_total" + ], + "title": "MoaProgressPayload", + "type": "object" + }, + "MoaReferencePayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_moa_reference``.", + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "label", + "text" + ], + "title": "MoaReferencePayload", + "type": "object" + }, + "ModelCapabilities": { + "additionalProperties": false, + "description": "``hermes_cli/inventory.py::_apply_capabilities``.", + "properties": { + "fast": { + "title": "Fast", + "type": "boolean" + }, + "reasoning": { + "title": "Reasoning", + "type": "boolean" + }, + "can_disable_reasoning": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Disable Reasoning" + } + }, + "required": [ + "fast", + "reasoning" + ], + "title": "ModelCapabilities", + "type": "object" + }, + "ModelDisconnectParams": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "slug" + ], + "title": "ModelDisconnectParams", + "type": "object" + }, + "ModelDisconnectResult": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "disconnected": { + "title": "Disconnected", + "type": "boolean" + } + }, + "required": [ + "slug", + "name", + "disconnected" + ], + "title": "ModelDisconnectResult", + "type": "object" + }, + "ModelOptionProvider": { + "additionalProperties": true, + "description": "One ``hermes_cli/inventory.py::build_models_payload`` provider row (the union of every field\nthe builder sets; ``pricing_pending`` / ``free_tier_pending`` mark the cached-only path).", + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "models": { + "items": { + "type": "string" + }, + "title": "Models", + "type": "array" + }, + "total_models": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Models" + }, + "is_current": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Current" + }, + "is_user_defined": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is User Defined" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "aliases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Aliases" + }, + "api_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Url" + }, + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Type" + }, + "authenticated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authenticated" + }, + "key_env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Env" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "featured_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Featured Models" + }, + "capabilities": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ModelCapabilities" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capabilities" + }, + "pricing": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ModelPricing" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pricing" + }, + "pricing_pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pricing Pending" + }, + "free_tier": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier" + }, + "free_tier_pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Pending" + }, + "free_tier_row": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Row" + }, + "unavailable_models": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unavailable Models" + } + }, + "required": [ + "slug", + "name" + ], + "title": "ModelOptionProvider", + "type": "object" + }, + "ModelOptionsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "explicit_only": { + "default": false, + "title": "Explicit Only", + "type": "boolean" + }, + "include_unconfigured": { + "default": false, + "title": "Include Unconfigured", + "type": "boolean" + }, + "refresh": { + "default": false, + "title": "Refresh", + "type": "boolean" + } + }, + "title": "ModelOptionsParams", + "type": "object" + }, + "ModelOptionsResult": { + "additionalProperties": false, + "properties": { + "providers": { + "items": { + "$ref": "#/components/schemas/ModelOptionProvider" + }, + "title": "Providers", + "type": "array" + }, + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + } + }, + "required": [ + "providers" + ], + "title": "ModelOptionsResult", + "type": "object" + }, + "ModelPricing": { + "additionalProperties": false, + "description": "``hermes_cli/inventory.py::_apply_pricing`` \u2014 formatted $/Mtok strings (``\"\"`` unknown,\n``\"free\"``); the sale fields are Nous Portal-only.", + "properties": { + "input": { + "title": "Input", + "type": "string" + }, + "output": { + "title": "Output", + "type": "string" + }, + "cache": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache" + }, + "free": { + "title": "Free", + "type": "boolean" + }, + "discount_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Discount Percent" + }, + "was_input": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Was Input" + }, + "was_output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Was Output" + } + }, + "required": [ + "input", + "output", + "free" + ], + "title": "ModelPricing", + "type": "object" + }, + "ModelSaveKeyParams": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "api_key": { + "title": "Api Key", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "slug", + "api_key" + ], + "title": "ModelSaveKeyParams", + "type": "object" + }, + "ModelSaveKeyResult": { + "additionalProperties": false, + "properties": { + "provider": { + "$ref": "#/components/schemas/ModelOptionProvider" + } + }, + "required": [ + "provider" + ], + "title": "ModelSaveKeyResult", + "type": "object" + }, + "NoticePayload": { + "additionalProperties": false, + "description": "``tui_gateway/model_switch.py`` capability-refresh notice.", + "properties": { + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "NoticePayload", + "type": "object" + }, + "NotificationClearPayload": { + "additionalProperties": false, + "properties": { + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key" + ], + "title": "NotificationClearPayload", + "type": "object" + }, + "NotificationShowPayload": { + "additionalProperties": false, + "description": "``agent/credits_tracker.py::AgentNotice`` via notice_callback, and ``server._await_agent_ready``'s\nslow-build notice. ``level``: info | warn | error | success; ``kind``: sticky | ttl | agent.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "level": { + "title": "Level", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "ttl_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ttl Ms" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "required": [ + "text", + "level", + "kind" + ], + "title": "NotificationShowPayload", + "type": "object" + }, + "OkResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + } + }, + "title": "OkResult", + "type": "object" + }, + "OnboardingAnswers": { + "additionalProperties": true, + "description": "``tui_gateway/onboarding_personalization.py`` \u2014 the facts agreed during onboarding.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "theme": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Theme" + }, + "accent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accent" + }, + "layout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Layout" + }, + "focus": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Focus" + }, + "connectors": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connectors" + }, + "plugins": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugins" + } + }, + "title": "OnboardingAnswers", + "type": "object" + }, + "OnboardingCatalogPlugin": { + "additionalProperties": false, + "description": "A catalog plugin curated for the onboarding card (``onboarding: true``) that this OS runs.\n``app_state`` is the pinned ``plugin.json`` declaration judged on this host; ``sentence`` names what\nis missing (empty when present or unknown).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tier": { + "$ref": "#/components/schemas/CatalogTier" + }, + "platforms": { + "items": { + "type": "string" + }, + "title": "Platforms", + "type": "array" + }, + "app_state": { + "$ref": "#/components/schemas/CatalogAppState" + }, + "sentence": { + "title": "Sentence", + "type": "string" + } + }, + "required": [ + "name", + "title", + "description", + "tier", + "platforms", + "app_state", + "sentence" + ], + "title": "OnboardingCatalogPlugin", + "type": "object" + }, + "OnboardingEnsureSetupProfileResult": { + "additionalProperties": false, + "description": "``created`` is false when an existing setup profile was found (and returned untouched).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "created": { + "title": "Created", + "type": "boolean" + }, + "role": { + "const": "setup", + "default": "setup", + "title": "Role", + "type": "string" + } + }, + "required": [ + "name", + "path", + "created" + ], + "title": "OnboardingEnsureSetupProfileResult", + "type": "object" + }, + "OnboardingResetSetupProfileResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "reset": { + "default": true, + "title": "Reset", + "type": "boolean" + } + }, + "required": [ + "name", + "path" + ], + "title": "OnboardingResetSetupProfileResult", + "type": "object" + }, + "OpenRequestEntry": { + "additionalProperties": false, + "description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "method": { + "title": "Method", + "type": "string" + }, + "params": { + "additionalProperties": true, + "title": "Params", + "type": "object" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "OpenRequestEntry", + "type": "object" + }, + "OptionalProjectResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "OptionalProjectResult", + "type": "object" + }, + "PaneRevealPayload": { + "additionalProperties": true, + "description": "``tools/focus_pane_tool.py``.", + "properties": { + "pane": { + "title": "Pane", + "type": "string" + } + }, + "required": [ + "pane" + ], + "title": "PaneRevealPayload", + "type": "object" + }, + "Params": { + "additionalProperties": false, + "description": "Client\u2192server method params / server\u2192client request params. Unknown keys are rejected.", + "properties": {}, + "title": "Params", + "type": "object" + }, + "PasteCollapseParams": { + "additionalProperties": false, + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "title": "PasteCollapseParams", + "type": "object" + }, + "PasteCollapseResult": { + "additionalProperties": false, + "properties": { + "placeholder": { + "title": "Placeholder", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "lines": { + "title": "Lines", + "type": "integer" + } + }, + "required": [ + "placeholder", + "path", + "lines" + ], + "title": "PasteCollapseResult", + "type": "object" + }, + "PaymentMethodKind": { + "enum": [ + "card", + "link", + "unknown" + ], + "title": "PaymentMethodKind", + "type": "string" + }, + "PdfAttachParams": { + "additionalProperties": false, + "description": "Host ``path`` or base64 ``content_base64`` / ``data``; ``first_page`` / ``last_page`` bound the\nrender (per-call page cap enforced server-side).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "content_base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Base64" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Filename" + }, + "first_page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Page" + }, + "last_page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Page" + } + }, + "required": [ + "session_id" + ], + "title": "PdfAttachParams", + "type": "object" + }, + "PdfAttachResult": { + "additionalProperties": false, + "properties": { + "attached": { + "title": "Attached", + "type": "boolean" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "pages_attached": { + "title": "Pages Attached", + "type": "integer" + }, + "pages": { + "items": { + "$ref": "#/components/schemas/PdfPage" + }, + "title": "Pages", + "type": "array" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "attached", + "filename", + "pages_attached", + "pages", + "count", + "text" + ], + "title": "PdfAttachResult", + "type": "object" + }, + "PdfPage": { + "additionalProperties": false, + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Estimate" + }, + "path": { + "title": "Path", + "type": "string" + }, + "page": { + "title": "Page", + "type": "integer" + } + }, + "required": [ + "path", + "page" + ], + "title": "PdfPage", + "type": "object" + }, + "PeerRouteStatus": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "room_id", + "member_id", + "status" + ], + "title": "PeerRouteStatus", + "type": "object" + }, + "PendingApproval": { + "additionalProperties": true, + "description": "One unresolved ``tools/approval.py`` gateway queue entry as ``server._approval_request_payload``\nrenders it (command redacted; ``choices`` precomputed). The key set is owned by the approval tool.", + "properties": { + "request_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "pattern_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pattern Key" + }, + "pattern_keys": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pattern Keys" + }, + "allow_permanent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Permanent" + }, + "allow_session": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Session" + }, + "smart_denied": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Smart Denied" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + } + }, + "title": "PendingApproval", + "type": "object" + }, + "PersistedTurn": { + "additionalProperties": false, + "description": "Committed SQLite row addresses for the agent's current-turn suffix. Missing ids are\nunproven, never negative acknowledgements. ``complete`` permits retiring the whole local\nturn only when the original turn boundary, every row and final body are still accounted\nfor; compaction, redirects and partial writes conservatively leave it false. Row ids are\nscoped to the owning profile's store, as in ``SessionMessage.row_id``.", + "properties": { + "row_ids": { + "items": { + "type": "integer" + }, + "title": "Row Ids", + "type": "array" + }, + "complete": { + "title": "Complete", + "type": "boolean" + }, + "user_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Row Id" + }, + "final_assistant_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Assistant Row Id" + } + }, + "required": [ + "row_ids", + "complete" + ], + "title": "PersistedTurn", + "type": "object" + }, + "PetCancelParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token" + } + }, + "title": "PetCancelParams", + "type": "object" + }, + "PetCancelResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "PetCancelResult", + "type": "object" + }, + "PetCellsParams": { + "additionalProperties": false, + "description": "``graphics`` opts into the kitty payload when the TTY speaks it; ``cols`` overrides the width.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "graphics": { + "default": false, + "title": "Graphics", + "type": "boolean" + } + }, + "title": "PetCellsParams", + "type": "object" + }, + "PetCellsResult": { + "additionalProperties": false, + "description": "Unicode: ``frames`` is frame \u2192 row \u2192 cell ``[tr,tg,tb,ta, br,bg,bb,ba]``; kitty (``graphics``\nset): ``frames`` are transmit escapes and ``placeholder`` the text grid.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "frameMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framems" + }, + "frames": { + "anyOf": [ + { + "items": { + "items": { + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "type": "array" + }, + "type": "array" + }, + "type": "array" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frames" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "graphics": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Graphics" + }, + "imageId": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Imageid" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "rows": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rows" + }, + "placeholder": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Placeholder" + } + }, + "required": [ + "enabled" + ], + "title": "PetCellsResult", + "type": "object" + }, + "PetChangedPayload": { + "additionalProperties": true, + "description": "``change_watcher._pet_changed_payload`` \u2014 ``pet.info.meta``-shaped; ``{enabled: false}`` when off.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + } + }, + "required": [ + "enabled" + ], + "title": "PetChangedPayload", + "type": "object" + }, + "PetDraft": { + "additionalProperties": false, + "properties": { + "index": { + "title": "Index", + "type": "integer" + }, + "dataUri": { + "title": "Datauri", + "type": "string" + } + }, + "required": [ + "index", + "dataUri" + ], + "title": "PetDraft", + "type": "object" + }, + "PetExportResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "zipBase64": { + "title": "Zipbase64", + "type": "string" + } + }, + "required": [ + "ok", + "filename", + "zipBase64" + ], + "title": "PetExportResult", + "type": "object" + }, + "PetGalleryEntry": { + "additionalProperties": false, + "properties": { + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "spritesheetUrl": { + "default": "", + "title": "Spritesheeturl", + "type": "string" + }, + "curated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Curated" + }, + "generated": { + "default": false, + "title": "Generated", + "type": "boolean" + } + }, + "required": [ + "slug", + "displayName", + "installed" + ], + "title": "PetGalleryEntry", + "type": "object" + }, + "PetGalleryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "localOnly": { + "default": false, + "title": "Localonly", + "type": "boolean" + } + }, + "title": "PetGalleryParams", + "type": "object" + }, + "PetGalleryResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "active": { + "default": "", + "title": "Active", + "type": "string" + }, + "pets": { + "items": { + "$ref": "#/components/schemas/PetGalleryEntry" + }, + "title": "Pets", + "type": "array" + } + }, + "required": [ + "enabled" + ], + "title": "PetGalleryResult", + "type": "object" + }, + "PetGenProvider": { + "additionalProperties": false, + "description": "``agent/pet/generate/imagegen.py::list_sprite_providers`` row.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "default": { + "type": "boolean", + "title": "Default" + } + }, + "required": [ + "name", + "label", + "default" + ], + "title": "PetGenProvider", + "type": "object" + }, + "PetGenerateParams": { + "additionalProperties": false, + "description": "``prompt`` or a ``referenceImage`` data URL is required (the handler answers 4004 without one).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "referenceImage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Referenceimage" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "title": "PetGenerateParams", + "type": "object" + }, + "PetGenerateProgressPayload": { + "additionalProperties": true, + "description": "``methods_session`` pet.generate: token-only init frame, then one per draft.", + "properties": { + "token": { + "title": "Token", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "dataUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Datauri" + } + }, + "required": [ + "token", + "count" + ], + "title": "PetGenerateProgressPayload", + "type": "object" + }, + "PetGenerateResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "token": { + "title": "Token", + "type": "string" + }, + "drafts": { + "items": { + "$ref": "#/components/schemas/PetDraft" + }, + "title": "Drafts", + "type": "array" + } + }, + "required": [ + "ok", + "token", + "drafts" + ], + "title": "PetGenerateResult", + "type": "object" + }, + "PetGenerateStatusResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "providers": { + "items": { + "$ref": "#/components/schemas/PetGenProvider" + }, + "title": "Providers", + "type": "array" + } + }, + "required": [ + "available", + "providers" + ], + "title": "PetGenerateStatusResult", + "type": "object" + }, + "PetHatchParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "token": { + "title": "Token", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "cancelToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canceltoken" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Prompt" + }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Style" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "required": [ + "token", + "name" + ], + "title": "PetHatchParams", + "type": "object" + }, + "PetHatchProgressPayload": { + "additionalProperties": true, + "description": "``methods_session`` pet.hatch ``_on_progress``: ``{event, detail}`` or the parsed row form.", + "properties": { + "event": { + "title": "Event", + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detail" + }, + "state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "State" + }, + "done": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Done" + }, + "total": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total" + } + }, + "required": [ + "event" + ], + "title": "PetHatchProgressPayload", + "type": "object" + }, + "PetHatchResult": { + "additionalProperties": false, + "description": "The hatched pet is installed but NOT active (``pet.select`` adopts, ``pet.remove`` discards).", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "warnings": { + "items": {}, + "title": "Warnings", + "type": "array" + }, + "pet": { + "$ref": "#/components/schemas/PetSpritePayload" + } + }, + "required": [ + "ok", + "slug", + "displayName", + "pet" + ], + "title": "PetHatchResult", + "type": "object" + }, + "PetInfoMetaResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + } + }, + "required": [ + "enabled" + ], + "title": "PetInfoMetaResult", + "type": "object" + }, + "PetInfoParams": { + "additionalProperties": false, + "description": "``knownRevision``: the spritesheet revision the caller already holds (send-once bytes).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "knownRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Knownrevision" + } + }, + "title": "PetInfoParams", + "type": "object" + }, + "PetInfoResult": { + "additionalProperties": true, + "description": "``server._pet_sprite_payload`` behind ``enabled``; every sprite field is absent when the pet\ndisplay is off, ``spritesheetBase64`` is elided when ``spritesheetUnchanged``.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "spritesheetBase64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetbase64" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + }, + "spritesheetUnchanged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetunchanged" + }, + "frameW": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framew" + }, + "frameH": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frameh" + }, + "framesPerState": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesperstate" + }, + "framesByState": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbystate" + }, + "framesByRow": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbyrow" + }, + "loopMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loopms" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "stateRows": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Staterows" + } + }, + "required": [ + "enabled" + ], + "title": "PetInfoResult", + "type": "object" + }, + "PetRenameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "slug", + "name" + ], + "title": "PetRenameParams", + "type": "object" + }, + "PetScaleParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "scale": { + "default": null, + "title": "Scale" + } + }, + "title": "PetScaleParams", + "type": "object" + }, + "PetScaleResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "scale": { + "title": "Scale", + "type": "number" + } + }, + "required": [ + "ok", + "scale" + ], + "title": "PetScaleResult", + "type": "object" + }, + "PetSlugParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + } + }, + "required": [ + "slug" + ], + "title": "PetSlugParams", + "type": "object" + }, + "PetSlugResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + } + }, + "required": [ + "ok", + "slug" + ], + "title": "PetSlugResult", + "type": "object" + }, + "PetSpritePayload": { + "additionalProperties": false, + "properties": { + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Displayname" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "spritesheetBase64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetbase64" + }, + "spritesheetRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Spritesheetrevision" + }, + "frameW": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framew" + }, + "frameH": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frameh" + }, + "framesPerState": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesperstate" + }, + "framesByState": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbystate" + }, + "framesByRow": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Framesbyrow" + }, + "loopMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loopms" + }, + "scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scale" + }, + "stateRows": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Staterows" + } + }, + "title": "PetSpritePayload", + "type": "object" + }, + "PetThumbParams": { + "additionalProperties": false, + "description": "``url``: spritesheet source for a not-yet-installed pet.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + } + }, + "required": [ + "slug" + ], + "title": "PetThumbParams", + "type": "object" + }, + "PetThumbResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "dataUri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Datauri" + } + }, + "required": [ + "ok", + "slug" + ], + "title": "PetThumbResult", + "type": "object" + }, + "PingParams": { + "additionalProperties": false, + "properties": {}, + "title": "PingParams", + "type": "object" + }, + "PingResult": { + "additionalProperties": false, + "properties": { + "pong": { + "title": "Pong", + "type": "boolean" + } + }, + "required": [ + "pong" + ], + "title": "PingResult", + "type": "object" + }, + "PluginActivation": { + "additionalProperties": false, + "description": "What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now``\nkinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``locales`` (``.``\nlanguage-pack layers), ``gateway_transforms`` / ``hooks`` (hook\nnames), ``callbacks`` (platforms / ``slack:``) \u2014 live in the running gateway once it reloaded\n(``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the\nerror) and skills, usable in every open chat of the profile from its next turn \u2014 the chats also get a\nnote listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids)\napply from the next session.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "activated_now": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Activated Now", + "type": "object" + }, + "live_now": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginLiveNow" + }, + { + "type": "null" + } + ], + "default": null + }, + "deferred": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Deferred", + "type": "object" + } + }, + "required": [ + "name", + "key" + ], + "title": "PluginActivation", + "type": "object" + }, + "PluginLiveNow": { + "additionalProperties": false, + "properties": { + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/PluginLiveServer" + }, + "title": "Mcp Servers", + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/components/schemas/PluginLiveSkill" + }, + "title": "Skills", + "type": "array" + } + }, + "title": "PluginLiveNow", + "type": "object" + }, + "PluginLiveServer": { + "additionalProperties": false, + "description": "One plugin MCP server connected at activation: its callable tool names, or the reason it did not connect.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "tools": { + "items": { + "type": "string" + }, + "title": "Tools", + "type": "array" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "name", + "connected" + ], + "title": "PluginLiveServer", + "type": "object" + }, + "PluginLiveSkill": { + "additionalProperties": false, + "description": "One plugin skill usable now through ``skill_view`` (qualified ``:``).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "PluginLiveSkill", + "type": "object" + }, + "PluginServerRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/PluginServerState" + }, + "sentence": { + "title": "Sentence", + "type": "string" + } + }, + "required": [ + "name", + "state", + "sentence" + ], + "title": "PluginServerRow", + "type": "object" + }, + "PluginServerState": { + "enum": [ + "connected", + "app_not_running", + "hermes_not_connected", + "endpoint_unavailable", + "no_interactive_session", + "version_too_old", + "missing_app", + "unknown" + ], + "title": "PluginServerState", + "type": "string" + }, + "PluginSettingField": { + "additionalProperties": false, + "description": "One ``config_schema`` key of a plugin manifest, rendered by the Plugins hub\n(``hermes_cli.plugins_settings.plugin_settings_fields``). ``secret`` fields carry no value: ``env``\nnames the ``.env`` variable and ``has_value`` whether it is set.", + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/PluginSettingFieldType" + }, + "label": { + "title": "Label", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Default" + }, + "choices": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Choices" + }, + "env": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + }, + "has_value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Value" + } + }, + "required": [ + "key", + "type", + "label", + "description", + "required" + ], + "title": "PluginSettingField", + "type": "object" + }, + "PluginSettingFieldType": { + "enum": [ + "string", + "number", + "boolean", + "enum", + "secret", + "json" + ], + "title": "PluginSettingFieldType", + "type": "string" + }, + "PluginsAction": { + "enum": [ + "list", + "toggle", + "install", + "update", + "remove", + "settings", + "onboarding" + ], + "title": "PluginsAction", + "type": "string" + }, + "PluginsListParams": { + "additionalProperties": false, + "properties": {}, + "title": "PluginsListParams", + "type": "object" + }, + "PluginsListResult": { + "additionalProperties": false, + "properties": { + "plugins": { + "items": { + "$ref": "#/components/schemas/LegacyPluginRow" + }, + "title": "Plugins", + "type": "array" + } + }, + "required": [ + "plugins" + ], + "title": "PluginsListResult", + "type": "object" + }, + "PluginsManageParams": { + "additionalProperties": false, + "description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "action": { + "$ref": "#/components/schemas/PluginsAction", + "default": "list" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "enable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enable" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "repo": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo" + }, + "catalog_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Name" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Force" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "accept_capabilities": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accept Capabilities" + }, + "values": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Values" + } + }, + "title": "PluginsManageParams", + "type": "object" + }, + "PluginsManageResult": { + "additionalProperties": false, + "description": "``list`` \u2192 ``plugins`` + counts; ``toggle`` \u2192 ``ok``/``unchanged``/``restart_required``/``name``\n(the canonical key written)/``plugin``; ``install`` \u2192 ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s\nok payload; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the\nrunning gateway picked it up and re-wired its handlers) and ``activation`` \u2014 the honest split of what is\nlive now vs deferred, so ``restart_required`` is True only when no gateway answered; ``update`` \u2192 ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the\n``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds \u2014 nothing changed until the\nclient retries with ``accept_capabilities``; ``remove`` \u2192 ``ok``/``name`` plus\n``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``.", + "properties": { + "plugins": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AgentPluginRow" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugins" + }, + "user_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Count" + }, + "bundled_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bundled Count" + }, + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "unchanged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unchanged" + }, + "restart_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restart Required" + }, + "gateway_reloaded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gateway Reloaded" + }, + "activation": { + "anyOf": [ + { + "$ref": "#/components/schemas/PluginActivation" + }, + { + "type": "null" + } + ], + "default": null + }, + "cleared_memory_provider": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cleared Memory Provider" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "plugin": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentPluginRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "plugin_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugin Name" + }, + "warnings": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warnings" + }, + "missing_env": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Missing Env" + }, + "python_dependencies": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Python Dependencies" + }, + "known_issues": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Known Issues" + }, + "after_install_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Install Path" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "sha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sha" + }, + "consent_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Consent Required" + }, + "delta": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delta" + }, + "delta_lines": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delta Lines" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "written": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Written" + }, + "onboarding": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OnboardingCatalogPlugin" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Onboarding" + } + }, + "title": "PluginsManageResult", + "type": "object" + }, + "PreviewActRequestParams": { + "additionalProperties": false, + "description": "``tools/drive_preview_tool.py`` and ``tools/annotate_preview_tool.py`` field sets.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref" + }, + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key" + }, + "submit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Submit" + }, + "full": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Full" + }, + "to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "To" + }, + "amount": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount" + }, + "max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max" + }, + "allow_shortcut": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Allow Shortcut" + } + }, + "required": [ + "session_id", + "action" + ], + "title": "PreviewActRequestParams", + "type": "object" + }, + "PreviewClosePayload": { + "additionalProperties": false, + "description": "``tools/preview_tool.py`` / ``tools/close_preview_tool.py``; ``url`` '' closes every tab.", + "properties": { + "url": { + "default": "", + "title": "Url", + "type": "string" + } + }, + "title": "PreviewClosePayload", + "type": "object" + }, + "PreviewOpenPayload": { + "additionalProperties": false, + "description": "``tools/open_preview_tool.py``.", + "properties": { + "url": { + "title": "Url", + "type": "string" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "PreviewOpenPayload", + "type": "object" + }, + "PreviewRestartParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "url": { + "title": "Url", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + } + }, + "required": [ + "session_id", + "url" + ], + "title": "PreviewRestartParams", + "type": "object" + }, + "PreviewRestartProgressPayload": { + "additionalProperties": false, + "description": "``methods_prompt`` restart body + ``agent_callbacks._preview_restart_callbacks``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Level" + } + }, + "required": [ + "task_id", + "text" + ], + "title": "PreviewRestartProgressPayload", + "type": "object" + }, + "ProcessEntry": { + "additionalProperties": true, + "description": "``tools/process_registry.py::list_sessions`` row plus the gateway's ``output_tail``.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "pid": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pid" + }, + "owner_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Task Id" + }, + "started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "uptime_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Uptime Seconds" + }, + "status": { + "default": "running", + "title": "Status", + "type": "string" + }, + "output_preview": { + "default": "", + "title": "Output Preview", + "type": "string" + }, + "output_tail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tail" + }, + "session_scoped": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Scoped" + }, + "watch_patterns": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Watch Patterns" + }, + "watch_hit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Watch Hit" + }, + "notify_on_complete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notify On Complete" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "exited_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exited At" + }, + "completion_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completion Reason" + }, + "detached": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Detached" + } + }, + "required": [ + "session_id" + ], + "title": "ProcessEntry", + "type": "object" + }, + "ProcessKillParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "process_id": { + "title": "Process Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "process_id" + ], + "title": "ProcessKillParams", + "type": "object" + }, + "ProcessKillResult": { + "additionalProperties": true, + "description": "``tools/process_registry.py::kill_process`` snapshot; ``error`` rides on the failure statuses.", + "properties": { + "status": { + "$ref": "#/components/schemas/ProcessKillStatus" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "completion_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completion Reason" + }, + "termination_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Termination Source" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + } + }, + "required": [ + "status" + ], + "title": "ProcessKillResult", + "type": "object" + }, + "ProcessKillStatus": { + "enum": [ + "killed", + "already_exited", + "not_found", + "error" + ], + "title": "ProcessKillStatus", + "type": "string" + }, + "ProcessListParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "ProcessListParams", + "type": "object" + }, + "ProcessListResult": { + "additionalProperties": false, + "properties": { + "processes": { + "items": { + "$ref": "#/components/schemas/ProcessEntry" + }, + "title": "Processes", + "type": "array" + } + }, + "title": "ProcessListResult", + "type": "object" + }, + "ProcessStopParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ProcessStopParams", + "type": "object" + }, + "ProcessStopResult": { + "additionalProperties": false, + "properties": { + "killed": { + "title": "Killed", + "type": "integer" + } + }, + "required": [ + "killed" + ], + "title": "ProcessStopResult", + "type": "object" + }, + "ProfileCanonicalSession": { + "additionalProperties": false, + "description": "The profile's \"Bot Chat\" registry row; ``resolved_id`` is the live compression tip.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "resolved_id": { + "title": "Resolved Id", + "type": "string" + }, + "root_title": { + "default": "", + "title": "Root Title", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Started At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + } + }, + "required": [ + "id", + "resolved_id" + ], + "title": "ProfileCanonicalSession", + "type": "object" + }, + "ProfileMirrored": { + "additionalProperties": false, + "description": "What was copied from the launch profile; ``auth`` is ``\"shared\"`` under ``share_auth``.", + "properties": { + "env": { + "default": false, + "title": "Env", + "type": "boolean" + }, + "auth": { + "anyOf": [ + { + "type": "boolean" + }, + { + "const": "shared", + "type": "string" + } + ], + "default": false, + "title": "Auth" + }, + "model_inherited": { + "default": false, + "title": "Model Inherited", + "type": "boolean" + }, + "voice": { + "default": false, + "title": "Voice", + "type": "boolean" + } + }, + "title": "ProfileMirrored", + "type": "object" + }, + "ProfileModelPin": { + "additionalProperties": false, + "properties": { + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "default": { + "type": "string", + "default": "", + "title": "Default" + } + }, + "title": "ProfileModelPin", + "type": "object" + }, + "ProfileNameParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "title": "ProfileNameParams", + "type": "object" + }, + "ProfileParams": { + "additionalProperties": false, + "description": "Any method the desktop may route to a named profile (``requestGatewayForProfile`` adds ``profile``).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "ProfileParams", + "type": "object" + }, + "ProfileRow": { + "additionalProperties": false, + "description": "One roster row; the session fields are present only with ``include_sessions``.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "is_default": { + "default": false, + "title": "Is Default", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "display_name": { + "default": "", + "title": "Display Name", + "type": "string" + }, + "skill_count": { + "default": 0, + "title": "Skill Count", + "type": "integer" + }, + "previous_names": { + "items": { + "type": "string" + }, + "title": "Previous Names", + "type": "array" + }, + "role": { + "anyOf": [ + { + "const": "setup", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "last_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileSessionPreview" + }, + { + "type": "null" + } + ], + "default": null + }, + "worker_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileWorkerSession" + }, + { + "type": "null" + } + ], + "default": null + }, + "canonical_session": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProfileCanonicalSession" + }, + { + "type": "null" + } + ], + "default": null + }, + "ui_meta_revisions": { + "additionalProperties": { + "type": "integer" + }, + "title": "Ui Meta Revisions", + "type": "object" + }, + "ui_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "has_avatar": { + "default": false, + "title": "Has Avatar", + "type": "boolean" + } + }, + "required": [ + "name", + "path" + ], + "title": "ProfileRow", + "type": "object" + }, + "ProfileSessionPreview": { + "additionalProperties": false, + "description": "Newest human-facing session of a profile (``_latest_profile_session_rows``).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Started At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "ProfileSessionPreview", + "type": "object" + }, + "ProfileWorkerSession": { + "additionalProperties": false, + "description": "Newest kanban/tool worker row, so rosters can show a profile as working.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + } + ], + "default": 0, + "title": "Last Active" + } + }, + "required": [ + "id" + ], + "title": "ProfileWorkerSession", + "type": "object" + }, + "ProfilesConfigureApplied": { + "additionalProperties": false, + "description": "Per-section outcome; only the sections the request carried are present.", + "properties": { + "ui_meta": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "ui_meta_revisions": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Revisions" + }, + "ui_meta_conflicts": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/UiMetaConflict" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Conflicts" + }, + "soul": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "description": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "model": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "toolsets": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Toolsets" + }, + "mcp_servers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mcp Servers" + } + }, + "title": "ProfilesConfigureApplied", + "type": "object" + }, + "ProfilesConfigureParams": { + "additionalProperties": false, + "description": "Sections are independent; ``ui_meta_expected_revisions`` is a per-key compare-and-swap.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "ui_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta" + }, + "ui_meta_expected_revisions": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Meta Expected Revisions" + }, + "soul": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "confirm_expensive_model": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Expensive Model" + }, + "disabled_skills": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disabled Skills" + }, + "enabled_toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Toolsets" + }, + "enabled_mcp_servers": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Mcp Servers" + } + }, + "title": "ProfilesConfigureParams", + "type": "object" + }, + "ProfilesConfigureResult": { + "additionalProperties": false, + "description": "``confirm_required`` mirrors ``config.set``: a guarded model pick wrote nothing yet.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "applied": { + "$ref": "#/components/schemas/ProfilesConfigureApplied" + }, + "confirm_required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Required" + }, + "confirm_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Message" + } + }, + "required": [ + "ok", + "applied" + ], + "title": "ProfilesConfigureResult", + "type": "object" + }, + "ProfilesCreateParams": { + "additionalProperties": false, + "description": "``clone_from`` omitted = fresh profile + bundled skills; ``mirror_credentials`` defaults on\nso a headless bot has a provider.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "clone_from": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone From" + }, + "clone_all": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone All" + }, + "clone_channels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clone Channels" + }, + "no_skills": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Skills" + }, + "no_alias": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Alias" + }, + "soul": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Soul" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "share_auth": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Share Auth" + }, + "mirror_credentials": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mirror Credentials" + } + }, + "required": [ + "name" + ], + "title": "ProfilesCreateParams", + "type": "object" + }, + "ProfilesCreateResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "soul_written": { + "default": false, + "title": "Soul Written", + "type": "boolean" + }, + "model_set": { + "default": false, + "title": "Model Set", + "type": "boolean" + }, + "mirrored": { + "$ref": "#/components/schemas/ProfileMirrored" + } + }, + "required": [ + "name", + "path", + "mirrored" + ], + "title": "ProfilesCreateResult", + "type": "object" + }, + "ProfilesDescribeResult": { + "additionalProperties": false, + "description": "Editor snapshot; ``toolsets_pinned`` says whether ``tools.enabled_toolsets`` is explicit.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "soul": { + "default": "", + "title": "Soul", + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/ProfileModelPin" + }, + "skills": { + "items": { + "$ref": "#/components/schemas/CapabilityEntry" + }, + "title": "Skills", + "type": "array" + }, + "toolsets": { + "items": { + "$ref": "#/components/schemas/ToolsetEntry" + }, + "title": "Toolsets", + "type": "array" + }, + "toolsets_pinned": { + "default": false, + "title": "Toolsets Pinned", + "type": "boolean" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerEntry" + }, + "title": "Mcp Servers", + "type": "array" + } + }, + "required": [ + "name", + "model" + ], + "title": "ProfilesDescribeResult", + "type": "object" + }, + "ProfilesGetAssetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "asset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Asset" + } + }, + "title": "ProfilesGetAssetParams", + "type": "object" + }, + "ProfilesGetAssetResult": { + "additionalProperties": false, + "description": "Absent is ``found: false``, not an error.", + "properties": { + "found": { + "title": "Found", + "type": "boolean" + }, + "mime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mime" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Size" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + } + }, + "required": [ + "found" + ], + "title": "ProfilesGetAssetResult", + "type": "object" + }, + "ProfilesListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "include_sessions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Sessions" + } + }, + "title": "ProfilesListParams", + "type": "object" + }, + "ProfilesListResult": { + "additionalProperties": false, + "description": "``bot_mode_protocol`` tells clients this backend injects the teammate protocol itself.", + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/ProfileRow" + }, + "title": "Profiles", + "type": "array" + }, + "bot_mode_protocol": { + "default": true, + "title": "Bot Mode Protocol", + "type": "boolean" + } + }, + "title": "ProfilesListResult", + "type": "object" + }, + "ProfilesRememberOnboardingParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "answers": { + "anyOf": [ + { + "$ref": "#/components/schemas/OnboardingAnswers" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProfilesRememberOnboardingParams", + "type": "object" + }, + "ProfilesRememberOnboardingResult": { + "additionalProperties": false, + "properties": { + "saved": { + "default": true, + "title": "Saved", + "type": "boolean" + }, + "profile": { + "default": "default", + "title": "Profile", + "type": "string" + }, + "target": { + "default": "user", + "title": "Target", + "type": "string" + } + }, + "title": "ProfilesRememberOnboardingResult", + "type": "object" + }, + "ProfilesSetAssetParams": { + "additionalProperties": false, + "description": "``data`` is a data URL or bare base64 (PNG/JPEG/WebP, sniffed); ``clear`` deletes instead.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "asset": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Asset" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data" + }, + "clear": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clear" + } + }, + "title": "ProfilesSetAssetParams", + "type": "object" + }, + "ProfilesSetAssetResult": { + "additionalProperties": false, + "properties": { + "ok": { + "default": true, + "title": "Ok", + "type": "boolean" + }, + "asset": { + "title": "Asset", + "type": "string" + }, + "size": { + "default": 0, + "title": "Size", + "type": "integer" + }, + "removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Removed" + } + }, + "required": [ + "asset" + ], + "title": "ProfilesSetAssetResult", + "type": "object" + }, + "ProjectFacts": { + "additionalProperties": false, + "description": "``agent/coding_context.py::project_facts_for`` \u2014 the system prompt's coding-context detection.", + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "manifests": { + "items": { + "type": "string" + }, + "title": "Manifests", + "type": "array" + }, + "packageManagers": { + "items": { + "type": "string" + }, + "title": "Packagemanagers", + "type": "array" + }, + "verifyCommands": { + "items": { + "type": "string" + }, + "title": "Verifycommands", + "type": "array" + }, + "contextFiles": { + "items": { + "type": "string" + }, + "title": "Contextfiles", + "type": "array" + } + }, + "required": [ + "root", + "manifests", + "packageManagers", + "verifyCommands", + "contextFiles" + ], + "title": "ProjectFacts", + "type": "object" + }, + "ProjectFactsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "ProjectFactsParams", + "type": "object" + }, + "ProjectFactsResult": { + "additionalProperties": false, + "description": "``facts`` null outside a workspace (or when detection failed).", + "properties": { + "facts": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectFacts" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectFactsResult", + "type": "object" + }, + "ProjectFolder": { + "additionalProperties": false, + "description": "``hermes_cli/projects_db.py::ProjectFolder.to_dict``.", + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "is_primary": { + "default": false, + "title": "Is Primary", + "type": "boolean" + }, + "added_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Added At" + } + }, + "required": [ + "path" + ], + "title": "ProjectFolder", + "type": "object" + }, + "ProjectFolderParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "id", + "path" + ], + "title": "ProjectFolderParams", + "type": "object" + }, + "ProjectIdParams": { + "additionalProperties": false, + "description": "Any method addressed at one stored project (``5062`` when the id resolves to nothing).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "ProjectIdParams", + "type": "object" + }, + "ProjectInfo": { + "additionalProperties": false, + "description": "``hermes_cli/projects_db.py::Project.to_dict`` \u2014 one stored project with its folders.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + }, + "archived": { + "default": false, + "title": "Archived", + "type": "boolean" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "folders": { + "items": { + "$ref": "#/components/schemas/ProjectFolder" + }, + "title": "Folders", + "type": "array" + } + }, + "required": [ + "id", + "slug", + "name", + "created_at" + ], + "title": "ProjectInfo", + "type": "object" + }, + "ProjectRef": { + "additionalProperties": false, + "description": "``tui_gateway/server.py::_project_info_for_cwd``.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + } + }, + "required": [ + "id", + "slug", + "name" + ], + "title": "ProjectRef", + "type": "object" + }, + "ProjectResult": { + "additionalProperties": false, + "properties": { + "project": { + "$ref": "#/components/schemas/ProjectInfo" + } + }, + "required": [ + "project" + ], + "title": "ProjectResult", + "type": "object" + }, + "ProjectTreeLane": { + "additionalProperties": false, + "description": "One branch / worktree / kanban lane inside a repo; ``sessions`` is empty unless hydrated.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "isMain": { + "default": false, + "title": "Ismain", + "type": "boolean" + }, + "isKanban": { + "default": false, + "title": "Iskanban", + "type": "boolean" + }, + "isGit": { + "default": true, + "title": "Isgit", + "type": "boolean" + }, + "sessions": { + "items": { + "$ref": "#/components/schemas/ProjectTreeSession" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeLane", + "type": "object" + }, + "ProjectTreeNode": { + "additionalProperties": false, + "description": "``project_tree._project_node`` \u2014 explicit, auto (git root) or the synthetic Home bucket.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "isAuto": { + "default": false, + "title": "Isauto", + "type": "boolean" + }, + "isNoProject": { + "default": false, + "title": "Isnoproject", + "type": "boolean" + }, + "sessionCount": { + "default": 0, + "title": "Sessioncount", + "type": "integer" + }, + "lastActive": { + "default": 0.0, + "title": "Lastactive", + "type": "number" + }, + "totalTokens": { + "default": 0, + "title": "Totaltokens", + "type": "integer" + }, + "totalCostUsd": { + "default": 0.0, + "title": "Totalcostusd", + "type": "number" + }, + "repos": { + "items": { + "$ref": "#/components/schemas/ProjectTreeRepo" + }, + "title": "Repos", + "type": "array" + }, + "previewSessions": { + "items": { + "$ref": "#/components/schemas/ProjectTreeSession" + }, + "title": "Previewsessions", + "type": "array" + }, + "sessionIds": { + "items": { + "type": "string" + }, + "title": "Sessionids", + "type": "array" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeNode", + "type": "object" + }, + "ProjectTreeRepo": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "groups": { + "items": { + "$ref": "#/components/schemas/ProjectTreeLane" + }, + "title": "Groups", + "type": "array" + }, + "sessionCount": { + "default": 0, + "title": "Sessioncount", + "type": "integer" + } + }, + "required": [ + "id", + "label" + ], + "title": "ProjectTreeRepo", + "type": "object" + }, + "ProjectTreeSession": { + "additionalProperties": true, + "description": "``methods_projects._project_tree_row`` + ``project_tree.stamp_profile``: the minimal row the\nsidebar renders, stamped with the profile it belongs to.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ended At" + }, + "last_active": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Active" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + }, + "tool_call_count": { + "default": 0, + "title": "Tool Call Count", + "type": "integer" + }, + "input_tokens": { + "default": 0, + "title": "Input Tokens", + "type": "integer" + }, + "output_tokens": { + "default": 0, + "title": "Output Tokens", + "type": "integer" + }, + "is_active": { + "default": false, + "title": "Is Active", + "type": "boolean" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "git_branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Branch" + }, + "git_repo_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Repo Root" + }, + "parent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Session Id" + }, + "pinned": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pinned" + }, + "unread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unread" + }, + "archived": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Archived" + }, + "actual_cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actual Cost Usd" + }, + "estimated_cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Estimated Cost Usd" + }, + "handoff_platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handoff Platform" + }, + "handoff_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handoff State" + }, + "_lineage_root_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lineage Root Id" + }, + "_lineage_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lineage Ids" + }, + "continuation_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Continuation Kind" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "id" + ], + "title": "ProjectTreeSession", + "type": "object" + }, + "ProjectsAddFolderParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "is_primary": { + "default": false, + "title": "Is Primary", + "type": "boolean" + } + }, + "required": [ + "id", + "path" + ], + "title": "ProjectsAddFolderParams", + "type": "object" + }, + "ProjectsArchiveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "restore": { + "default": false, + "title": "Restore", + "type": "boolean" + } + }, + "required": [ + "id" + ], + "title": "ProjectsArchiveParams", + "type": "object" + }, + "ProjectsCreateParams": { + "additionalProperties": false, + "description": "``use`` also activates the new project.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "title": "Name", + "type": "string" + }, + "folders": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Folders" + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Slug" + }, + "primary_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Primary Path" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + }, + "use": { + "default": false, + "title": "Use", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "ProjectsCreateParams", + "type": "object" + }, + "ProjectsDiscoverReposParams": { + "additionalProperties": false, + "description": "``scan`` asks the host to walk the policy roots itself (remote-gateway desktop).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "scan": { + "default": false, + "title": "Scan", + "type": "boolean" + } + }, + "title": "ProjectsDiscoverReposParams", + "type": "object" + }, + "ProjectsDiscoverReposResult": { + "additionalProperties": false, + "properties": { + "repos": { + "items": { + "$ref": "#/components/schemas/DiscoveredRepo" + }, + "title": "Repos", + "type": "array" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicy" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "repos" + ], + "title": "ProjectsDiscoverReposResult", + "type": "object" + }, + "ProjectsForCwdParams": { + "additionalProperties": false, + "description": "Absent ``cwd`` resolves the gateway's default completion cwd.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "ProjectsForCwdParams", + "type": "object" + }, + "ProjectsForCwdResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "cwd": { + "title": "Cwd", + "type": "string" + }, + "branch": { + "default": "", + "title": "Branch", + "type": "string" + } + }, + "required": [ + "cwd" + ], + "title": "ProjectsForCwdResult", + "type": "object" + }, + "ProjectsPayload": { + "additionalProperties": false, + "description": "``methods_projects._projects_payload``: every project (archived included) + the active id.", + "properties": { + "projects": { + "items": { + "$ref": "#/components/schemas/ProjectInfo" + }, + "title": "Projects", + "type": "array" + }, + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + } + }, + "required": [ + "projects" + ], + "title": "ProjectsPayload", + "type": "object" + }, + "ProjectsProjectSessionsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "project_id": { + "title": "Project Id", + "type": "string" + }, + "session_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Limit" + } + }, + "required": [ + "project_id" + ], + "title": "ProjectsProjectSessionsParams", + "type": "object" + }, + "ProjectsProjectSessionsResult": { + "additionalProperties": false, + "properties": { + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectTreeNode" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectsProjectSessionsResult", + "type": "object" + }, + "ProjectsRecordReposParams": { + "additionalProperties": false, + "description": "Repos as ``{root, label}`` objects or bare root strings; entries without a root are skipped.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "repos": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/RecordRepoItem" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repos" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicyParams" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProjectsRecordReposParams", + "type": "object" + }, + "ProjectsRecordReposResult": { + "additionalProperties": false, + "properties": { + "repos": { + "items": { + "$ref": "#/components/schemas/DiscoveredRepo" + }, + "title": "Repos", + "type": "array" + }, + "discovery_policy": { + "anyOf": [ + { + "$ref": "#/components/schemas/RepoDiscoveryPolicy" + }, + { + "type": "null" + } + ], + "default": null + }, + "accepted": { + "title": "Accepted", + "type": "boolean" + } + }, + "required": [ + "repos", + "accepted" + ], + "title": "ProjectsRecordReposResult", + "type": "object" + }, + "ProjectsSetActiveParams": { + "additionalProperties": false, + "description": "No ``id`` (or null) clears the active project.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "ProjectsSetActiveParams", + "type": "object" + }, + "ProjectsTreeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "preview_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview Limit" + }, + "session_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Limit" + } + }, + "title": "ProjectsTreeParams", + "type": "object" + }, + "ProjectsTreeResult": { + "additionalProperties": false, + "properties": { + "projects": { + "items": { + "$ref": "#/components/schemas/ProjectTreeNode" + }, + "title": "Projects", + "type": "array" + }, + "active_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Id" + }, + "scoped_session_ids": { + "items": { + "type": "string" + }, + "title": "Scoped Session Ids", + "type": "array" + } + }, + "required": [ + "projects" + ], + "title": "ProjectsTreeResult", + "type": "object" + }, + "ProjectsUpdateParams": { + "additionalProperties": false, + "description": "Absent keys are left untouched; ``''`` clears ``color`` / ``icon``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Color" + }, + "board_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Slug" + } + }, + "required": [ + "id" + ], + "title": "ProjectsUpdateParams", + "type": "object" + }, + "PromptSubmitParams": { + "additionalProperties": false, + "description": "``text`` is normally a string; the relay / hosted paths may hand a structured (parts list)\npayload, and the busy path renders it. Truncation (rewind / edit / regenerate) needs explicit\nconsent: ``confirm_truncate`` plus one durable target (``truncate_before_row_id`` preferred,\n``truncate_before_message_id``, or the legacy ``truncate_before_user_ordinal``).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "default": "", + "title": "Text" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "interrupted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interrupted" + }, + "queued": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Queued" + }, + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "voice_context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Voice Context" + }, + "title_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title Preview" + }, + "truncate_before_user_ordinal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before User Ordinal" + }, + "truncate_before_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before Row Id" + }, + "truncate_before_message_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncate Before Message Id" + }, + "confirm_truncate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Truncate" + }, + "confirm_empty_truncate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirm Empty Truncate" + }, + "rebind_survivor_row_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rebind Survivor Row Ids" + } + }, + "required": [ + "session_id" + ], + "title": "PromptSubmitParams", + "type": "object" + }, + "PromptSubmitResult": { + "additionalProperties": false, + "description": "``status`` is absent only on the typed-stop-phrase reply (``voice_stopped``). After a\ntruncation the survivor row ids let the client rebind its cached ``rowId``s (``None`` map\nentries: drop the cached id). ``turn_isolation`` marks a compute-host dispatch.", + "properties": { + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptSubmitStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "voice_stopped": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Voice Stopped" + }, + "user_row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "User Row Id" + }, + "survivor_user_row_ids": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Survivor User Row Ids" + }, + "survivor_row_id_map": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Survivor Row Id Map" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + } + }, + "title": "PromptSubmitResult", + "type": "object" + }, + "PromptSubmitStatus": { + "enum": [ + "streaming", + "queued", + "steered", + "redirected" + ], + "title": "PromptSubmitStatus", + "type": "string" + }, + "QueuedPrompt": { + "additionalProperties": false, + "properties": { + "user": { + "title": "User", + "type": "string" + } + }, + "required": [ + "user" + ], + "title": "QueuedPrompt", + "type": "object" + }, + "ReactionAuthor": { + "enum": [ + "user", + "agent" + ], + "title": "ReactionAuthor", + "type": "string" + }, + "ReactionPayload": { + "additionalProperties": false, + "description": "``agent_callbacks`` reaction_callback.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "ReactionPayload", + "type": "object" + }, + "ReadRangeRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "session_id" + ], + "title": "ReadRangeRequestParams", + "type": "object" + }, + "RecordRepoItem": { + "additionalProperties": false, + "properties": { + "root": { + "title": "Root", + "type": "string" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + } + }, + "required": [ + "root" + ], + "title": "RecordRepoItem", + "type": "object" + }, + "RelayAgentRow": { + "additionalProperties": true, + "description": "A roster row the Desktop pushes (``tools/bot_relay.py::_normalize_roster_row``); invalid\nrows are dropped server-side, so the shape stays open.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "connection_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Id" + }, + "connection_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Connection Label" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "online": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Online" + } + }, + "title": "RelayAgentRow", + "type": "object" + }, + "RelayEnvelope": { + "additionalProperties": true, + "description": "``tools/bot_relay.py::enqueue_envelope``.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + } + ], + "title": "Created At" + }, + "from_profile": { + "title": "From Profile", + "type": "string" + }, + "from_handle": { + "title": "From Handle", + "type": "string" + }, + "target_connection": { + "title": "Target Connection", + "type": "string" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "target_handle": { + "title": "Target Handle", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "id", + "created_at", + "from_profile", + "from_handle", + "target_connection", + "target_profile", + "target_handle", + "message" + ], + "title": "RelayEnvelope", + "type": "object" + }, + "ReloadEnvParams": { + "additionalProperties": false, + "properties": {}, + "title": "ReloadEnvParams", + "type": "object" + }, + "ReloadEnvResult": { + "additionalProperties": false, + "properties": { + "updated": { + "title": "Updated", + "type": "integer" + } + }, + "required": [ + "updated" + ], + "title": "ReloadEnvResult", + "type": "object" + }, + "ReloadMcpParams": { + "additionalProperties": false, + "description": "Without ``confirm`` the handler may answer ``confirm_required`` (per ``approvals.mcp_reload_confirm``);\n``always`` persists the opt-out; ``rev`` is the config revision the caller wants loaded (coalescing).", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "confirm": { + "default": false, + "title": "Confirm", + "type": "boolean" + }, + "always": { + "default": false, + "title": "Always", + "type": "boolean" + }, + "rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rev" + } + }, + "title": "ReloadMcpParams", + "type": "object" + }, + "ReloadMcpResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/ReloadMcpStatus" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "loaded_rev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Loaded Rev" + }, + "coalesced": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Coalesced" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + }, + "host_ack": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Host Ack" + } + }, + "required": [ + "status" + ], + "title": "ReloadMcpResult", + "type": "object" + }, + "ReloadMcpStatus": { + "enum": [ + "confirm_required", + "reloaded" + ], + "title": "ReloadMcpStatus", + "type": "string" + }, + "RepoDiscoveryPolicy": { + "additionalProperties": false, + "description": "``methods_projects._repo_discovery_policy`` \u2014 the effective ``desktop.repo_scan_*`` config.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "roots": { + "items": { + "type": "string" + }, + "title": "Roots", + "type": "array" + }, + "exclude_paths": { + "items": { + "type": "string" + }, + "title": "Exclude Paths", + "type": "array" + } + }, + "required": [ + "enabled", + "roots", + "exclude_paths" + ], + "title": "RepoDiscoveryPolicy", + "type": "object" + }, + "RepoDiscoveryPolicyParams": { + "additionalProperties": false, + "description": "The policy the desktop scanned under (short or ``repo_scan_*`` long keys both accepted).", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + }, + "roots": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Roots" + }, + "exclude_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exclude Paths" + }, + "repo_scan_enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Enabled" + }, + "repo_scan_roots": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Roots" + }, + "repo_scan_exclude_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Repo Scan Exclude Paths" + } + }, + "title": "RepoDiscoveryPolicyParams", + "type": "object" + }, + "RequestAnswerParams": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "result": { + "additionalProperties": true, + "title": "Result", + "type": "object" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "id", + "result" + ], + "title": "RequestAnswerParams", + "type": "object" + }, + "RequestAnswerResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/ClarifyLockStatus" + } + }, + "required": [ + "status" + ], + "title": "RequestAnswerResult", + "type": "object" + }, + "RequestCancelPayload": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "method": { + "title": "Method", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "id", + "method", + "reason" + ], + "title": "RequestCancelPayload", + "type": "object" + }, + "ResumePhaseStatus": { + "enum": [ + "loading", + "complete", + "failed" + ], + "title": "ResumePhaseStatus", + "type": "string" + }, + "ReviewSummaryPayload": { + "additionalProperties": false, + "description": "``server`` background_review_callback.", + "properties": { + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "ReviewSummaryPayload", + "type": "object" + }, + "RollbackCheckpoint": { + "additionalProperties": false, + "properties": { + "hash": { + "default": "", + "title": "Hash", + "type": "string" + }, + "timestamp": { + "default": "", + "title": "Timestamp", + "type": "string" + }, + "message": { + "default": "", + "title": "Message", + "type": "string" + } + }, + "title": "RollbackCheckpoint", + "type": "object" + }, + "RollbackDiffParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hash": { + "title": "Hash", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hash" + ], + "title": "RollbackDiffParams", + "type": "object" + }, + "RollbackDiffResult": { + "additionalProperties": false, + "properties": { + "stat": { + "default": "", + "title": "Stat", + "type": "string" + }, + "diff": { + "default": "", + "title": "Diff", + "type": "string" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + } + }, + "title": "RollbackDiffResult", + "type": "object" + }, + "RollbackListParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "RollbackListParams", + "type": "object" + }, + "RollbackListResult": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "checkpoints": { + "items": { + "$ref": "#/components/schemas/RollbackCheckpoint" + }, + "title": "Checkpoints", + "type": "array" + } + }, + "required": [ + "enabled" + ], + "title": "RollbackListResult", + "type": "object" + }, + "RollbackRestoreParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hash": { + "title": "Hash", + "type": "string" + }, + "file_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File Path" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hash" + ], + "title": "RollbackRestoreParams", + "type": "object" + }, + "RollbackRestoreResult": { + "additionalProperties": true, + "description": "``tools/checkpoint_manager.py::restore`` outcome; ``history_removed`` is added for a full\n(non-file) restore that also rewound the live transcript.", + "properties": { + "success": { + "title": "Success", + "type": "boolean" + }, + "restored_to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restored To" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Directory" + }, + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File" + }, + "restored_files": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Restored Files" + }, + "skipped_user_edits": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skipped User Edits" + }, + "skipped_oversize": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skipped Oversize" + }, + "failed_deletes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failed Deletes" + }, + "history_removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Removed" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "debug": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Debug" + } + }, + "required": [ + "success" + ], + "title": "RollbackRestoreResult", + "type": "object" + }, + "Room": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::_room_from_row`` plus the branch-only keys ``create`` (legacy\nadoption), ``state`` (``authority_claim``) and ``rename`` (``event``) add.", + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/RoomMember" + }, + "title": "Members", + "type": "array" + }, + "authority_gateway_id": { + "title": "Authority Gateway Id", + "type": "string" + }, + "authority_epoch": { + "title": "Authority Epoch", + "type": "integer" + }, + "revision": { + "title": "Revision", + "type": "integer" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + }, + "idempotent": { + "default": false, + "title": "Idempotent", + "type": "boolean" + }, + "disbanded_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disbanded At" + }, + "latest_seq": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Latest Seq" + }, + "adopted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Adopted" + }, + "claim_event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + }, + "authority_claim": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + }, + "event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room_id", + "name", + "members", + "authority_gateway_id", + "authority_epoch", + "revision", + "created_at", + "updated_at" + ], + "title": "Room", + "type": "object" + }, + "RoomActor": { + "additionalProperties": false, + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "title": "RoomActor", + "type": "object" + }, + "RoomAuthority": { + "additionalProperties": false, + "properties": { + "gateway_id": { + "title": "Gateway Id", + "type": "string" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + } + }, + "required": [ + "gateway_id", + "epoch" + ], + "title": "RoomAuthority", + "type": "object" + }, + "RoomDriverStatus": { + "additionalProperties": false, + "description": "``HostedRoomService.status(room_id)``; ``pending_actions`` rows are ``{kind: retry, task_id}``\nor the driver's approval action (``kind: approval`` + run/session/approval context).", + "properties": { + "running": { + "title": "Running", + "type": "boolean" + }, + "working": { + "title": "Working", + "type": "boolean" + }, + "blocked": { + "title": "Blocked", + "type": "boolean" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Counts", + "type": "object" + }, + "pending_actions": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Pending Actions", + "type": "array" + }, + "peer_routes": { + "items": { + "$ref": "#/components/schemas/PeerRouteStatus" + }, + "title": "Peer Routes", + "type": "array" + } + }, + "required": [ + "running", + "working", + "blocked", + "counts", + "pending_actions", + "peer_routes" + ], + "title": "RoomDriverStatus", + "type": "object" + }, + "RoomEvent": { + "additionalProperties": false, + "description": "``gateway/hosted_rooms.py::_event_from_row``.", + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "seq": { + "title": "Seq", + "type": "integer" + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "actor": { + "$ref": "#/components/schemas/RoomActor" + }, + "authority_epoch": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Epoch" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "created_at": { + "title": "Created At", + "type": "number" + }, + "idempotent": { + "default": false, + "title": "Idempotent", + "type": "boolean" + } + }, + "required": [ + "room_id", + "seq", + "event_id", + "kind", + "actor", + "payload", + "created_at" + ], + "title": "RoomEvent", + "type": "object" + }, + "RoomExecutionPolicy": { + "additionalProperties": false, + "description": "``gateway/hosted_room_execution_policy.py::execution_policy_mapping``.", + "properties": { + "version": { + "title": "Version", + "type": "integer" + }, + "target_profile": { + "title": "Target Profile", + "type": "string" + }, + "enabled_toolsets": { + "items": { + "type": "string" + }, + "title": "Enabled Toolsets", + "type": "array" + }, + "approval_mode": { + "title": "Approval Mode", + "type": "string" + }, + "max_iterations": { + "title": "Max Iterations", + "type": "integer" + }, + "policy_digest": { + "title": "Policy Digest", + "type": "string" + } + }, + "required": [ + "version", + "target_profile", + "enabled_toolsets", + "approval_mode", + "max_iterations", + "policy_digest" + ], + "title": "RoomExecutionPolicy", + "type": "object" + }, + "RoomLinkCatalog": { + "additionalProperties": false, + "description": "``gateway/hosted_room_peer.py::GatewayRoomCatalog.as_mapping``.", + "properties": { + "installation_id": { + "title": "Installation Id", + "type": "string" + }, + "protocol_versions": { + "items": { + "type": "integer" + }, + "title": "Protocol Versions", + "type": "array" + }, + "link_modes": { + "items": { + "type": "string" + }, + "title": "Link Modes", + "type": "array" + }, + "persistent_process": { + "title": "Persistent Process", + "type": "boolean" + }, + "text": { + "title": "Text", + "type": "boolean" + }, + "attachments": { + "title": "Attachments", + "type": "boolean" + }, + "execution_policy": { + "$ref": "#/components/schemas/RoomExecutionPolicy" + }, + "catalog_digest": { + "title": "Catalog Digest", + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkEndpoint" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "installation_id", + "protocol_versions", + "link_modes", + "persistent_process", + "text", + "attachments", + "execution_policy", + "catalog_digest" + ], + "title": "RoomLinkCatalog", + "type": "object" + }, + "RoomLinkEndpoint": { + "additionalProperties": false, + "description": "``GatewayRoomCatalog.endpoint_mapping``: ``url``/``transport_security`` when available,\n``reason`` when not.", + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "transport_security": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transport Security" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "available" + ], + "title": "RoomLinkEndpoint", + "type": "object" + }, + "RoomLinkStatus": { + "additionalProperties": false, + "description": "``enabled`` with ``profile``/``catalog``/``endpoint``, or disabled with a ``reason``.", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "catalog": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkCatalog" + }, + { + "type": "null" + } + ], + "default": null + }, + "endpoint": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomLinkEndpoint" + }, + { + "type": "null" + } + ], + "default": null + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "enabled" + ], + "title": "RoomLinkStatus", + "type": "object" + }, + "RoomMember": { + "additionalProperties": true, + "description": "One roster row (``hosted_room_discussion.validate_roster``); legacy rooms may carry\npre-normalisation rows, so the set stays open.", + "properties": { + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "target": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "title": "RoomMember", + "type": "object" + }, + "RoomMemberInput": { + "additionalProperties": true, + "description": "A roster row as the client proposes it; ``validate_roster`` owns the exact rules.", + "properties": { + "member_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Member Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "handle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Handle" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Name" + }, + "target": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "title": "RoomMemberInput", + "type": "object" + }, + "RoomTaskReceipt": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "task_id": { + "title": "Task Id", + "type": "string" + }, + "thread_id": { + "title": "Thread Id", + "type": "string" + }, + "turn_id": { + "title": "Turn Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "execution_generation": { + "title": "Execution Generation", + "type": "integer" + }, + "cancel_generation": { + "title": "Cancel Generation", + "type": "integer" + } + }, + "required": [ + "room_id", + "task_id", + "thread_id", + "turn_id", + "status", + "execution_generation", + "cancel_generation" + ], + "title": "RoomTaskReceipt", + "type": "object" + }, + "RoomTombstone": { + "additionalProperties": false, + "properties": { + "room_id": { + "title": "Room Id", + "type": "string" + }, + "disbanded_at": { + "title": "Disbanded At", + "type": "number" + }, + "idempotent": { + "title": "Idempotent", + "type": "boolean" + }, + "history_expired": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "History Expired" + }, + "event": { + "anyOf": [ + { + "$ref": "#/components/schemas/RoomEvent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "room_id", + "disbanded_at", + "idempotent" + ], + "title": "RoomTombstone", + "type": "object" + }, + "SecretRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "env_var": { + "title": "Env Var", + "type": "string" + }, + "prompt": { + "title": "Prompt", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata" + } + }, + "required": [ + "session_id", + "env_var", + "prompt" + ], + "title": "SecretRequestParams", + "type": "object" + }, + "SeedMessage": { + "additionalProperties": true, + "description": "One create-time transcript row (``session_history._coerce_seed_history``); ``text`` is the\nlegacy alias of ``content``; only ``display_kind: \"hidden\"`` is accepted from the wire. Clients\nforward stored rows verbatim (``_row_id``, ``timestamp``, \u2026) and the coercer drops what it does\nnot use, so the row stays open.", + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + } + }, + "required": [ + "role" + ], + "title": "SeedMessage", + "type": "object" + }, + "SessionActivateParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "omit_messages": { + "default": false, + "title": "Omit Messages", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionActivateParams", + "type": "object" + }, + "SessionActivateResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + "stored_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stored Session Id" + }, + "resumed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resumed" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "messages_omitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages Omitted" + }, + "hydrating": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hydrating" + }, + "running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Running" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "inflight": { + "anyOf": [ + { + "$ref": "#/components/schemas/InflightTurn" + }, + { + "type": "null" + } + ], + "default": null + }, + "queued": { + "anyOf": [ + { + "$ref": "#/components/schemas/QueuedPrompt" + }, + { + "type": "null" + } + ], + "default": null + }, + "pending_approval": { + "anyOf": [ + { + "$ref": "#/components/schemas/PendingApproval" + }, + { + "type": "null" + } + ], + "default": null + }, + "open_requests": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Open Requests" + }, + "pending_connection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionRequestPayload" + }, + { + "type": "null" + } + ], + "default": null + }, + "todo_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/TodoState" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_continue": { + "anyOf": [ + { + "$ref": "#/components/schemas/AutoContinue" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionActivateResult", + "type": "object" + }, + "SessionActiveItem": { + "additionalProperties": false, + "description": "``server._session_live_item``.", + "properties": { + "current": { + "title": "Current", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "last_active": { + "title": "Last Active", + "type": "number" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "model": { + "title": "Model", + "type": "string" + }, + "preview": { + "title": "Preview", + "type": "string" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "started_at": { + "title": "Started At", + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/LiveSessionStatus" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "current", + "id", + "last_active", + "message_count", + "model", + "preview", + "session_key", + "started_at", + "status", + "title" + ], + "title": "SessionActiveItem", + "type": "object" + }, + "SessionActiveListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "current_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Session Id" + } + }, + "title": "SessionActiveListParams", + "type": "object" + }, + "SessionActiveListResult": { + "additionalProperties": false, + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionActiveItem" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "sessions" + ], + "title": "SessionActiveListResult", + "type": "object" + }, + "SessionArchiveParams": { + "additionalProperties": false, + "description": "``session_id`` (or its ``session_key`` alias) is a live runtime id first, else a stored id / key / title.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "archived": { + "default": true, + "title": "Archived", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionArchiveParams", + "type": "object" + }, + "SessionArchiveResult": { + "additionalProperties": false, + "properties": { + "archived": { + "title": "Archived", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + } + }, + "required": [ + "archived", + "session_key" + ], + "title": "SessionArchiveResult", + "type": "object" + }, + "SessionBranchParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Count" + } + }, + "required": [ + "session_id" + ], + "title": "SessionBranchParams", + "type": "object" + }, + "SessionBranchResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "parent": { + "title": "Parent", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "title", + "parent", + "message_count", + "messages", + "info" + ], + "title": "SessionBranchResult", + "type": "object" + }, + "SessionBranchStoredParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "parent_session_id": { + "minLength": 1, + "title": "Parent Session Id", + "type": "string" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "required": [ + "parent_session_id" + ], + "title": "SessionBranchStoredParams", + "type": "object" + }, + "SessionBranchStoredResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages_omitted": { + "title": "Messages Omitted", + "type": "boolean" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "message_count", + "messages_omitted", + "info" + ], + "title": "SessionBranchStoredResult", + "type": "object" + }, + "SessionBranchWholeParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "required": [ + "session_id" + ], + "title": "SessionBranchWholeParams", + "type": "object" + }, + "SessionBranchWholeResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "parent": { + "title": "Parent", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages_omitted": { + "title": "Messages Omitted", + "type": "boolean" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "title", + "parent", + "message_count", + "messages_omitted", + "info" + ], + "title": "SessionBranchWholeResult", + "type": "object" + }, + "SessionCloseParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionCloseParams", + "type": "object" + }, + "SessionCloseResult": { + "additionalProperties": false, + "properties": { + "closed": { + "title": "Closed", + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "title": "SessionCloseResult", + "type": "object" + }, + "SessionCompressParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "focus_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Focus Topic" + } + }, + "required": [ + "session_id" + ], + "title": "SessionCompressParams", + "type": "object" + }, + "SessionCompressResult": { + "additionalProperties": true, + "description": "In-process: the before/after summary + replacement transcript. Compute host: its result passes\nthrough (hence open) with ``turn_isolation``; a lock held elsewhere answers ``compressed: false``.", + "properties": { + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "removed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Removed" + }, + "before_messages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Before Messages" + }, + "after_messages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Messages" + }, + "before_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Before Tokens" + }, + "after_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "After Tokens" + }, + "summary": { + "anyOf": [ + { + "$ref": "#/components/schemas/CompressionSummary" + }, + { + "type": "null" + } + ], + "default": null + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "compressed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressed" + }, + "lock_held": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lock Held" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + }, + "host_ack": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Host Ack" + } + }, + "title": "SessionCompressResult", + "type": "object" + }, + "SessionContextBreakdownParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionContextBreakdownParams", + "type": "object" + }, + "SessionContextBreakdownResult": { + "additionalProperties": false, + "description": "``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)\nplus the per-file context manifest (empty until the agent exists).", + "properties": { + "categories": { + "items": { + "$ref": "#/components/schemas/ContextCategory" + }, + "title": "Categories", + "type": "array" + }, + "context_max": { + "title": "Context Max", + "type": "integer" + }, + "context_percent": { + "title": "Context Percent", + "type": "integer" + }, + "context_used": { + "title": "Context Used", + "type": "integer" + }, + "estimated_total": { + "title": "Estimated Total", + "type": "integer" + }, + "context_estimated": { + "title": "Context Estimated", + "type": "boolean" + }, + "context_source": { + "title": "Context Source", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "context_files": { + "default": [], + "items": { + "$ref": "#/components/schemas/ContextFileSource" + }, + "title": "Context Files", + "type": "array" + } + }, + "required": [ + "categories", + "context_max", + "context_percent", + "context_used", + "estimated_total", + "context_estimated", + "context_source", + "model" + ], + "title": "SessionContextBreakdownResult", + "type": "object" + }, + "SessionControlArgs": { + "additionalProperties": false, + "description": "``subgoal.add`` reads ``text``; ``subgoal.remove`` reads the 1-based ``index``.", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index" + } + }, + "title": "SessionControlArgs", + "type": "object" + }, + "SessionControlDispatch": { + "additionalProperties": false, + "description": "``_dispatch_envelope`` \u2014 the command result's user-visible envelope, every key always present.", + "properties": { + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Output" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notice" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display" + } + }, + "required": [ + "type", + "output", + "notice", + "message", + "display" + ], + "title": "SessionControlDispatch", + "type": "object" + }, + "SessionControlParams": { + "additionalProperties": false, + "description": "``action`` is validated by the handler (unknown / gate actions answer ``4004``), so it stays a\nstring on the wire; ``SessionControlAction`` lists the accepted set.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "args": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionControlArgs" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "action" + ], + "title": "SessionControlParams", + "type": "object" + }, + "SessionControlReadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SessionControlReadParams", + "type": "object" + }, + "SessionControlReadResult": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + } + }, + "required": [ + "control" + ], + "title": "SessionControlReadResult", + "type": "object" + }, + "SessionControlResult": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + }, + "dispatch": { + "$ref": "#/components/schemas/SessionControlDispatch" + } + }, + "required": [ + "control", + "dispatch" + ], + "title": "SessionControlResult", + "type": "object" + }, + "SessionControlSnapshot": { + "additionalProperties": false, + "description": "``_snapshot_control`` \u2014 ``revision`` is a hash of the visible state (``\"\"`` when empty);\n``updated_at`` is the newest persisted timestamp (``0`` when none).", + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalSnapshot" + }, + { + "type": "null" + } + ] + }, + "loop": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopSnapshot" + }, + { + "type": "null" + } + ] + }, + "heartbeat": { + "anyOf": [ + { + "$ref": "#/components/schemas/HeartbeatSnapshot" + }, + { + "type": "null" + } + ] + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "updated_at": { + "title": "Updated At", + "type": "number" + } + }, + "required": [ + "goal", + "loop", + "heartbeat", + "revision", + "updated_at" + ], + "title": "SessionControlSnapshot", + "type": "object" + }, + "SessionControlUpdatePayload": { + "additionalProperties": false, + "properties": { + "control": { + "$ref": "#/components/schemas/SessionControlSnapshot" + } + }, + "required": [ + "control" + ], + "title": "SessionControlUpdatePayload", + "type": "object" + }, + "SessionCorrectionParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "text" + ], + "title": "SessionCorrectionParams", + "type": "object" + }, + "SessionCorrectionResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/CorrectionStatus" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "status", + "text" + ], + "title": "SessionCorrectionResult", + "type": "object" + }, + "SessionCreateParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "cwd_explicit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd Explicit" + }, + "messages": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SeedMessage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages" + }, + "parent_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Session Id" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Effort" + }, + "fast": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fast" + }, + "close_on_disconnect": { + "default": false, + "title": "Close On Disconnect", + "type": "boolean" + }, + "hidden": { + "default": false, + "title": "Hidden", + "type": "boolean" + }, + "room_plumbing": { + "default": false, + "title": "Room Plumbing", + "type": "boolean" + }, + "follow_profile_config": { + "default": false, + "title": "Follow Profile Config", + "type": "boolean" + } + }, + "title": "SessionCreateParams", + "type": "object" + }, + "SessionCreateResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + }, + "required": [ + "session_id", + "stored_session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionCreateResult", + "type": "object" + }, + "SessionCwdSetParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cwd": { + "title": "Cwd", + "type": "string" + } + }, + "required": [ + "session_id", + "cwd" + ], + "title": "SessionCwdSetParams", + "type": "object" + }, + "SessionCwdSetResult": { + "additionalProperties": true, + "description": "The refreshed ``session.info`` view (full agent view, or the lazy shape).", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "reasoning_effort_wire": { + "default": "", + "title": "Reasoning Effort Wire", + "type": "string" + }, + "service_tier": { + "default": "", + "title": "Service Tier", + "type": "string" + }, + "fast": { + "default": false, + "title": "Fast", + "type": "boolean" + }, + "yolo": { + "default": false, + "title": "Yolo", + "type": "boolean" + }, + "approval_mode": { + "default": "manual", + "title": "Approval Mode", + "type": "string" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "skills": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Skills", + "type": "object" + }, + "cwd": { + "default": "", + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "terminal_backend": { + "default": "", + "title": "Terminal Backend", + "type": "string" + }, + "personality": { + "default": "", + "title": "Personality", + "type": "string" + }, + "running": { + "default": false, + "title": "Running", + "type": "boolean" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "stored_session_id": { + "default": "", + "title": "Stored Session Id", + "type": "string" + }, + "desktop_contract": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop Contract" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + }, + "release_date": { + "default": "", + "title": "Release Date", + "type": "string" + }, + "update_behind": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Update Behind" + }, + "update_command": { + "default": "", + "title": "Update Command", + "type": "string" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "profile_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile Name" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerStatus" + }, + "title": "Mcp Servers", + "type": "array" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "System Prompt" + }, + "credential_warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Warning" + }, + "lazy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lazy" + } + }, + "title": "SessionCwdSetResult", + "type": "object" + }, + "SessionDeleteParams": { + "additionalProperties": false, + "description": "``session_id`` is the STORED id.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionDeleteParams", + "type": "object" + }, + "SessionDeleteResult": { + "additionalProperties": false, + "properties": { + "deleted": { + "title": "Deleted", + "type": "string" + } + }, + "required": [ + "deleted" + ], + "title": "SessionDeleteResult", + "type": "object" + }, + "SessionEventsSinceParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "last_seen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Seen" + } + }, + "required": [ + "session_id" + ], + "title": "SessionEventsSinceParams", + "type": "object" + }, + "SessionEventsSinceResult": { + "additionalProperties": false, + "properties": { + "events": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Events", + "type": "array" + }, + "latest_seq": { + "title": "Latest Seq", + "type": "integer" + }, + "truncated": { + "title": "Truncated", + "type": "boolean" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "epoch": { + "title": "Epoch", + "type": "string" + }, + "open_requests": { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "title": "Open Requests", + "type": "array" + } + }, + "required": [ + "events", + "latest_seq", + "truncated", + "count", + "epoch", + "open_requests" + ], + "title": "SessionEventsSinceResult", + "type": "object" + }, + "SessionEventsStatsParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionEventsStatsParams", + "type": "object" + }, + "SessionEventsStatsResult": { + "additionalProperties": false, + "description": "``event_replay.replay_stats``.", + "properties": { + "sessions": { + "title": "Sessions", + "type": "integer" + }, + "events": { + "title": "Events", + "type": "integer" + }, + "bytes": { + "title": "Bytes", + "type": "integer" + }, + "max_per_session": { + "title": "Max Per Session", + "type": "integer" + }, + "max_bytes_per_session": { + "title": "Max Bytes Per Session", + "type": "integer" + }, + "max_bytes_process": { + "title": "Max Bytes Process", + "type": "integer" + } + }, + "required": [ + "sessions", + "events", + "bytes", + "max_per_session", + "max_bytes_per_session", + "max_bytes_process" + ], + "title": "SessionEventsStatsResult", + "type": "object" + }, + "SessionForeignIdParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "SessionForeignIdParams", + "type": "object" + }, + "SessionForeignImportResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "already_imported": { + "default": false, + "title": "Already Imported", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionForeignImportResult", + "type": "object" + }, + "SessionForeignListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/ForeignSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Offset" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + } + }, + "title": "SessionForeignListParams", + "type": "object" + }, + "SessionForeignListResult": { + "additionalProperties": false, + "description": "``unreadable`` counts logs on this page that failed to parse.", + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/ForeignSessionRow" + }, + "title": "Sessions", + "type": "array" + }, + "next_offset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Offset" + }, + "host": { + "title": "Host", + "type": "string" + }, + "unreadable": { + "default": 0, + "title": "Unreadable", + "type": "integer" + } + }, + "required": [ + "host" + ], + "title": "SessionForeignListResult", + "type": "object" + }, + "SessionForeignPreviewResult": { + "additionalProperties": false, + "description": "Bounded to the last 40 turns / 8000 chars each; ``already_imported`` is the local id.", + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/ForeignTurn" + }, + "title": "Messages", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + }, + "already_imported": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Already Imported" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "SessionForeignPreviewResult", + "type": "object" + }, + "SessionHistoryParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionHistoryParams", + "type": "object" + }, + "SessionHistoryResult": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + } + }, + "required": [ + "count", + "messages" + ], + "title": "SessionHistoryResult", + "type": "object" + }, + "SessionInterruptParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "expected_hosted_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hosted Task Id" + } + }, + "required": [ + "session_id" + ], + "title": "SessionInterruptParams", + "type": "object" + }, + "SessionInterruptResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/InterruptStatus" + }, + "interrupted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interrupted" + }, + "turn_isolation": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Isolation" + } + }, + "required": [ + "status" + ], + "title": "SessionInterruptResult", + "type": "object" + }, + "SessionListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + }, + "include_hidden": { + "default": false, + "title": "Include Hidden", + "type": "boolean" + } + }, + "title": "SessionListParams", + "type": "object" + }, + "SessionListResult": { + "additionalProperties": false, + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionListRow" + }, + "title": "Sessions", + "type": "array" + } + }, + "required": [ + "sessions" + ], + "title": "SessionListResult", + "type": "object" + }, + "SessionListRow": { + "additionalProperties": false, + "description": "``methods_session._session_row_summary``; ``resolved_id`` only on a title lookup that followed a\ncompression lineage to its tip.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "resolved_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resolved Id" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + }, + "started_at": { + "default": 0, + "title": "Started At", + "type": "number" + }, + "message_count": { + "default": 0, + "title": "Message Count", + "type": "integer" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "SessionListRow", + "type": "object" + }, + "SessionLiveInfo": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_session_info`` \u2014 the ``session.info`` event and the ``info`` field of\n``session.create`` / ``session.resume`` / ``session.activate`` results.", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "provider": { + "default": "", + "title": "Provider", + "type": "string" + }, + "reasoning_effort": { + "default": "", + "title": "Reasoning Effort", + "type": "string" + }, + "reasoning_effort_wire": { + "default": "", + "title": "Reasoning Effort Wire", + "type": "string" + }, + "service_tier": { + "default": "", + "title": "Service Tier", + "type": "string" + }, + "fast": { + "default": false, + "title": "Fast", + "type": "boolean" + }, + "yolo": { + "default": false, + "title": "Yolo", + "type": "boolean" + }, + "approval_mode": { + "default": "manual", + "title": "Approval Mode", + "type": "string" + }, + "tools": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Tools", + "type": "object" + }, + "skills": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Skills", + "type": "object" + }, + "cwd": { + "default": "", + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "terminal_backend": { + "default": "", + "title": "Terminal Backend", + "type": "string" + }, + "personality": { + "default": "", + "title": "Personality", + "type": "string" + }, + "running": { + "default": false, + "title": "Running", + "type": "boolean" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + }, + "stored_session_id": { + "default": "", + "title": "Stored Session Id", + "type": "string" + }, + "desktop_contract": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Desktop Contract" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + }, + "release_date": { + "default": "", + "title": "Release Date", + "type": "string" + }, + "update_behind": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Update Behind" + }, + "update_command": { + "default": "", + "title": "Update Command", + "type": "string" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/Usage" + }, + { + "type": "null" + } + ], + "default": null + }, + "profile_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile Name" + }, + "mcp_servers": { + "items": { + "$ref": "#/components/schemas/McpServerStatus" + }, + "title": "Mcp Servers", + "type": "array" + }, + "system_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "System Prompt" + }, + "credential_warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Warning" + }, + "lazy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Lazy" + } + }, + "title": "SessionLiveInfo", + "type": "object" + }, + "SessionMostRecentParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SessionMostRecentParams", + "type": "object" + }, + "SessionMostRecentResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Session Id" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + } + }, + "required": [ + "session_id" + ], + "title": "SessionMostRecentResult", + "type": "object" + }, + "SessionOwner": { + "additionalProperties": false, + "properties": { + "type": { + "const": "session", + "title": "Type", + "type": "string" + }, + "session_id": { + "minLength": 1, + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "type", + "session_id" + ], + "title": "SessionOwner", + "type": "object" + }, + "SessionParams": { + "additionalProperties": false, + "description": "Any method addressed at one live session.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionParams", + "type": "object" + }, + "SessionReclaimedPayload": { + "additionalProperties": false, + "description": "``session_lifecycle._announce_session_reclaimed`` (broadcast).", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "stored_session_id": { + "title": "Stored Session Id", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "session_id", + "stored_session_id", + "reason" + ], + "title": "SessionReclaimedPayload", + "type": "object" + }, + "SessionResumeParams": { + "additionalProperties": false, + "description": "``session_id`` is the STORED id (or an exact title); the reply's ``session_id`` is the runtime id.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "lazy": { + "default": false, + "title": "Lazy", + "type": "boolean" + }, + "defer_history": { + "default": false, + "title": "Defer History", + "type": "boolean" + }, + "omit_messages": { + "default": false, + "title": "Omit Messages", + "type": "boolean" + }, + "eager_build": { + "default": false, + "title": "Eager Build", + "type": "boolean" + }, + "close_on_disconnect": { + "default": false, + "title": "Close On Disconnect", + "type": "boolean" + }, + "inline_images": { + "default": true, + "title": "Inline Images", + "type": "boolean" + } + }, + "required": [ + "session_id" + ], + "title": "SessionResumeParams", + "type": "object" + }, + "SessionResumeProgressPayload": { + "additionalProperties": false, + "description": "``server._hydrate_resume_history``.", + "properties": { + "phase": { + "title": "Phase", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ResumePhaseStatus" + }, + "message_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message Count" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + } + }, + "required": [ + "phase", + "status" + ], + "title": "SessionResumeProgressPayload", + "type": "object" + }, + "SessionResumeResult": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "message_count": { + "title": "Message Count", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/TranscriptMessage" + }, + "title": "Messages", + "type": "array" + }, + "info": { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + "stored_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stored Session Id" + }, + "resumed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resumed" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "messages_omitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Messages Omitted" + }, + "hydrating": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hydrating" + }, + "running": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Running" + }, + "turn_started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Turn Started At" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "inflight": { + "anyOf": [ + { + "$ref": "#/components/schemas/InflightTurn" + }, + { + "type": "null" + } + ], + "default": null + }, + "queued": { + "anyOf": [ + { + "$ref": "#/components/schemas/QueuedPrompt" + }, + { + "type": "null" + } + ], + "default": null + }, + "pending_approval": { + "anyOf": [ + { + "$ref": "#/components/schemas/PendingApproval" + }, + { + "type": "null" + } + ], + "default": null + }, + "open_requests": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OpenRequestEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Open Requests" + }, + "pending_connection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConnectionRequestPayload" + }, + { + "type": "null" + } + ], + "default": null + }, + "todo_state": { + "anyOf": [ + { + "$ref": "#/components/schemas/TodoState" + }, + { + "type": "null" + } + ], + "default": null + }, + "auto_continue": { + "anyOf": [ + { + "$ref": "#/components/schemas/AutoContinue" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id", + "message_count", + "messages", + "info" + ], + "title": "SessionResumeResult", + "type": "object" + }, + "SessionSaveParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionSaveParams", + "type": "object" + }, + "SessionSaveResult": { + "additionalProperties": true, + "description": "Under turn isolation the compute host's result passes through verbatim.", + "properties": { + "file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "File" + } + }, + "title": "SessionSaveResult", + "type": "object" + }, + "SessionSetHiddenParams": { + "additionalProperties": false, + "description": "``session_id`` is a live runtime id first, else a stored id / key / title.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "hidden": { + "title": "Hidden", + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "hidden" + ], + "title": "SessionSetHiddenParams", + "type": "object" + }, + "SessionSetHiddenResult": { + "additionalProperties": false, + "properties": { + "hidden": { + "title": "Hidden", + "type": "boolean" + }, + "session_key": { + "title": "Session Key", + "type": "string" + } + }, + "required": [ + "hidden", + "session_key" + ], + "title": "SessionSetHiddenResult", + "type": "object" + }, + "SessionStatusParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionStatusParams", + "type": "object" + }, + "SessionStatusResult": { + "additionalProperties": false, + "properties": { + "output": { + "title": "Output", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "SessionStatusResult", + "type": "object" + }, + "SessionTitleParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + } + }, + "required": [ + "session_id" + ], + "title": "SessionTitleParams", + "type": "object" + }, + "SessionTitlePayload": { + "additionalProperties": false, + "description": "``prompt_turn._invoke_agent`` ``_on_session_title`` hook.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "session_id", + "title" + ], + "title": "SessionTitlePayload", + "type": "object" + }, + "SessionTitleResult": { + "additionalProperties": false, + "properties": { + "title": { + "title": "Title", + "type": "string" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "pending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pending" + } + }, + "required": [ + "title" + ], + "title": "SessionTitleResult", + "type": "object" + }, + "SessionUndoParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "intent": { + "anyOf": [ + { + "$ref": "#/components/schemas/UndoIntent" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "session_id" + ], + "title": "SessionUndoParams", + "type": "object" + }, + "SessionUndoResult": { + "additionalProperties": false, + "properties": { + "removed": { + "title": "Removed", + "type": "integer" + } + }, + "required": [ + "removed" + ], + "title": "SessionUndoResult", + "type": "object" + }, + "SessionUsageParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id" + ], + "title": "SessionUsageParams", + "type": "object" + }, + "SessionUsagePayload": { + "additionalProperties": false, + "description": "``server._start_usage_ticker``.", + "properties": { + "usage": { + "$ref": "#/components/schemas/Usage" + } + }, + "required": [ + "usage" + ], + "title": "SessionUsagePayload", + "type": "object" + }, + "SessionUsageResult": { + "additionalProperties": true, + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "input": { + "default": 0, + "title": "Input", + "type": "integer" + }, + "output": { + "default": 0, + "title": "Output", + "type": "integer" + }, + "reasoning": { + "default": 0, + "title": "Reasoning", + "type": "integer" + }, + "prompt": { + "default": 0, + "title": "Prompt", + "type": "integer" + }, + "completion": { + "default": 0, + "title": "Completion", + "type": "integer" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "calls": { + "default": 0, + "title": "Calls", + "type": "integer" + }, + "compressions": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressions" + }, + "context_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Used" + }, + "context_max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Max" + }, + "context_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Percent" + }, + "context_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Source" + }, + "context_estimated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Estimated" + }, + "cache_hit_pct": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Hit Pct" + }, + "cache_read": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Read" + }, + "cache_write": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Write" + }, + "avg_latency_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Latency S" + }, + "avg_tps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Tps" + }, + "active_subagents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Subagents" + }, + "dev_credits_spent_micros": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dev Credits Spent Micros" + }, + "cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Usd" + }, + "cost_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Status" + }, + "credits_lines": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credits Lines" + } + }, + "title": "SessionUsageResult", + "type": "object" + }, + "SessionWorkspaceMoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_key": { + "title": "Session Key", + "type": "string" + }, + "cwd": { + "title": "Cwd", + "type": "string" + } + }, + "required": [ + "session_key", + "cwd" + ], + "title": "SessionWorkspaceMoveParams", + "type": "object" + }, + "SessionWorkspaceMoveResult": { + "additionalProperties": false, + "properties": { + "cwd": { + "title": "Cwd", + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Branch" + }, + "git_repo_root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Git Repo Root" + } + }, + "required": [ + "cwd" + ], + "title": "SessionWorkspaceMoveResult", + "type": "object" + }, + "SetupReadyPayload": { + "additionalProperties": true, + "description": "``hermes_cli/free_tier_bootstrap.py::SetupRecord.as_payload``.", + "properties": { + "provider_configured": { + "title": "Provider Configured", + "type": "boolean" + }, + "inference_provider": { + "title": "Inference Provider", + "type": "string" + }, + "free_tier_account": { + "title": "Free Tier Account", + "type": "boolean" + }, + "free_tier_route": { + "title": "Free Tier Route", + "type": "boolean" + }, + "has_identity": { + "title": "Has Identity", + "type": "boolean" + }, + "other_providers": { + "title": "Other Providers", + "type": "boolean" + }, + "error": { + "default": "", + "title": "Error", + "type": "string" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "finished_at": { + "title": "Finished At", + "type": "number" + } + }, + "required": [ + "provider_configured", + "inference_provider", + "free_tier_account", + "free_tier_route", + "has_identity", + "other_providers", + "finished_at" + ], + "title": "SetupReadyPayload", + "type": "object" + }, + "SetupRuntimeCheckParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + } + }, + "title": "SetupRuntimeCheckParams", + "type": "object" + }, + "SetupRuntimeCheckResult": { + "additionalProperties": false, + "description": "``ok=False`` + ``error`` when the resolved model can't be served; ``free_tier_route`` says the\nselected route is the welcome host.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "free_tier_route": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Route" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "ok" + ], + "title": "SetupRuntimeCheckResult", + "type": "object" + }, + "SetupStatusResult": { + "additionalProperties": false, + "description": "``provider_configured`` is the loose answer; the boot record's fields (``ready``,\n``free_tier_account``, ``free_tier_route``, ``other_providers``, ``inference_provider``) ride along\non the launch profile. An unknown ``profile`` answers ``ok=False`` + ``error``.", + "properties": { + "provider_configured": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider Configured" + }, + "ready": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ready" + }, + "free_tier_account": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Account" + }, + "free_tier_route": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Free Tier Route" + }, + "other_providers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Other Providers" + }, + "inference_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inference Provider" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error Code" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + } + }, + "title": "SetupStatusResult", + "type": "object" + }, + "SharedMetricsConsentResult": { + "additionalProperties": false, + "description": "The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while\n``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults\nare not an answer).", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "send": { + "title": "Send", + "type": "boolean" + }, + "decided": { + "title": "Decided", + "type": "boolean" + } + }, + "required": [ + "enabled", + "send", + "decided" + ], + "title": "SharedMetricsConsentResult", + "type": "object" + }, + "SharedMetricsDesktopActionDay": { + "additionalProperties": false, + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "via": { + "enum": [ + "click", + "menu", + "palette", + "shortcut" + ], + "title": "Via", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "action", + "via", + "count" + ], + "title": "SharedMetricsDesktopActionDay", + "type": "object" + }, + "SharedMetricsDesktopDailyParams": { + "additionalProperties": false, + "description": "One finished UTC day of Desktop use, aggregated on the client. ``day`` (YYYY-MM-DD) only latches\na resend and is never recorded; the raw counts are bucketed by the backend.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "day": { + "title": "Day", + "type": "string" + }, + "bot_count": { + "default": 0, + "title": "Bot Count", + "type": "integer" + }, + "modes": { + "items": { + "$ref": "#/components/schemas/SharedMetricsDesktopModeDay" + }, + "title": "Modes", + "type": "array" + }, + "actions": { + "items": { + "$ref": "#/components/schemas/SharedMetricsDesktopActionDay" + }, + "title": "Actions", + "type": "array" + } + }, + "required": [ + "day" + ], + "title": "SharedMetricsDesktopDailyParams", + "type": "object" + }, + "SharedMetricsDesktopDailyResult": { + "additionalProperties": false, + "properties": { + "recorded": { + "title": "Recorded", + "type": "boolean" + } + }, + "required": [ + "recorded" + ], + "title": "SharedMetricsDesktopDailyResult", + "type": "object" + }, + "SharedMetricsDesktopDislikeParams": { + "additionalProperties": false, + "description": "``signal`` quick_close|cancelled|setting_off_default|rage_click|undo|feature_disabled; ``target`` a\nclosed code-defined id for that signal (area, flow, action, undo path, feature toggle); ``setting`` a\nconfig key for setting_off_default only (the value is never sent \u2014 the backend compares it to the\ndefault).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "signal": { + "title": "Signal", + "type": "string" + }, + "target": { + "default": "", + "title": "Target", + "type": "string" + }, + "setting": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Setting" + } + }, + "required": [ + "signal" + ], + "title": "SharedMetricsDesktopDislikeParams", + "type": "object" + }, + "SharedMetricsDesktopFeatureUseParams": { + "additionalProperties": false, + "description": "``area`` is a Desktop surface id (``command_palette``, ``terminal_pane``, ``settings_`` \u2026);\nthe backend collapses anything outside its closed set to ``other``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "area": { + "title": "Area", + "type": "string" + } + }, + "required": [ + "area" + ], + "title": "SharedMetricsDesktopFeatureUseParams", + "type": "object" + }, + "SharedMetricsDesktopFrictionParams": { + "additionalProperties": false, + "description": "``kind`` notice_dismissed|error_toast|renderer_crash|backend_disconnect|slow_frame; ``detail`` a\nclosed code-defined word for that kind (notice id, error category, crash reason, drop reason, frame\nduration bucket), never message text.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + } + }, + "required": [ + "kind", + "detail" + ], + "title": "SharedMetricsDesktopFrictionParams", + "type": "object" + }, + "SharedMetricsDesktopModeDay": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "bots", + "sessions" + ], + "title": "Mode", + "type": "string" + }, + "active_ms": { + "default": 0, + "title": "Active Ms", + "type": "number" + }, + "messages_sent": { + "default": 0, + "title": "Messages Sent", + "type": "integer" + } + }, + "required": [ + "mode" + ], + "title": "SharedMetricsDesktopModeDay", + "type": "object" + }, + "SharedMetricsDesktopOnboardingParams": { + "additionalProperties": false, + "description": "``step`` a Desktop first-run step id; ``event`` reached|completed|abandoned.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "step": { + "title": "Step", + "type": "string" + }, + "event": { + "title": "Event", + "type": "string" + } + }, + "required": [ + "step", + "event" + ], + "title": "SharedMetricsDesktopOnboardingParams", + "type": "object" + }, + "SharedMetricsSetParams": { + "additionalProperties": false, + "description": "``send`` is ignored unless ``enabled``; ``first_run`` marks the Desktop first-run answer.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "send": { + "default": false, + "title": "Send", + "type": "boolean" + }, + "first_run": { + "default": false, + "title": "First Run", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "title": "SharedMetricsSetParams", + "type": "object" + }, + "SharedMetricsSlashCommandParams": { + "additionalProperties": false, + "description": "``command`` is the raw typed name (no leading ``/``, no args); the backend canonicalizes it\nagainst the published registry. ``session_id`` scopes the count to that session's profile.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "command": { + "title": "Command", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "required": [ + "command" + ], + "title": "SharedMetricsSlashCommandParams", + "type": "object" + }, + "SharedMetricsSlashCommandResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsSlashCommandResult", + "type": "object" + }, + "SharedMetricsStartupLatencyParams": { + "additionalProperties": false, + "description": "``elapsed_ms`` = the client's own launch (TUI process start / Desktop app start) to ready\n(TUI gateway ready / Desktop backend attached), measured once per launch by the client. The\nclient names its surface because a Desktop may attach to a URL/cloud backend where\n``HERMES_DESKTOP`` is unset; without it the backend falls back to its own client detection.\n``launch_id`` is an opaque per-launch token the backend latches on (never recorded), so a\nreconnect re-sending the same launch counts once while a new launch counts again.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "elapsed_ms": { + "title": "Elapsed Ms", + "type": "number" + }, + "surface": { + "anyOf": [ + { + "enum": [ + "desktop_attach", + "tui" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "launch_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Launch Id" + } + }, + "required": [ + "elapsed_ms" + ], + "title": "SharedMetricsStartupLatencyParams", + "type": "object" + }, + "SharedMetricsStartupLatencyResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsStartupLatencyResult", + "type": "object" + }, + "SharedMetricsUpdateRunParams": { + "additionalProperties": false, + "description": "One Desktop PACKAGED self-update (electron-updater / App Installer / Store). Source-checkout\nhand-offs run ``hermes update`` and are counted from its receipt, never here. Raw words; the\nbackend buckets them: ``outcome`` success|failed|noop|refused, ``failed_stage``\ndownload|verify|apply|restart, ``mechanism`` the updater strategy kind, ``duration_ms`` wall\ntime, ``from_commit_date`` the updated-from build's commit time (epoch seconds) when known.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "outcome": { + "title": "Outcome", + "type": "string" + }, + "failed_stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failed Stage" + }, + "mechanism": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mechanism" + }, + "duration_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Ms" + }, + "from_commit_date": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Commit Date" + } + }, + "required": [ + "outcome" + ], + "title": "SharedMetricsUpdateRunParams", + "type": "object" + }, + "SharedMetricsUpdateRunResult": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "SharedMetricsUpdateRunResult", + "type": "object" + }, + "ShellExecParams": { + "additionalProperties": false, + "properties": { + "command": { + "title": "Command", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "command" + ], + "title": "ShellExecParams", + "type": "object" + }, + "ShellExecResult": { + "additionalProperties": false, + "properties": { + "stdout": { + "title": "Stdout", + "type": "string" + }, + "stderr": { + "title": "Stderr", + "type": "string" + }, + "code": { + "title": "Code", + "type": "integer" + } + }, + "required": [ + "stdout", + "stderr", + "code" + ], + "title": "ShellExecResult", + "type": "object" + }, + "SideAgentCompletePayload": { + "additionalProperties": false, + "description": "``methods_prompt._spawn_side_agent``: ``{task_id, **extra, text}``; btw adds ``question``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "question": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Question" + } + }, + "required": [ + "task_id", + "text" + ], + "title": "SideAgentCompletePayload", + "type": "object" + }, + "SideAgentParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "text" + ], + "title": "SideAgentParams", + "type": "object" + }, + "SkillBrowseItem": { + "additionalProperties": true, + "description": "``hermes_cli.skills_hub.browse_skills`` row.", + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "source": { + "default": "", + "title": "Source", + "type": "string" + }, + "trust": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Trust" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + } + }, + "title": "SkillBrowseItem", + "type": "object" + }, + "SkillCatalogEntry": { + "additionalProperties": false, + "properties": { + "usage": { + "default": 0, + "title": "Usage", + "type": "integer" + }, + "origin": { + "default": "local", + "title": "Origin", + "type": "string" + } + }, + "title": "SkillCatalogEntry", + "type": "object" + }, + "SkillCommandRef": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "SkillCommandRef", + "type": "object" + }, + "SkillHubHit": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "SkillHubHit", + "type": "object" + }, + "SkillInspectInfo": { + "additionalProperties": true, + "description": "``hermes_cli.skills_hub.inspect_skill``; ``{}`` when the identifier resolves nowhere.", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tags" + }, + "skill_md_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skill Md Preview" + } + }, + "title": "SkillInspectInfo", + "type": "object" + }, + "SkillsAction": { + "enum": [ + "list", + "search", + "install", + "browse", + "inspect" + ], + "title": "SkillsAction", + "type": "string" + }, + "SkillsManageParams": { + "additionalProperties": false, + "description": "``query`` is the search text / hub identifier / browse page (digits); ``page`` / ``page_size``\napply to ``browse``.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "action": { + "$ref": "#/components/schemas/SkillsAction", + "default": "list" + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Query" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "page_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page Size" + } + }, + "title": "SkillsManageParams", + "type": "object" + }, + "SkillsManageResult": { + "additionalProperties": false, + "description": "Shape follows the action: ``list`` \u2192 ``skills`` (category \u2192 names); ``search`` \u2192 ``results``;\n``install`` \u2192 ``installed`` + ``name``; ``browse`` \u2192 ``items`` + paging; ``inspect`` \u2192 ``info``.", + "properties": { + "skills": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Skills" + }, + "results": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SkillHubHit" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Results" + }, + "installed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Installed" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "items": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SkillBrowseItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Items" + }, + "page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "total_pages": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Pages" + }, + "total": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillInspectInfo" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "SkillsManageResult", + "type": "object" + }, + "SkillsReloadDiff": { + "additionalProperties": true, + "description": "``agent.skill_commands.reload_skills``.", + "properties": { + "added": { + "items": { + "$ref": "#/components/schemas/SkillCommandRef" + }, + "title": "Added", + "type": "array" + }, + "removed": { + "items": { + "$ref": "#/components/schemas/SkillCommandRef" + }, + "title": "Removed", + "type": "array" + }, + "unchanged": { + "items": { + "type": "string" + }, + "title": "Unchanged", + "type": "array" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "commands": { + "default": 0, + "title": "Commands", + "type": "integer" + } + }, + "title": "SkillsReloadDiff", + "type": "object" + }, + "SkillsReloadParams": { + "additionalProperties": false, + "description": "``session_id`` binds the rescan to that session's profile and workspace (project skills);\n``profile`` scopes a session-less rescan.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SkillsReloadParams", + "type": "object" + }, + "SkillsReloadResult": { + "additionalProperties": false, + "properties": { + "output": { + "title": "Output", + "type": "string" + }, + "result": { + "$ref": "#/components/schemas/SkillsReloadDiff" + } + }, + "required": [ + "output", + "result" + ], + "title": "SkillsReloadResult", + "type": "object" + }, + "SkinPayload": { + "additionalProperties": true, + "description": "``tui_gateway/change_watcher.py::resolve_skin`` \u2014 the resolved active skin (``HermesSkin``).\n``{}`` when the skin engine failed to load. Colour maps are token \u2192 colour string.", + "properties": { + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Colors", + "type": "object" + }, + "light_colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Light Colors", + "type": "object" + }, + "dark_colors": { + "additionalProperties": { + "type": "string" + }, + "title": "Dark Colors", + "type": "object" + }, + "branding": { + "additionalProperties": { + "type": "string" + }, + "title": "Branding", + "type": "object" + }, + "banner_logo": { + "default": "", + "title": "Banner Logo", + "type": "string" + }, + "banner_hero": { + "default": "", + "title": "Banner Hero", + "type": "string" + }, + "tool_prefix": { + "default": "", + "title": "Tool Prefix", + "type": "string" + }, + "help_header": { + "default": "", + "title": "Help Header", + "type": "string" + } + }, + "title": "SkinPayload", + "type": "object" + }, + "SlashExecParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "title": "Command", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "session_id", + "command" + ], + "title": "SlashExecParams", + "type": "object" + }, + "SlashExecResult": { + "additionalProperties": false, + "description": "Plain worker/plugin text in ``output`` (+ ``warning``), or \u2014 when the command was rerouted to\n``command.dispatch`` \u2014 that method's directive fields with ``type`` set.", + "properties": { + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Warning" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/DispatchType" + }, + { + "type": "null" + } + ], + "default": null + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "notice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notice" + }, + "display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + } + }, + "title": "SlashExecResult", + "type": "object" + }, + "SpawnTreeEntry": { + "additionalProperties": true, + "description": "Index row (``server._append_spawn_tree_index``) or a legacy file scan.", + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "count": { + "default": 0, + "title": "Count", + "type": "integer" + } + }, + "required": [ + "path" + ], + "title": "SpawnTreeEntry", + "type": "object" + }, + "SpawnTreeListParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "cross_session": { + "default": false, + "title": "Cross Session", + "type": "boolean" + }, + "limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" + } + }, + "title": "SpawnTreeListParams", + "type": "object" + }, + "SpawnTreeListResult": { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "$ref": "#/components/schemas/SpawnTreeEntry" + }, + "title": "Entries", + "type": "array" + } + }, + "required": [ + "entries" + ], + "title": "SpawnTreeListResult", + "type": "object" + }, + "SpawnTreeLoadParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "SpawnTreeLoadParams", + "type": "object" + }, + "SpawnTreeLoadResult": { + "additionalProperties": true, + "description": "The snapshot file as written by ``spawn_tree.save`` (open: the file is the contract).", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "subagents": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Subagents", + "type": "array" + } + }, + "title": "SpawnTreeLoadResult", + "type": "object" + }, + "SpawnTreeSaveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagents": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Subagents", + "type": "array" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Finished At" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + } + }, + "required": [ + "subagents" + ], + "title": "SpawnTreeSaveParams", + "type": "object" + }, + "SpawnTreeSaveResult": { + "additionalProperties": false, + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "session_id": { + "title": "Session Id", + "type": "string" + } + }, + "required": [ + "path", + "session_id" + ], + "title": "SpawnTreeSaveResult", + "type": "object" + }, + "StatusUpdatePayload": { + "additionalProperties": false, + "description": "``server._status_update`` and the direct emitters (goal / loop / heartbeat / process).", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "kind", + "text" + ], + "title": "StatusUpdatePayload", + "type": "object" + }, + "SteerStatus": { + "enum": [ + "queued", + "rejected" + ], + "title": "SteerStatus", + "type": "string" + }, + "StreamDeltaPayload": { + "additionalProperties": false, + "description": "``prompt_turn._invoke_agent._stream`` (message.delta: ``text`` + optional ``rendered``),\n``agent_callbacks._agent_cbs`` (reasoning.delta / thinking.delta), ``tool_progress._progress_reasoning``\n(reasoning.available). ``verbose`` rides only when the session's verbose reasoning mode is on.", + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "rendered": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rendered" + }, + "verbose": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verbose" + } + }, + "required": [ + "text" + ], + "title": "StreamDeltaPayload", + "type": "object" + }, + "SubagentEventPayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_subagent`` \u2014 every ``subagent.*`` frame; identity fields are optional\nbecause older emitters omit them and the TUI spawn tree falls back to flat rendering.", + "properties": { + "goal": { + "title": "Goal", + "type": "string" + }, + "task_count": { + "title": "Task Count", + "type": "integer" + }, + "task_index": { + "title": "Task Index", + "type": "integer" + }, + "subagent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subagent Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "child_session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Child Session Id" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "toolsets": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Toolsets" + }, + "input_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Input Tokens" + }, + "output_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tokens" + }, + "reasoning_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning Tokens" + }, + "api_calls": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Calls" + }, + "files_read": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Files Read" + }, + "files_written": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Files Written" + }, + "output_tail": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SubagentOutputTailEntry" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Tail" + }, + "tool_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Name" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubagentStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, + "duration_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Seconds" + }, + "tool_preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Preview" + } + }, + "required": [ + "goal", + "task_count", + "task_index" + ], + "title": "SubagentEventPayload", + "type": "object" + }, + "SubagentIdParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + } + }, + "required": [ + "session_id", + "subagent_id" + ], + "title": "SubagentIdParams", + "type": "object" + }, + "SubagentInterruptResult": { + "additionalProperties": false, + "properties": { + "found": { + "title": "Found", + "type": "boolean" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + } + }, + "required": [ + "found", + "subagent_id" + ], + "title": "SubagentInterruptResult", + "type": "object" + }, + "SubagentListResult": { + "additionalProperties": false, + "description": "``delegations``: recently failed async delegation tasks for the session (durable store), newest first.", + "properties": { + "subagents": { + "items": { + "$ref": "#/components/schemas/SubagentSnapshot" + }, + "title": "Subagents", + "type": "array" + }, + "delegations": { + "items": { + "$ref": "#/components/schemas/FailedDelegation" + }, + "title": "Delegations", + "type": "array" + } + }, + "title": "SubagentListResult", + "type": "object" + }, + "SubagentOutputTailEntry": { + "additionalProperties": false, + "description": "``tools/delegate_tool_results.py::_extract_output_tail`` row.", + "properties": { + "tool": { + "title": "Tool", + "type": "string" + }, + "preview": { + "title": "Preview", + "type": "string" + }, + "is_error": { + "title": "Is Error", + "type": "boolean" + } + }, + "required": [ + "tool", + "preview", + "is_error" + ], + "title": "SubagentOutputTailEntry", + "type": "object" + }, + "SubagentSnapshot": { + "additionalProperties": false, + "description": "``methods_subagents._SUBAGENT_SNAPSHOT_FIELDS`` projection of one live child record.", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "parent_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Id" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + }, + "goal": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Goal" + }, + "delegation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Delegation Id" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "started_at": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Started At" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubagentStatus" + }, + { + "type": "null" + } + ], + "default": null + }, + "tool_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Count" + }, + "last_tool": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Tool" + }, + "accepting_steer": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Accepting Steer" + } + }, + "required": [ + "subagent_id" + ], + "title": "SubagentSnapshot", + "type": "object" + }, + "SubagentStatus": { + "description": "Lifecycle of one delegated child (``tools/delegate_tool_child_run.py``); ``failed`` /\n``error`` / ``timeout`` / ``interrupted`` / ``completed`` are terminal.", + "enum": [ + "queued", + "running", + "completed", + "failed", + "error", + "timeout", + "interrupted" + ], + "title": "SubagentStatus", + "type": "string" + }, + "SubagentSteerParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "session_id", + "subagent_id", + "text" + ], + "title": "SubagentSteerParams", + "type": "object" + }, + "SubagentSteerResult": { + "additionalProperties": false, + "description": "``queued`` is not ``delivered``: a child past its final tool batch surfaces ``missed_steer``.", + "properties": { + "status": { + "$ref": "#/components/schemas/SteerStatus" + }, + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "status", + "subagent_id", + "text" + ], + "title": "SubagentSteerResult", + "type": "object" + }, + "SubagentTailResult": { + "additionalProperties": false, + "description": "``available`` is false while the child has no live transcript yet (or it was cleaned up).", + "properties": { + "subagent_id": { + "title": "Subagent Id", + "type": "string" + }, + "available": { + "default": false, + "title": "Available", + "type": "boolean" + }, + "text": { + "default": "", + "title": "Text", + "type": "string" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + } + }, + "required": [ + "subagent_id" + ], + "title": "SubagentTailResult", + "type": "object" + }, + "SubscriptionChangeEffect": { + "enum": [ + "charge_now", + "scheduled", + "no_op", + "blocked" + ], + "title": "SubscriptionChangeEffect", + "type": "string" + }, + "SubscriptionChangeParams": { + "additionalProperties": false, + "description": "Either a target tier (downgrade / same-price change) or ``cancel`` (period-end cancellation).", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + }, + "cancel": { + "default": false, + "title": "Cancel", + "type": "boolean" + } + }, + "title": "SubscriptionChangeParams", + "type": "object" + }, + "SubscriptionContext": { + "enum": [ + "personal", + "team" + ], + "title": "SubscriptionContext", + "type": "string" + }, + "SubscriptionPreviewParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + } + }, + "title": "SubscriptionPreviewParams", + "type": "object" + }, + "SubscriptionPreviewResult": { + "additionalProperties": false, + "description": "``_serialize_subscription_preview`` on success; ``effect`` drives the confirm copy.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "effect": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionChangeEffect" + }, + { + "type": "null" + } + ], + "default": null + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "current_tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Tier Id" + }, + "current_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Current Tier Name" + }, + "target_tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Id" + }, + "target_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Name" + }, + "monthly_credits_delta": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits Delta" + }, + "amount_due_now_cents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Amount Due Now Cents" + }, + "effective_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effective At" + } + }, + "required": [ + "ok" + ], + "title": "SubscriptionPreviewResult", + "type": "object" + }, + "SubscriptionStateResult": { + "additionalProperties": false, + "description": "``_serialize_subscription_state``; the view's fallback emits only ``ok / logged_in / error``.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "logged_in": { + "title": "Logged In", + "type": "boolean" + }, + "is_admin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Is Admin" + }, + "can_change_plan": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Can Change Plan" + }, + "org_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Name" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Org Id" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, + "context": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionContext" + }, + { + "type": "null" + } + ], + "default": null + }, + "current": { + "anyOf": [ + { + "$ref": "#/components/schemas/CurrentSubscription" + }, + { + "type": "null" + } + ], + "default": null + }, + "tiers": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SubscriptionTierOption" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tiers" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "ok", + "logged_in" + ], + "title": "SubscriptionStateResult", + "type": "object" + }, + "SubscriptionTierOption": { + "additionalProperties": false, + "properties": { + "tier_id": { + "title": "Tier Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tier_order": { + "title": "Tier Order", + "type": "integer" + }, + "dollars_per_month_display": { + "title": "Dollars Per Month Display", + "type": "string" + }, + "monthly_credits": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Monthly Credits" + }, + "is_current": { + "title": "Is Current", + "type": "boolean" + }, + "is_enabled": { + "title": "Is Enabled", + "type": "boolean" + } + }, + "required": [ + "tier_id", + "name", + "tier_order", + "dollars_per_month_display", + "is_current", + "is_enabled" + ], + "title": "SubscriptionTierOption", + "type": "object" + }, + "SubscriptionUpgradeParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "subscription_type_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Type Id" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "title": "SubscriptionUpgradeParams", + "type": "object" + }, + "SubscriptionUpgradeResult": { + "additionalProperties": false, + "description": "The money route: ``status`` separates a completed upgrade from an SCA / decline that must\nfinish in the portal at ``recovery_url``; ``idempotency_key`` is echoed (also on error) so a\nretry reuses it.", + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "portal_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Portal Url" + }, + "retry_after": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retry After" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Payload" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code" + }, + "recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "target_tier_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Tier Name" + }, + "recovery_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Recovery Url" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "idempotency_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Idempotency Key" + } + }, + "required": [ + "ok" + ], + "title": "SubscriptionUpgradeResult", + "type": "object" + }, + "SudoRequestParams": { + "additionalProperties": false, + "description": "Original command, redacted server-side before any password-injection rewrite.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SudoRequestParams", + "type": "object" + }, + "SystemBatteryParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "SystemBatteryParams", + "type": "object" + }, + "SystemBatteryResult": { + "additionalProperties": false, + "properties": { + "available": { + "title": "Available", + "type": "boolean" + }, + "percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Percent" + }, + "plugged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plugged" + }, + "category": { + "$ref": "#/components/schemas/BatteryCategory", + "default": "dim" + } + }, + "required": [ + "available" + ], + "title": "SystemBatteryResult", + "type": "object" + }, + "TaskIdResult": { + "additionalProperties": false, + "description": "The side agent runs detached; its answer lands on the parent session as an event carrying\nthis ``task_id``.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "title": "TaskIdResult", + "type": "object" + }, + "TerminalClosePayload": { + "additionalProperties": false, + "properties": { + "process_id": { + "title": "Process Id", + "type": "string" + } + }, + "required": [ + "process_id" + ], + "title": "TerminalClosePayload", + "type": "object" + }, + "TerminalOutputPayload": { + "additionalProperties": false, + "description": "``session_notifications`` process_registry.on_output.", + "properties": { + "process_id": { + "title": "Process Id", + "type": "string" + }, + "chunk": { + "title": "Chunk", + "type": "string" + } + }, + "required": [ + "process_id", + "chunk" + ], + "title": "TerminalOutputPayload", + "type": "object" + }, + "TerminalResizeParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "cols": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cols" + } + }, + "required": [ + "session_id" + ], + "title": "TerminalResizeParams", + "type": "object" + }, + "TerminalResizeResult": { + "additionalProperties": false, + "properties": { + "cols": { + "title": "Cols", + "type": "integer" + } + }, + "required": [ + "cols" + ], + "title": "TerminalResizeResult", + "type": "object" + }, + "TipShowPayload": { + "additionalProperties": false, + "description": "``tools/tip_tool.py``.", + "properties": { + "selector": { + "title": "Selector", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + } + }, + "required": [ + "selector", + "text" + ], + "title": "TipShowPayload", + "type": "object" + }, + "TodoState": { + "additionalProperties": false, + "description": "``tool_progress._normalize_todo_state``: the authoritative todo snapshot.", + "properties": { + "todos": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Todos", + "type": "array" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "todos", + "revision" + ], + "title": "TodoState", + "type": "object" + }, + "TodoUpdatedPayload": { + "additionalProperties": false, + "description": "``tool_progress._normalize_todo_state`` \u2014 full task snapshot.", + "properties": { + "todos": { + "items": {}, + "title": "Todos", + "type": "array" + }, + "revision": { + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "todos", + "revision" + ], + "title": "TodoUpdatedPayload", + "type": "object" + }, + "ToolCompletePayload": { + "additionalProperties": false, + "description": "``tool_progress._on_tool_complete``; ``todos``/``revision`` merged in for the todo tools.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "duration_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration S" + }, + "result": { + "default": null, + "title": "Result" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, + "result_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result Text" + }, + "inline_diff": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inline Diff" + }, + "todos": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Todos" + }, + "revision": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revision" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + } + }, + "required": [ + "tool_id", + "name" + ], + "title": "ToolCompletePayload", + "type": "object" + }, + "ToolGeneratingPayload": { + "additionalProperties": false, + "description": "``agent_callbacks`` tool_gen_callback.", + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ToolGeneratingPayload", + "type": "object" + }, + "ToolLabel": { + "additionalProperties": false, + "description": "``tools.tool_labels.ToolLabel`` \u2014 what one call executed through the tool_search bridge is,\nin words. Clients render ``text`` (or ``app``/``action`` in their own columns) and never parse\nthe tool name themselves.", + "properties": { + "kind": { + "$ref": "#/components/schemas/ToolLabelKind" + }, + "app": { + "title": "App", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "emoji": { + "title": "Emoji", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "preview": { + "default": "", + "title": "Preview", + "type": "string" + } + }, + "required": [ + "kind", + "app", + "action", + "emoji", + "text", + "name" + ], + "title": "ToolLabel", + "type": "object" + }, + "ToolLabelKind": { + "description": "Which surface one inner call of a bridged ``tool_call`` runs on.", + "enum": [ + "connector", + "mcp", + "tool" + ], + "title": "ToolLabelKind", + "type": "string" + }, + "ToolOutputRiskPayload": { + "additionalProperties": false, + "description": "``tool_progress._progress_output_risk``.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "risk": { + "title": "Risk", + "type": "string" + }, + "findings": { + "items": { + "type": "string" + }, + "title": "Findings", + "type": "array" + }, + "redacted": { + "title": "Redacted", + "type": "boolean" + } + }, + "required": [ + "tool_id", + "name", + "risk", + "findings", + "redacted" + ], + "title": "ToolOutputRiskPayload", + "type": "object" + }, + "ToolShowRow": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "title": "ToolShowRow", + "type": "object" + }, + "ToolShowSection": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/ToolShowRow" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "name", + "tools" + ], + "title": "ToolShowSection", + "type": "object" + }, + "ToolStartPayload": { + "additionalProperties": false, + "description": "``tool_progress._on_tool_start`` (+ ``agent_callbacks._mirror_subagent_to_child`` rows with\n``preview`` and empty ``args``). ``todos``/``revision`` are NOT set by the emitter; kept optional\nbecause tool.start rows may pass through connector redaction unchanged.", + "properties": { + "tool_id": { + "title": "Tool Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "args_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args Text" + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Preview" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + } + }, + "required": [ + "tool_id", + "name" + ], + "title": "ToolStartPayload", + "type": "object" + }, + "ToolsAction": { + "enum": [ + "enable", + "disable" + ], + "title": "ToolsAction", + "type": "string" + }, + "ToolsChange": { + "additionalProperties": false, + "properties": { + "type": { + "const": "tools", + "title": "Type", + "type": "string" + }, + "connector": { + "title": "Connector", + "type": "string" + }, + "disabled_tools": { + "items": { + "type": "string" + }, + "maxItems": 2000, + "title": "Disabled Tools", + "type": "array" + } + }, + "required": [ + "type", + "connector", + "disabled_tools" + ], + "title": "ToolsChange", + "type": "object" + }, + "ToolsConfigureParams": { + "additionalProperties": false, + "description": "``names`` are toolset keys or ``server:tool`` MCP targets; with ``session_id`` the live session's\nprofile is authoritative and its agent is rebuilt.", + "properties": { + "action": { + "$ref": "#/components/schemas/ToolsAction" + }, + "names": { + "items": { + "type": "string" + }, + "title": "Names", + "type": "array" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "action", + "names" + ], + "title": "ToolsConfigureParams", + "type": "object" + }, + "ToolsConfigureResult": { + "additionalProperties": false, + "properties": { + "changed": { + "items": { + "type": "string" + }, + "title": "Changed", + "type": "array" + }, + "enabled_toolsets": { + "items": { + "type": "string" + }, + "title": "Enabled Toolsets", + "type": "array" + }, + "info": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLiveInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "missing_servers": { + "items": { + "type": "string" + }, + "title": "Missing Servers", + "type": "array" + }, + "reset": { + "title": "Reset", + "type": "boolean" + }, + "unknown": { + "items": { + "type": "string" + }, + "title": "Unknown", + "type": "array" + } + }, + "required": [ + "changed", + "enabled_toolsets", + "missing_servers", + "reset", + "unknown" + ], + "title": "ToolsConfigureResult", + "type": "object" + }, + "ToolsShowResult": { + "additionalProperties": false, + "properties": { + "sections": { + "items": { + "$ref": "#/components/schemas/ToolShowSection" + }, + "title": "Sections", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "sections", + "total" + ], + "title": "ToolsShowResult", + "type": "object" + }, + "ToolsetEntry": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "tool_count": { + "default": 0, + "title": "Tool Count", + "type": "integer" + } + }, + "required": [ + "name" + ], + "title": "ToolsetEntry", + "type": "object" + }, + "ToolsetRow": { + "additionalProperties": false, + "description": "One row of ``methods_tools._toolset_rows``; ``tools`` only when the caller asked for them\n(``tools.list``).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tool_count": { + "title": "Tool Count", + "type": "integer" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "tools": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tools" + } + }, + "required": [ + "name", + "description", + "tool_count", + "enabled" + ], + "title": "ToolsetRow", + "type": "object" + }, + "ToolsetsListResult": { + "additionalProperties": false, + "properties": { + "toolsets": { + "items": { + "$ref": "#/components/schemas/ToolsetRow" + }, + "title": "Toolsets", + "type": "array" + } + }, + "required": [ + "toolsets" + ], + "title": "ToolsetsListResult", + "type": "object" + }, + "TourRequestParams": { + "additionalProperties": false, + "description": "``tools/tour_tool.py`` field set.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "action": { + "title": "Action", + "type": "string" + }, + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + }, + "steps": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TourStep" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Steps" + }, + "step_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Step Index" + } + }, + "required": [ + "session_id", + "action" + ], + "title": "TourRequestParams", + "type": "object" + }, + "TourStep": { + "additionalProperties": true, + "properties": { + "selector": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "side": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Side" + } + }, + "title": "TourStep", + "type": "object" + }, + "TranscriptMessage": { + "additionalProperties": true, + "description": "One transcript row as the gateway PROJECTS it for renderers (``session_history._project_history``):\n``text``, display-only ``timestamp`` / ``display_kind`` / ``display_metadata``, the durable ``row_id``\nrewind targets, and for tool rows raw ``content``, ``tool_call_id``, ``name``, ``context`` and ``args``.\nAssistant detail sidecars (``reasoning``, \u2026) ride as extra keys.", + "properties": { + "role": { + "title": "Role", + "type": "string" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "content": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Content" + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Call Id" + }, + "timestamp": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Timestamp" + }, + "row_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Row Id" + }, + "display_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Display Kind" + }, + "display_metadata": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Display Metadata" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" + }, + "labels": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolLabel" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Labels" + }, + "reasoning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reasoning" + } + }, + "required": [ + "role" + ], + "title": "TranscriptMessage", + "type": "object" + }, + "TurnStatus": { + "description": "``prompt_turn._result_status``.", + "enum": [ + "complete", + "error", + "interrupted" + ], + "title": "TurnStatus", + "type": "string" + }, + "UiMetaConflict": { + "additionalProperties": false, + "properties": { + "expected": { + "default": null, + "title": "Expected" + }, + "actual": { + "default": 0, + "title": "Actual", + "type": "integer" + } + }, + "title": "UiMetaConflict", + "type": "object" + }, + "UndoIntent": { + "enum": [ + "retry", + "undo" + ], + "title": "UndoIntent", + "type": "string" + }, + "Usage": { + "additionalProperties": true, + "description": "``tui_gateway/server.py::_get_usage`` + ``agent/context_breakdown.py::context_usage_fields``.", + "properties": { + "model": { + "default": "", + "title": "Model", + "type": "string" + }, + "input": { + "default": 0, + "title": "Input", + "type": "integer" + }, + "output": { + "default": 0, + "title": "Output", + "type": "integer" + }, + "reasoning": { + "default": 0, + "title": "Reasoning", + "type": "integer" + }, + "prompt": { + "default": 0, + "title": "Prompt", + "type": "integer" + }, + "completion": { + "default": 0, + "title": "Completion", + "type": "integer" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "calls": { + "default": 0, + "title": "Calls", + "type": "integer" + }, + "compressions": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Compressions" + }, + "context_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Used" + }, + "context_max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Max" + }, + "context_percent": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Percent" + }, + "context_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Source" + }, + "context_estimated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context Estimated" + }, + "cache_hit_pct": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Hit Pct" + }, + "cache_read": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Read" + }, + "cache_write": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cache Write" + }, + "avg_latency_s": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Latency S" + }, + "avg_tps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Avg Tps" + }, + "active_subagents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Active Subagents" + }, + "dev_credits_spent_micros": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dev Credits Spent Micros" + }, + "cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Usd" + }, + "cost_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cost Status" + } + }, + "title": "Usage", + "type": "object" + }, + "UsageBar": { + "additionalProperties": false, + "description": "``_serialize_usage_bar``: one bar, magnitudes pre-formatted; ``pct_used`` only for ``plan``.", + "properties": { + "kind": { + "$ref": "#/components/schemas/UsageBarKind" + }, + "remaining_display": { + "title": "Remaining Display", + "type": "string" + }, + "total_display": { + "title": "Total Display", + "type": "string" + }, + "spent_display": { + "title": "Spent Display", + "type": "string" + }, + "pct_used": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pct Used" + }, + "fill_fraction": { + "title": "Fill Fraction", + "type": "number" + } + }, + "required": [ + "kind", + "remaining_display", + "total_display", + "spent_display", + "fill_fraction" + ], + "title": "UsageBar", + "type": "object" + }, + "UsageBarKind": { + "enum": [ + "plan", + "topup" + ], + "title": "UsageBarKind", + "type": "string" + }, + "UsageModel": { + "additionalProperties": false, + "description": "``_serialize_usage_model`` \u2014 also embedded as ``usage`` in the billing / subscription states,\nwhere the fail-open form is a bare ``{available: false}`` (no ``ok``).", + "properties": { + "ok": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ok" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "plan_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Plan Name" + }, + "renews_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Renews At" + }, + "renews_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Renews Display" + }, + "subscription_remaining_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subscription Remaining Display" + }, + "topup_remaining_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Topup Remaining Display" + }, + "total_spendable_display": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Spendable Display" + }, + "has_topup": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Topup" + }, + "plan_bar": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageBar" + }, + { + "type": "null" + } + ], + "default": null + }, + "topup_bar": { + "anyOf": [ + { + "$ref": "#/components/schemas/UsageBar" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "available" + ], + "title": "UsageModel", + "type": "object" + }, + "ValueResult": { + "additionalProperties": false, + "description": "The answer to any one-string prompt (sudo, secret, vault prompts, desktop bridges):\n``''`` means skipped / declined.", + "properties": { + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "ValueResult", + "type": "object" + }, + "VaultAddParams": { + "additionalProperties": false, + "description": "``secret`` goes straight into the encrypted store; the result carries only the new id.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/VaultKind" + }, + { + "type": "null" + } + ], + "default": null + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Label" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Origin" + }, + "secret": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Secret" + } + }, + "title": "VaultAddParams", + "type": "object" + }, + "VaultAddResult": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "VaultAddResult", + "type": "object" + }, + "VaultCodeRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "site": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Site" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + } + }, + "required": [ + "session_id" + ], + "title": "VaultCodeRequestParams", + "type": "object" + }, + "VaultItem": { + "additionalProperties": false, + "description": "Metadata-only view (``VaultItemMeta.to_dict`` + ``backend``); never a secret.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Origin" + }, + "created_at": { + "default": "", + "title": "Created At", + "type": "string" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier" + }, + "identifier_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identifier Type" + }, + "has_otp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Has Otp" + }, + "backend": { + "title": "Backend", + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "backend" + ], + "title": "VaultItem", + "type": "object" + }, + "VaultKind": { + "enum": [ + "login", + "payment", + "address" + ], + "title": "VaultKind", + "type": "string" + }, + "VaultListResult": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/VaultItem" + }, + "title": "Items", + "type": "array" + } + }, + "title": "VaultListResult", + "type": "object" + }, + "VaultLockParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + } + }, + "title": "VaultLockParams", + "type": "object" + }, + "VaultLockResult": { + "additionalProperties": false, + "properties": { + "locked": { + "default": true, + "title": "Locked", + "type": "boolean" + } + }, + "title": "VaultLockResult", + "type": "object" + }, + "VaultRemoveParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + } + }, + "title": "VaultRemoveParams", + "type": "object" + }, + "VaultRemoveResult": { + "additionalProperties": false, + "properties": { + "removed": { + "title": "Removed", + "type": "boolean" + } + }, + "required": [ + "removed" + ], + "title": "VaultRemoveResult", + "type": "object" + }, + "VaultSaveLoginRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "origin": { + "title": "Origin", + "type": "string" + }, + "site": { + "title": "Site", + "type": "string" + } + }, + "required": [ + "session_id", + "origin", + "site" + ], + "title": "VaultSaveLoginRequestParams", + "type": "object" + }, + "VaultSource": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "needs_unlock": { + "title": "Needs Unlock", + "type": "boolean" + }, + "unlocked": { + "title": "Unlocked", + "type": "boolean" + }, + "installed": { + "title": "Installed", + "type": "boolean" + } + }, + "required": [ + "name", + "display_name", + "enabled", + "needs_unlock", + "unlocked", + "installed" + ], + "title": "VaultSource", + "type": "object" + }, + "VaultSourceSetParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + } + }, + "title": "VaultSourceSetParams", + "type": "object" + }, + "VaultSourceSetResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "name", + "enabled" + ], + "title": "VaultSourceSetResult", + "type": "object" + }, + "VaultSourcesResult": { + "additionalProperties": false, + "properties": { + "sources": { + "items": { + "$ref": "#/components/schemas/VaultSource" + }, + "title": "Sources", + "type": "array" + } + }, + "title": "VaultSourcesResult", + "type": "object" + }, + "VaultUnlockParams": { + "additionalProperties": false, + "description": "The master password is consumed by the manager CLI and never stored or logged.", + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Password" + } + }, + "title": "VaultUnlockParams", + "type": "object" + }, + "VaultUnlockRequestParams": { + "additionalProperties": false, + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "backend": { + "title": "Backend", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + } + }, + "required": [ + "session_id", + "backend", + "display_name" + ], + "title": "VaultUnlockRequestParams", + "type": "object" + }, + "VaultUnlockResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "unlocked": { + "default": true, + "title": "Unlocked", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "title": "VaultUnlockResult", + "type": "object" + }, + "VerificationEvidenceRow": { + "additionalProperties": true, + "description": "One ``verification_events`` row (``agent/verification_evidence.py``).", + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Id" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command" + }, + "canonical_command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Command" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Kind" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scope" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Status" + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Exit Code" + }, + "output_summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Output Summary" + } + }, + "title": "VerificationEvidenceRow", + "type": "object" + }, + "VerificationStatusInfo": { + "additionalProperties": false, + "description": "``verification_status()``: ``disabled`` / ``not_applicable`` / ``unverified`` / ``stale`` or the\nlatest event's own status; ``root`` and friends only once a workspace was identified.", + "properties": { + "status": { + "title": "Status", + "type": "string" + }, + "evidence": { + "anyOf": [ + { + "$ref": "#/components/schemas/VerificationEvidenceRow" + }, + { + "type": "null" + } + ], + "default": null + }, + "root": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Root" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "changed_paths": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Changed Paths" + } + }, + "required": [ + "status" + ], + "title": "VerificationStatusInfo", + "type": "object" + }, + "VerificationStatusParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "session_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Key" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + } + }, + "title": "VerificationStatusParams", + "type": "object" + }, + "VerificationStatusResult": { + "additionalProperties": false, + "properties": { + "verification": { + "$ref": "#/components/schemas/VerificationStatusInfo" + } + }, + "required": [ + "verification" + ], + "title": "VerificationStatusResult", + "type": "object" + }, + "VoiceRecordAction": { + "enum": [ + "start", + "stop" + ], + "title": "VoiceRecordAction", + "type": "string" + }, + "VoiceRecordParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/VoiceRecordAction", + "default": "start" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "VoiceRecordParams", + "type": "object" + }, + "VoiceRecordResult": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/VoiceRecordStatus" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + } + }, + "required": [ + "status" + ], + "title": "VoiceRecordResult", + "type": "object" + }, + "VoiceRecordStatus": { + "enum": [ + "recording", + "stopped", + "busy" + ], + "title": "VoiceRecordStatus", + "type": "string" + }, + "VoiceStatusPayload": { + "additionalProperties": false, + "description": "``methods_voice._vr_on_status``; states come from the recorder (idle / listening / transcribing \u2026).", + "properties": { + "state": { + "title": "State", + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "VoiceStatusPayload", + "type": "object" + }, + "VoiceToggleAction": { + "enum": [ + "status", + "on", + "off", + "tts" + ], + "title": "VoiceToggleAction", + "type": "string" + }, + "VoiceToggleParams": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/VoiceToggleAction", + "default": "status" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "VoiceToggleParams", + "type": "object" + }, + "VoiceToggleResult": { + "additionalProperties": false, + "description": "``methods_voice.py::_voice_status_payload`` (+ the requirements probe on ``status``, the spoken\nstop hint on ``on``).", + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "record_key": { + "title": "Record Key", + "type": "string" + }, + "tts": { + "title": "Tts", + "type": "boolean" + }, + "stop_hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stop Hint" + }, + "available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Available" + }, + "audio_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audio Available" + }, + "stt_available": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stt Available" + }, + "details": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Details" + } + }, + "required": [ + "enabled", + "record_key", + "tts" + ], + "title": "VoiceToggleResult", + "type": "object" + }, + "VoiceTranscriptPayload": { + "additionalProperties": false, + "description": "``methods_voice._vr_transcript`` / ``_deliver_fd_transcript`` / typed stop phrase in methods_prompt.", + "properties": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + }, + "stop_phrase": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stop Phrase" + }, + "typed": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Typed" + }, + "no_speech_limit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "No Speech Limit" + } + }, + "title": "VoiceTranscriptPayload", + "type": "object" + }, + "VoiceTtsParams": { + "additionalProperties": false, + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "required": [ + "text" + ], + "title": "VoiceTtsParams", + "type": "object" + }, + "VoiceTtsResult": { + "additionalProperties": false, + "properties": { + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "status" + ], + "title": "VoiceTtsResult", + "type": "object" + }, + "WaitBarrierTarget": { + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "session", + "pid" + ], + "title": "Type", + "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "title": "Target" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "type", + "target" + ], + "title": "WaitBarrierTarget", + "type": "object" + }, + "WaitBarrierUntil": { + "additionalProperties": false, + "properties": { + "type": { + "const": "until", + "title": "Type", + "type": "string" + }, + "until_at": { + "title": "Until At", + "type": "number" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + } + }, + "required": [ + "type", + "until_at" + ], + "title": "WaitBarrierUntil", + "type": "object" + }, + "WakeControlParams": { + "additionalProperties": false, + "properties": { + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeControlParams", + "type": "object" + }, + "WakeDetectedPayload": { + "additionalProperties": false, + "description": "``methods_voice`` wake detector ``_on_detect``.", + "properties": { + "phrase": { + "title": "Phrase", + "type": "string" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + }, + "start_new_session": { + "title": "Start New Session", + "type": "boolean" + } + }, + "required": [ + "phrase", + "start_new_session" + ], + "title": "WakeDetectedPayload", + "type": "object" + }, + "WakeFeedParams": { + "additionalProperties": false, + "description": "``pcm`` (or the ``pcm_b64`` alias): base64 int16 mono little-endian, 16 kHz only.", + "properties": { + "pcm": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pcm" + }, + "pcm_b64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Pcm B64" + }, + "sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Rate" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeFeedParams", + "type": "object" + }, + "WakeFeedResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "fed": { + "title": "Fed", + "type": "boolean" + } + }, + "required": [ + "fed" + ], + "title": "WakeFeedResult", + "type": "object" + }, + "WakeInputDevice": { + "additionalProperties": true, + "description": "``tools/wake_word.py::_describe_input_device`` \u2014 PortAudio diagnostics for the configured mic.", + "properties": { + "selector": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Selector" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "max_input_channels": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Input Channels" + }, + "default_samplerate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default Samplerate" + }, + "hostapi_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hostapi Index" + }, + "hostapi": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hostapi" + } + }, + "title": "WakeInputDevice", + "type": "object" + }, + "WakePauseResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "paused": { + "title": "Paused", + "type": "boolean" + } + }, + "required": [ + "paused" + ], + "title": "WakePauseResult", + "type": "object" + }, + "WakeResumeResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "resumed": { + "title": "Resumed", + "type": "boolean" + } + }, + "required": [ + "resumed" + ], + "title": "WakeResumeResult", + "type": "object" + }, + "WakeStartParams": { + "additionalProperties": false, + "description": "``surface`` names the caller (\"tui\" | \"gui\"); ``persist`` is the explicit gesture that also\nflips ``wake_word.enabled`` on; ``client_capture`` asks for PCM streamed via wake.feed.", + "properties": { + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "persist": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Persist" + }, + "client_capture": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Capture" + }, + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStartParams", + "type": "object" + }, + "WakeStartResult": { + "additionalProperties": false, + "description": "``started: false`` carries ``reason`` (unavailable / disabled / disabled_for_surface / owned);\n``sample_rate`` / ``frame_length`` describe the PCM frames the armed detector expects.", + "properties": { + "started": { + "title": "Started", + "type": "boolean" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + }, + "phrase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Phrase" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "owner_surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Surface" + }, + "enabled_persisted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled Persisted" + }, + "capture": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capture" + }, + "sample_rate": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Rate" + }, + "frame_length": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frame Length" + } + }, + "required": [ + "started" + ], + "title": "WakeStartResult", + "type": "object" + }, + "WakeStatusParams": { + "additionalProperties": false, + "properties": { + "surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Surface" + }, + "client_capture": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Capture" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStatusParams", + "type": "object" + }, + "WakeStatusResult": { + "additionalProperties": false, + "description": "``enabled`` is config truth; ``listening`` is this caller's armed detector; ``audio_silent``\nmeans armed but deaf (see ``hint``).", + "properties": { + "listening": { + "title": "Listening", + "type": "boolean" + }, + "owned_by_caller": { + "title": "Owned By Caller", + "type": "boolean" + }, + "owner_surface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Surface" + }, + "phrase": { + "title": "Phrase", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "configured_surface": { + "title": "Configured Surface", + "type": "string" + }, + "input_device": { + "$ref": "#/components/schemas/WakeInputDevice" + }, + "available": { + "title": "Available", + "type": "boolean" + }, + "hint": { + "title": "Hint", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "audio_silent": { + "title": "Audio Silent", + "type": "boolean" + }, + "capture": { + "title": "Capture", + "type": "string" + }, + "local_input_available": { + "title": "Local Input Available", + "type": "boolean" + }, + "sample_rate": { + "title": "Sample Rate", + "type": "integer" + }, + "frame_length": { + "title": "Frame Length", + "type": "integer" + } + }, + "required": [ + "listening", + "owned_by_caller", + "phrase", + "provider", + "configured_surface", + "input_device", + "available", + "hint", + "enabled", + "audio_silent", + "capture", + "local_input_available", + "sample_rate", + "frame_length" + ], + "title": "WakeStatusResult", + "type": "object" + }, + "WakeStopParams": { + "additionalProperties": false, + "properties": { + "persist": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Persist" + }, + "profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Profile" + } + }, + "title": "WakeStopParams", + "type": "object" + }, + "WakeStopResult": { + "additionalProperties": false, + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reason" + }, + "stopped": { + "title": "Stopped", + "type": "boolean" + }, + "disabled_persisted": { + "title": "Disabled Persisted", + "type": "boolean" + } + }, + "required": [ + "stopped", + "disabled_persisted" + ], + "title": "WakeStopResult", + "type": "object" + }, + "_SessionScoped": { + "additionalProperties": false, + "description": "Handlers that look a live session up with ``_sessions.get(params.get(\"session_id\"))``: an\nabsent / unknown id falls back to the launch profile's config, so it is never required.", + "properties": { + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + } + }, + "title": "_SessionScoped", + "type": "object" + }, + "ConnectorErrorReason": { + "type": "string", + "enum": [ + "INVALID_PARAMS", + "NOT_OWNER", + "UNSUPPORTED_RUNTIME", + "CONNECTOR_REQUEST_FAILED", + "INVALID_CONNECTOR_RESPONSE", + "UNKNOWN_TARGET", + "LINK_STILL_VALID", + "REISSUE_REFUSED", + "UNKNOWN_OPERATION", + "INVALID_ANSWER", + "NEEDS_NOUS_AUTH", + "CONNECTOR_NOT_FOUND", + "TOOLS_UNAVAILABLE", + "CONNECTORS_UNAVAILABLE", + "CATALOG_UNAVAILABLE", + "ACCOUNTS_UNAVAILABLE", + "CONNECTION_NOT_FOUND", + "POLICY_UNAVAILABLE", + "POLICY_CONFLICT", + "FORBIDDEN_SCOPE", + "ORG_REQUIRED", + "ORG_ACCESS_DENIED", + "INVALID_POLICY" + ] + } + } + }, + "x-server-requests": [ + { + "name": "approval", + "summary": "A dangerous command awaits the user's decision.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ApprovalRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ApprovalResult" + } + } + }, + { + "name": "clarify", + "summary": "The clarify tool: ask the user one question or a batch.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ClarifyRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ClarifyResult" + } + } + }, + { + "name": "display.install.sudo", + "summary": "Masked sudo password for the Bot Screen package install; app-level (empty session).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/DisplayInstallSudoParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "preview.act", + "summary": "Click / type / scroll / annotate inside the in-app browser preview.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/PreviewActRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "preview.read", + "summary": "Read the in-app browser preview's text (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReadRangeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "secret", + "summary": "Masked value for a named env var (skills / setup flows).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SecretRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "sudo", + "summary": "Masked sudo password for the terminal tool.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/SudoRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "terminal.read", + "summary": "Read the visible in-app terminal buffer (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/ReadRangeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "tour", + "summary": "Drive a guided tour highlight in the desktop renderer.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/TourRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.code", + "summary": "A one-time / 2FA code the user reads from their device.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultCodeRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.save_login", + "summary": "Save a login for a site the agent is about to fill; the answer is JSON {identifier, password}.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultSaveLoginRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "vault.unlock_prompt", + "summary": "Master password to unlock an external password manager for this session.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/VaultUnlockRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + }, + { + "name": "window.read", + "summary": "Enumerate the native window below the app (JSON text answer).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/EmptyRequestParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ValueResult" + } + } + } + ], + "x-notifications": [ + { + "name": "agent.terminal.output", + "summary": "Output chunk from an agent-owned background process.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TerminalOutputPayload" + } + } + ] + }, + { + "name": "background.complete", + "summary": "A /background side agent finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "billing.step_up.verification", + "summary": "Device-flow URL + code for the billing scope step-up; the client opens the browser.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BillingStepUpVerificationPayload" + } + } + ] + }, + { + "name": "bot_relay.outbox.pending", + "summary": "A bot-relay outbox envelope is queued; drain it.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "browser.controller.cancel", + "summary": "Withdraw a pending controller command.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserControllerCancelPayload" + } + } + ] + }, + { + "name": "browser.controller.command", + "summary": "Dispatch one browser action to the attached controller.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserControllerCommandPayload" + } + } + ] + }, + { + "name": "browser.progress", + "summary": "Browser (CDP) connect / install progress line.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/BrowserProgressPayload" + } + } + ] + }, + { + "name": "btw.complete", + "summary": "A /btw side question was answered.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "connection.request", + "summary": "A connection operation opened on this session; the desktop renders its card.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ConnectionRequestPayload" + } + } + ] + }, + { + "name": "connection.update", + "summary": "One transition or the settlement of an open connection operation.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ConnectionUpdatePayload" + } + } + ] + }, + { + "name": "cron.changed", + "summary": "cron/jobs.json moved; refetch the cron list.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "display.install.done", + "summary": "The install ended (0 ok, -1 cancelled, -2 no sudo: the command to run by hand was streamed).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayInstallDonePayload" + } + } + ] + }, + { + "name": "display.install.log", + "summary": "One line of package-manager output.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayInstallLogPayload" + } + } + ] + }, + { + "name": "display.lease", + "summary": "The takeover lease changed hands; every client repaints.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayLeasePayload" + } + } + ] + }, + { + "name": "display.status", + "summary": "This profile's screen started or stopped (also for transitions made outside hermes serve).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/DisplayStatusPayload" + } + } + ] + }, + { + "name": "error", + "summary": "A session-level failure outside a turn (agent init, model switch, compression, resume).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ErrorPayload" + } + } + ] + }, + { + "name": "gateway.ready", + "summary": "First frame of a connection: the resolved skin, the change-event capability and the replay epoch.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/GatewayReadyPayload" + } + } + ] + }, + { + "name": "layout.apply", + "summary": "Apply a named desktop layout preset.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/LayoutApplyPayload" + } + } + ] + }, + { + "name": "message.complete", + "summary": "The turn ended: final text, usage and outcome.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageCompletePayload" + } + } + ] + }, + { + "name": "message.delta", + "summary": "One streamed chunk of the assistant reply.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "message.interim", + "summary": "Interim assistant commentary (text beside tool calls) sealed as its own segment.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageInterimPayload" + } + } + ] + }, + { + "name": "message.reaction", + "summary": "The agent reacted to a message; paint it live.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MessageReactionPayload" + } + } + ] + }, + { + "name": "message.start", + "summary": "A turn began streaming; no payload.", + "params": [ + { + "name": "payload", + "schema": { + "type": "object", + "additionalProperties": false + } + } + ] + }, + { + "name": "moa.aggregating", + "summary": "The MoA aggregator started.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaAggregatingPayload" + } + } + ] + }, + { + "name": "moa.phase", + "summary": "MoA phase transition (currently only ``aggregator``).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaPhasePayload" + } + } + ] + }, + { + "name": "moa.progress", + "summary": "MoA reference fan-out progress (n/total).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaProgressPayload" + } + } + ] + }, + { + "name": "moa.reference", + "summary": "One MoA reference model's output.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/MoaReferencePayload" + } + } + ] + }, + { + "name": "notice", + "summary": "Informational one-liner for the session (capabilities refreshed).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NoticePayload" + } + } + ] + }, + { + "name": "notification.clear", + "summary": "Withdraw the notice with this key.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NotificationClearPayload" + } + } + ] + }, + { + "name": "notification.show", + "summary": "Show / replace a keyed out-of-band notice (toast or status bar).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/NotificationShowPayload" + } + } + ] + }, + { + "name": "pairing.changed", + "summary": "Pairing state moved; refetch pairing.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "pane.reveal", + "summary": "Focus / reveal a named desktop pane.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PaneRevealPayload" + } + } + ] + }, + { + "name": "pet.changed", + "summary": "The active pet / its spritesheet changed (watcher).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetChangedPayload" + } + } + ] + }, + { + "name": "pet.generate.progress", + "summary": "Pet base-draft generation progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetGenerateProgressPayload" + } + } + ] + }, + { + "name": "pet.hatch.progress", + "summary": "Pet hatch (row drawing) progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PetHatchProgressPayload" + } + } + ] + }, + { + "name": "platforms.changed", + "summary": "gateway_state.json moved; refetch platform status.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "preview.close", + "summary": "Close the preview pane or one tab.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewClosePayload" + } + } + ] + }, + { + "name": "preview.open", + "summary": "Open a URL / file in the desktop preview pane.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewOpenPayload" + } + } + ] + }, + { + "name": "preview.restart.complete", + "summary": "The hidden preview-restart agent finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SideAgentCompletePayload" + } + } + ] + }, + { + "name": "preview.restart.progress", + "summary": "Progress line from the preview-restart agent.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/PreviewRestartProgressPayload" + } + } + ] + }, + { + "name": "projects.changed", + "summary": "projects.db moved; refetch the project list + tree.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "reaction", + "summary": "Affection reaction detected in the user's message (hearts etc.).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ReactionPayload" + } + } + ] + }, + { + "name": "reasoning.available", + "summary": "A completed reasoning block (non-streaming providers).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "reasoning.delta", + "summary": "One streamed chunk of the model's reasoning.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "request.cancel", + "summary": "The backend withdrew an open server\u2192client request; clear the matching card only.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/RequestCancelPayload" + } + } + ] + }, + { + "name": "review.summary", + "summary": "Background review of the last turn finished.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ReviewSummaryPayload" + } + } + ] + }, + { + "name": "session.control.update", + "summary": "Persisted goal / loop / heartbeat state changed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionControlUpdatePayload" + } + } + ] + }, + { + "name": "session.info", + "summary": "Live session settings snapshot (``server._session_info``); also the ``info`` of create/resume/activate.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionLiveInfo" + } + } + ] + }, + { + "name": "session.reclaimed", + "summary": "The backend reclaimed a live session out from under its clients.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionReclaimedPayload" + } + } + ] + }, + { + "name": "session.resume_progress", + "summary": "Deferred resume hydration progress.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionResumeProgressPayload" + } + } + ] + }, + { + "name": "session.title", + "summary": "Auto-titling renamed the session (``session_id`` is the stored key).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionTitlePayload" + } + } + ] + }, + { + "name": "session.usage", + "summary": "Mid-turn usage tick; message.complete carries the authoritative final usage.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SessionUsagePayload" + } + } + ] + }, + { + "name": "sessions.changed", + "summary": "state.db moved; refetch the session list.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ChangeSignalPayload" + } + } + ] + }, + { + "name": "setup.ready", + "summary": "The free-tier bootstrap finished (broadcast); the desktop's setup gate reads the record.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SetupReadyPayload" + } + } + ] + }, + { + "name": "skin.changed", + "summary": "The active skin moved (name switch or live colour edit); repaint from this palette.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SkinPayload" + } + } + ] + }, + { + "name": "status.update", + "summary": "Transient status line (kind: status, lifecycle, compacting, goal, loop, heartbeat, process, \u2026).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StatusUpdatePayload" + } + } + ] + }, + { + "name": "subagent.complete", + "summary": "A child finished (status + observability rollup).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.progress", + "summary": "Batched tool-name progress from a child.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.spawn_requested", + "summary": "delegate_task accepted a child goal (before the child starts).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.start", + "summary": "A delegated child started running.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.thinking", + "summary": "A child's reasoning chunk.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "subagent.tool", + "summary": "A child called a tool.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/SubagentEventPayload" + } + } + ] + }, + { + "name": "terminal.close", + "summary": "An agent-owned background process closed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TerminalClosePayload" + } + } + ] + }, + { + "name": "thinking.delta", + "summary": "Legacy thinking-text chunk (thinking_callback).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/StreamDeltaPayload" + } + } + ] + }, + { + "name": "tip.show", + "summary": "Point at a desktop element with a one-line tip bubble.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TipShowPayload" + } + } + ] + }, + { + "name": "todo.updated", + "summary": "Full todo snapshot after a todo tool ran.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/TodoUpdatedPayload" + } + } + ] + }, + { + "name": "tool.complete", + "summary": "A tool call finished: parsed result, summary, optional diff / todo snapshot.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolCompletePayload" + } + } + ] + }, + { + "name": "tool.generating", + "summary": "The model is emitting a tool call's arguments.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolGeneratingPayload" + } + } + ] + }, + { + "name": "tool.output_risk", + "summary": "Tool output was classified as risky (prompt-injection / secret findings).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolOutputRiskPayload" + } + } + ] + }, + { + "name": "tool.start", + "summary": "A tool call began (stable id + full args).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/ToolStartPayload" + } + } + ] + }, + { + "name": "voice.interrupted", + "summary": "Barge-in: the spoken interjection interrupted the turn; no payload.", + "params": [ + { + "name": "payload", + "schema": { + "type": "object", + "additionalProperties": false + } + } + ] + }, + { + "name": "voice.status", + "summary": "Voice recorder state changed.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/VoiceStatusPayload" + } + } + ] + }, + { + "name": "voice.transcript", + "summary": "A voice capture produced text (or a stop phrase / silence limit).", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/VoiceTranscriptPayload" + } + } + ] + }, + { + "name": "wake.detected", + "summary": "A wake phrase fired.", + "params": [ + { + "name": "payload", + "schema": { + "$ref": "#/components/schemas/WakeDetectedPayload" + } + } + ] + } + ] +} diff --git a/flake.nix b/flake.nix index 88a42a3a..6a57fb96 100644 --- a/flake.nix +++ b/flake.nix @@ -76,8 +76,9 @@ ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk"; JAVA_HOME = "${pkgs.jdk21}"; - # Gradle needs a writable home - GRADLE_USER_HOME = "$PWD/.gradle-home"; + # GRADLE_USER_HOME stays at the default ~/.gradle: Nix attrs are not + # shell-expanded, so "$PWD/..." gave every checkout and worktree its + # own cache and daemon pool, and idle daemons piled up in RAM. shellHook = '' echo " HermesControl Android dev shell" @@ -90,9 +91,6 @@ # Ensure Android CLI tools and local user binaries are on PATH export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$HOME/.local/bin:$PATH" - # Writable gradle home - mkdir -p "$GRADLE_USER_HOME" - # Enable host keyboard input for the project's existing AVD. hermesAvdConfig="''${ANDROID_AVD_HOME:-''${ANDROID_USER_HOME:-$HOME/.android}/avd}/hermes_dev.avd/config.ini" if [ -f "$hermesAvdConfig" ]; then diff --git a/gradle.properties b/gradle.properties index 32d72a93..76cd1e6a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,8 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# Stop idle daemons after 20 min (default 3 h) so finished builds release their RAM. +org.gradle.daemon.idletimeout=1200000 # Enables Gradle Build Cache. # See https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 38775 zcmXuK^Fw8i_dPt5akFl=ZP#SmwrzJO-E4ERYqD#yZQGt)Gu3xq@6Yr6a{hpG*4k_B zwbx0!hX{XwsIx-`dBlkz^s}H!JO^!(!a4_cTrz1Vit0f%cf_7G!OU||i2QW2#e#C- zzyJ9BnWX6jB;z;@A#xbj^p}xgYBF1L-QasiTtd%1&-L{#*7B0w{DPG`H4sf6OLlaD9UJ$J#D8)8}jKNALLb1 zU0I7uKrr=D7(Gs#Z9ug1TmZWvpWE2dgKZZPIjz{uFs77LDD@kBi&YH+`X0 zsIVIR#QonJD(;>{!T9>+i`M_WAte@~J=Lv~Bs(8qD=_s28~6%#FQp?cK<0Zw5~{-51f5N6UETWMv{ zQuF8lvs$Y(`FQ*^nzJ+p#`cye!XgkEVf`||N)^@WbmO?Q%iLvzp=PNijEF{>g~l=T z7Wa)FEgws|>LK(`Y$e=j58d-*8e@*}yGL>{TdG@d%Ot{q`XnP^4o`1`Y`d>_WK)P=BBQF}Uvel)T3 z;i+n01J&T7JqljFG*K!D>{fW!3-7hjiF%JIOX~PGSw+}cvCCEOGGGjx%)LRji-{THu_v6HH+JQ$E!FpCqPyoQd2UV2H19x?af?P_{4 z^!4qRiStDg2A&XL0A;e)f7p2s@!Bzv=j}1EoXZ2e2T0W$iFJX8?d?sG6#xmZqL*0Cwc8| zCnrChgue)g$L$^L?M2`D4IFB!NpEO#R(GhdFzndzGNBL}VGHhHA8Peu37|H$PlOaU z(}SulXP+REmea+$o{|1f3x%X28|fKzRfCIVRCYEUmKL5{jieMpMq)i1SsI4WgUvb%TJM*57;1DTkl z7$dFVChZ%PzjW9(36c(czy6utLCEK5V?Z0I>=NwE7?hn6e*l9Gv;=?Q04`E*w#;z! zrnOU7C*Y9;NidwX#qtB{k$mL<6>$+$EmjDRN!hjEva)20aHd8JiOA_CTjFH0*dMG& zL?LJ0nlUKujac5>qo?Nu6zz)fwKIpqgoM@}zb2kiyZ3NdI@ka5iglH+Q3x2(XY`6W z`9qy;|8d6t|onHpOjq_m)n8OTIJ=ur;XF|x`EWeUVwc{6 z5j!{dW6ulp9WQK<@|+t3l;XRt2n}9jwzTQ=yLOunxNe`}ecpJE|M4ZKR{@;PY`BT% zSBlxr#XOnBR%>lRS!AtQQ;Wa3#Hd!ILn-2^2Mp#9rmETmsWey49_LCtC2D)E%u@tar`SZEytAZtT}+k+#0`)EbU#- z3APhgE$LH`<*8>?M>5ZN{^3CXAzXkix5qQXz)_I%i*2YA6XU=U%#9?Ph!P4H30_!C zEE|Qr>1ngPsdeX9C-5K0SE;|OGkBZT<|q1k-1P096UHvkb+qrV1w{MwT|XLoy+*9F4}24O}tP%O;N;* zHkv{M0J&IuYy>)r?kz9U)=VW8QBQqk)1-apD;%bOpe)1_L1$l_~#d9)6meo*cUJ z$HNXNfcjFlh)Vu>A48!@)jP*z#5On?X^X~(kEgHhXKI!=J$$8z%YG2g{urDjZc9!5 z(D(U_7_^*1+oMN551u6N2!Lj_lF#H`6V5(BOv;re*sY4}Z*A)%nY=d|6)surqujV2 z?79J*-&Y*LOG?}UqKjKk&v)j+_VHW3F6J5#&BTJToB zNE7qP!SKN`)KJv!UVZul7_dlA+0nO}CiK%{{ueUKzo#Eq65f3tC88AJ4CPe8Dy{Cd zCVYgUyYp;AMv1s(7Q?%%h{xcerZnr6dD5F`W2fj@@W~Rpz+a@0T zTnCVi7lsBf&@r3M0hrkIU0q}tu4QUI7-6|Vx*~tINg{gD7^`&5fzO={!-|U0qc316 zULLp(v*)@GfNM5#c@|oZK4Ll96om55$xv( z_ch6NRWrl}K+U}xy_+HnzS^TeD{dM!a)p_#-uB!>i{gYTHG0++TnJ-U0q+KI+rcn@ z7Ze&|<{1Xy|2o?t3#ZMu{RWCA&)cOp5A z>k_ufc)+GlG+44KePhd~V555NvNKDX=gQT3neGC;9#*W?0g(vHn;Y$~zti=N*W7+M zjYOt9mGo_gUygLuhi51tw$_x#sYQzaE-UXiEmRTwq$swxmcw31$8Pfog6oDTh%Z=Xl{2u_aXZ3p=qBQZ+xQ@9L0q2bJoe0_v(ZRS;%ct} zmKO1x(`c!iN%HOcV3cH*dn~#7zM6!S^^@235;Wtvis5_cI;$e>-s_PRm6I22oX}oKJL@n2ur-zGn^WbF`|Z9dn<&wLjoVN@8MR ziTjIODfN?fIQve8S!FA#*r*P3T8YyZt&pZgP!vfia?loXB2+a5sB@^mFIJqF`~LA4 zcFHC1K6YBh3e$H1B{j@|^ON0M2!Q*ByfLN5j6$d^SMqis%>{y}!?x5g<6`DVNtJ-*!JuRXb=6`>I3;2J8+)sbd%q+Prr0 zDSW2)xgyuKbbaL*j(&4-@*BK=M4g}h{A+axvBegmhnkbb(*2$pomU7(?;HGP)BssB@Kw;C|te5I@E zJp1jFcy2>;-9T){Bk^!|7gIB*f86{T6OTN(jf5|9sa~c!Qfa?%xR-+IBL{#hRQf6L z0GMSCZPq>!Bcz7Vc;Fy|uBIh?l4EjMJPrCN@74YMk9oju>W(3*+M;L>+wu` zDUB?Nyl5mCW*2ozRDMHiijsMm+aJ55u8ovZon)cuZi*%3!mIHfdCQk>)IFi-TulaB zB@_I`WhjBtUo z0lck(^sE()xl_#Kf|B9XK>{HZ@C^l5d08S^38rP&6OUfj)JeR|+#Ra%&+!9F? zD%`Xw`5vUIGnn3AbsL`f7l-ymi>28l`Fs$b#7)PntA+Gb=N{BI*_AT#>LFGYlw!K{6lVynO(vixLuj3X!G~c^H)ODv6x^(*H^A+{XTk8~wrff;RQusEstb zDbYJ5PM%}}49JA>U2gO+U0Ws5+0F(OGw+Hq<*w>-SRq7BvnoYft)%MFBm3x+ z!=R)b@FBYNN)P?XExRQ=*Q;rQt0$Iz^~mJdJkk6{{GY<9IlZNf!F>5*h5Wzmg&LbO z*_xCY)RC&#a;E+_Omp_#ygVNf7>gdj*~b%-=(TdqMR(<4!;fx8@%!_47zH1O(F^R0 z6z-3->F+whvvSw9D>Ki3^ndtWuiOv|e`P2bzO`|>(YOzBbas;(jq54QHs~(Ru3c!g zC(^F1=695u?Vz^r3lJf0Z8zd>4QoF@AuuvJ1G#SCaCYxh@pl=uipCE)uAFYNMm%Pb z2Y4B8G}X}y{Lr^4Xs_#XcGCIGfH70vxb9^ia+|;gJRAI6IrZr>iw4{1#d5B=v|Tna z&>FhV;B0RqPCPTNZJdVl`|m%le?w0yMe9`CVfP45{3l<+JKEqqfrI$$b>1xUR?`CpfZhWAbgw`bFOnFQ0+qh81gA zq9c>$uA%&tf4=uklry%m_z#YXGR5HD*W8?+dk_e8(+6Z?%c)YAt;4JF@ZKHzTY~Mf zeOPoTTIlPo65o#Mwcp}T!w+oRz&co3AQyuHN`2(yYIgZ{*R9l|oj^XODr!UwhvX=i zT{v9pwy;;0V+Xz%>u2&K_BE3X?IOKC7DA?K6aq2tpY0}yhi9u;9oaFK>d+k7#qCkF z=kyPypV})#J*^Xsn*3pn8uL`7 z3&bbfdTE$csuL#~Uj5ZoX_G_8rD(4h(VfrAVh%)2-9ygD`y z8>8|JDtP2j_lQ)eir<;PD{vo^iDO9HbSkA4y4d9VSoD&H%vD^sFlDAVlISTX_FM8; z?dzvY$PY(#*AN-sgC-EiN+%2o@-s=dXJ4Wu=*6<1i2L&`YthvAd&c*rqY z^PNC#_p{Mphc-FDPx9Ek*e$7@FjR59FN~+gw-Q}`a0VhYaN)aBy$cU4?mB;Im}!*G zmd}S~amFrxL*{X`8^}9$%-2OD&OHNU&DSEShRp&uNjoo4p+lhR+vE(qM1i6pR4FlwD(mu%Zq-VngJe1o6@dLLbE`m{QOaP+G7+BR^eyxdqk8~A`J zG=JaaOFT>etQ+FDJ`6gNl}Ec&orxRxsgm(fR1uDdGGHg}TC6PeuhZJSF`C!A33OKpG~YcQCbog-rQ2rq2lTI%hDC4y|*zR z9(1iCf~Z2fNufIWw=%D&bwD zZmA@JBbT1q<()U8LL@<>2jEc-&w!oZPXy;nHBLQ0WMz7R9-@^{O`ci&unHITP%$Dt zs3q$|?4*f(fyI#gvzy7*ovN!_G=TOyU(r3v75MYmXr9I=4FG^!J5(TKosif~&lPJG zXPU616b>3DjJzO}*D|5XX`A62{PDPQC|ihZaYU5@@y%2uG7xmR%mip| zmFQ)q`uj4=Q%sc<`vyi>_v9ai7J&lv$MCXq4G~0*84%@aC zD_sE=1F6QXS`vK(IcFnf=iY!RAl$(y@p@0k(m@*YdF9HA{k4td>H)~`XPMW{MGC#% z!rS(20~`R)5XuiSkCb&_>(RZKT=Z+TBY* zO5kK3$2DLFv7#B$hVLB{9S|M$(^r5xl?;+wxq)__~=EF*%4h}iaB0y?u^%UBxrx-HnGLN#}Uq}0Yn z|0DYCK~zf`Rd|=})+n>@yNM5S^SK=~eOp?ehQ1ZeJ3fewqoy{2cJr^2k9&w7ZuLJ? z+AA18tTaC(mm5Si)rwVZB53a-)rX!Y_H(lL)KCLFh>Vm+iUaz`LF)_AozEXNo6Bi@TmaxB~ zj@q-Ko%i?M#q=I3YI^w}L0RGj_<{f8FrEJkT_fm4L2{6+sJYq>cFnrNz7GxF$~FcP zVX8wR5=tdeSSZ^KosD+u_6_E(&}h&g)&HNWTl+Yec%$;iM^kc^#|QUUgkO*g#&?fi zUpszV^KM=h-Bp(!y=hLldH~_aFbXsT7m3gvmC520nQnaay>8~NMnYIxvV_uEm#b+& z<>3OA(duS1&xB4pq_sBwCx0Mm;emybGO6E(ujj7&5+Bu}hVvJfAP@G#5=Qe&6^voQ ze&`Nb2%RMZ3G_c1F%4ZXiq;4^9s4mDdKp4w<5Aif+Yv71%EBF0zYB!?jZ`M%wMf`l z?=c7^n@rFDLRlF8w@*Ztd(0vQdn~^LwDhrbN+nso>-{HAM5hYW)d}APpLtb583X-^ z={jjFQWdAUqm--gHt)XonS@pIp*)>BV%#EuVN#a^kt=eV-@X?oZhJt`Ol{!SJMWli zP-Gnf>AUhhdl13uP--(+)nG)H0?ttND;lY0P+{StyH4$PLGzXh1`65YR{eS+zGS4b6xBLD6dBy1q3hT(NgVW7pKTh-XBjFz2d0{2@Vp64_ z;zEOuv5({0Z-9sk?SBSxjPW}Ck+Igg+2OqTgBq8IFWf9Ijp*hOBV!asa=TVu8 zIPxMn(L!5CU#wJ(sa-y)MKoDu1ltQ#y)cwJ-*2}-1drr#aH_cuU}rh%tw){kY;snn z(Hpv)A@bfYZ@F~GU+c1~dt>UMc#|YWuy5YL3&XC=B;1On7pPvz%t9NOM!49p0km3Q znLz(WM%KD73FAufVnSe)h4KuWlz=3Tu*lmF?OH4W3)I(7Tt5tbd4H3T!xKb&g!#4{ zQFI+>S^yGRP!0rix~P+S-xnA$ryNp;s9osi^pZ8#P<-)Q1q44P)^TGEe}uFFm&qeU zoLsYUw~At$=x;O`{pL}8hVlJnTY0(cDIi4dtZU983iWk#2Z_6bWubvnl?zSdwe^OS zzivx+A(YVUZGsVcZ37AXWh9aQFVR34H1}wuDknriZ90288dI(?2wA{ytPVy%rt+Ze zLn%$nhloV0y8AP7Zw)dg#Ri#0 zQ>b?k1%chV=_xsOE_(<||LV(vIqq7|ix)}ZFYuTm)5aKORCrtcj*QZQrB=s{z3(~H zJIy(C431qh*RWv=tE&CMb+}l{PBUvgh>Tt#OzhAqf)OXkw18yrlU_PqAjh;-cl$PFEUQ_Yk#7ENzKRctEs}un6s2NSA9~!uV-(ko!_GY#J~_xf3;3! zTlrZqsl9HA`Pe?R0fmNvTM*&V=UU$j?R6LhHPN{|DG@8ivLmn>z6WkE@eUjElwPRbOS^K;d ziJ%|@t5p+CgNxT!_>uV-bQ7&x{e4fL6fH`~6%V(kN>;Os#$oLPz`(NFzjV$DC{SN( z_w}+@Q5ds{wW;PndG>mQsRB3u#NojqE- zD+p6Ehozn`Zhz<@jpP8+$(73UUCetYJ|qHWSF8$98hvOJG2u++X-3{MuwKWCshKf7{qgGgGor^g+^Mr`YF< zL;36Oq?iE2x}K%11HNgRE?77!L~L-Hl$%1zNaBY}CnW(rIBq($ zxHo5)2BSdObXcCKjp0*qI>MVj;fr|)LtTNXlfl+C7XNp>{T1GErjNMD}7hPM>LPi633us*c_z`PbYD87UO*@_!{u|rRCf)F6b zW;zX@4~}zib`I~eOE7U@Om{^#M!rXX=ACM{jWvG9-+WVO)>ZI8c|J1s{>HTM5No=s zNUok+;3GgQ48h@Lk;>LAYp7id?ZDB2`R3}2mXr)w#Z5X$1u;0dx-Kve=v{A%(PzCp z{7v$_>F^@~&M=X@pm#Q;DHR?UCH^*YOv(D zI$_0#_51oj9*F7V5&i5^4q#Cxwyl3V0PU|XWl6`w^ut4*Y4`jFpS+Yy%se{+zY4Y8 z-m~cs^dQ9eOu>pttovt_4tQi0r)pB{>&MVM-x4I(GSqe`R&R5|E0G?E>b zK(iQFVteM??ZXz)3-J>TsWeRY`%xNI+KHJ-)j9%16=@h)?ODnaamgjBBD@+TMs6L1 zMwK>rxXW!eDw=HaO7e<1nK1%SA*@iYl2Z(K^p(T7{a_u%GzFbG?}qY8+1MNl)MjwTi zG@oUbJT1d^q`UAI+L^2)-LS$jmQ6HnyR183-DdH_ zxRlDOCunehnIKy{GmH1gFi!AlSiu*HP$0O&S#}k_mqk?MWo{>4=S&>US71i?eo z1p!1gbb>~+w{cQ45Zsl4gLM`czZ}fJ_j&i~%UP z-l2?Pm*ybQu}xIl+9ENHKp}~O3Q_W!^xJ6Xk0_dy$-xXP>lnMq@0JJ-We1FWI*70L zB!88UJeIxIhhk;ab;G`K$j)B|)k%}=cP-)V)17xQ;W6d|Tn8li$)W4R9r^#`o0T zql74sfB8-Fy`lV(}>2>1Gt=4>je!|s(>!iynQ(;^LOU@fqUbLmh=Y-auq*;GSa z5Be%1_a}J{mU%RqT8fT>8SpIUe)_dEtPVT_t2`j43+MJ=nlH|qy)D>uc^%PL0?qW@ z!Y&npn-CFf{hh+s;8nVaQ+ho?+dU4@k+cjP6H?~!T)0Pkdt#RWJtq3&MIYbGd0g5U z^ZckaGrII6u-?s|MM)G|FCcc@jhH1bCf?}R=6iy`0PO4v(3asE?%PAEy~-2bIGTb9 z8Y0ug?w_suAEl>WxLk|}ad!G)qi3=}{!%~zZ}FLdTdu_5aA%pO%Uhg&^ipYuc{Xg2 z(LCsT4o-Qfz*r#2C1}&NrxeCp#U~^nf0g3i}UkR#H3?6+9+M0|H~&gTFOl> z{v`@J@X7mpS|9^Ci2oWoSyh&=8b1Xu#Zt=kQb@2xpt>o5&_VGBmzR8efBGhCQ4?pC z&PhXq1HTE=;e~Dcw&(aww}<=PJkN~ad}qHz?_$D!?zXa$UyG5-Tky)bX(GT5k4)ge za#=(+i^n%~8LK#!%u(L5#F)0^Lu_7nu2}3kq7dOP2Fd7Ev&98~E^}jIwH`l?-{ob+ zCfnz!mQcGDTiw<@3=v46O2T>6Vs_xh;E_$S+JXx64w;hsyNhJqZupSJ@O&WAew#F` zPfEt+^Od-r%i=*_I}02!G$&Gz&QwgcQLW-vKQds=cdJGTA%!Z=#O{fTlXX+nA;%L< z(3n&~so+}#4+{t4^HZuTGZlR|MawQnJFK13DbF0{`bhzU>kvCiH?;^pdosa=TFfq@ zY!Oz$-C|O4=qdVusoqMfz^@DZpU52bD*B53E5jxKUl|UD7A3i+ffW?1vE_!Lf%&G6=Ki5R^)wWL8k&An5e(JiR)20G0%IIaRO6-UCfrbke z7C*TV-+8o?&^Z7U#*R0$)l`zgcOd3;lNZ0k`<%1G`}+Fh2K1#4rxoa^|0}Tc)3&-d zmemBu43f17Q*Ibgb`HwIMv))6uHcgpaaGa|{u7*nb^Q#)GKqO@t|Z2%ahbpw%h0>7 zaBUq)0d~rkh`M+M*>`{NeyUD2NV6~r{7{ye-Pu^_EMxXmfHsRb%*nEfNG_!W`V#!i zq4Vpqj!dzE?Qd*PrAwsq$bwC6BuXdQl${_5Ei=XyJE0VsRs&(TPE@Fc$@RlwR+B8s zJG%3*yZ>Eujtep{j(F>$pcqB{shWTF`;z>Z{%)q{>+UqQM0_8&1wtVp>g-2(M<0cl zo&QM~OeU>?R2DuL*lOOSJ)=$=!KB`@9{yPi*<@SwTVOS+ThU)*z72f6p}@HjdgX z$%HS;>bE|AiwVEr5Jv3~Qsr2OH($3nQelKySxlm_w##VK)b1##*i9&k!j~*`L#{N4 ze1g%$O2k3dwkI8`6S1@8>K;%f7Qo0-v#2t2kgT&%#s#{zn9XxnuZt(r!49%FU$0mb zSq+uPeCL^sdj}e8W6y9T^J0n+BdcM^?0;mzNMYM9 zRGq=KFX)q5&_!kG%4tMcJ^%@4mABzM>DzLxT98H_`oc8?=}(6mQ8h5I!wmYe=5=6LoZp^{gmXNQ7&k?$Ltx`ppK=|EO4wCRoyS@^-T zEIyVN(GFg6AW0>{wxu=oa^4exmUo{y=1sm}a8(a2d^ z=ejqpLU;!ULj1B@J<$#Iku=)BDYGr$bPz22YFc+V;p;#yx!0|FA^Vbs)bp-E_4&E{ zR^OEPi8RgWG<8{GrJ0fte@M??%&Ta_TwM$~^ z4VwNo!JTfHb}dXDPXz$1mT^?anwqtrk?OGG`d1YF zHOx+32pdoIqGCM-b0nhO@{X?-G^{xdkG% zER3sR(T7X%o|e^dEWv$niv4W(JZ5oHqXO~C%!a@LT7pf{f_1rh7^FxbwLp#Lc6E5; zua7&>n^SG30YkOnx1sbL%p8ph(Vg7Y5M^j51_9fy&_6g{QJ`!^xh9{*a;$0X%DJF} z-t$&0fAr9W>s^7#>&3vHneaG^t<9V6hKn~g**eEtF)yZ-|3}}d!=H3^eO2Jf3{6hZ z`xG9a=7Tm@Fh#6O7TxU|Rv|CEud^8)o$WiOP5ulK+{8F7@3=IBmYVQTv~SY+(}$ob$8Xc$p@+mcxoi)Yx|c6#61c@B8K zTlw|t1KF56(7Y}1YCZB@>)(-y@IaDkkw~?%U^&6|_%5qmz4u4vefQW6b4rlr%KuR7tHOhe*B)n_Q-|8Y17Crr&!%- zDRWrKo7G?jhWy-N(51VXbDu_Ktw~&sC^2^@qF>JilU^39>vt0kU0UmN;~+g3ayzza zfpXlefQ$m(u0{Wf;v`c2pQc-`tdk$zj@uJVc?*ssF=M;Zwx%?`1|drvMC6su1> z1G{Qr%yK!6R?%R(bJU9p2IMAnekJ32`F>xRv3(x{mBx@GJwKf$+AXr_-&4KPi}!p zeM@8c8Lp#@b)i{GcEMCM%RmPmAPtf9&uk3bk}5}JqDh1%y8&6HIT?>iQzR-Ym~oHJ zeu{gO7GMN2zFoJ}8LQ*ce~js@ms-8HSp6%kky`hIFYWuqf!p7yi&N+}dNSd4mwasI zeUe-qP9%+boL444%R9lCbf{-$_Cw|j@XS0 ztUKG$TIHg-`t@x~rxGY8XPAu=7e$C!QN{i@j_^N0^KJ3Yog2yu)N^Ujk>i>h^t%0- zgRI2prxvTn+PGP`YEoELz5j{8o0oH0SUSE4!S^ zR(|kuCv8)RfSJ&AFB{&ivk{pb^qlVLIuqVObg=bhdn0bfRqW&5Las*DEzIDBxtdzP9z7^ZN5RI~xGXGl~#8yCI z;8vOix-H&@b(iZ5mn*5A69LCdiGajjrliO9xC6s#6eHNhe2#@ly5coqh}RY-ut!>P zA_F<~^=3X5B6E>BgG5Ri6PINODch<%yQmyQ@EyTXkP|~J@U$*~CSO|T3=99N5RdoS zJLj60)EPlzi1T`chY4|oKGgM$-hC*#iUSH3_6>cjJG|RF8%&{e%ABQH%4!$Y-lpg~ zHF_%-pd*VN7oBml7ci$t%(93XRd}3g7&%h)G>+s?F)56#&t&zxqV6T;nRDLACv{SdlxPb(>Y!E@(A@sPuICVQ!HhfSrXNYPNjkB_0Z zl1zB12#1v`!yXiO#S4*kg!G}SQUbIXh(%>24rR=#h{&=L;uK9UQNo$NT0(;Kl~Bnj z7OeXbU{xS*GhSYTnyTPSvcC_Fb^KWNSnO*@m=#Ha&%a8fy(~)3L-7TJhGm23@8F1n z1Vw*6yX;X{W3*Xo-CR;UGqxGD8njx`Rs?lkv$ibgeWPXCSEZD|z!EK9r6{9O4gQ;) zw?k5*%GAC?c=jv4)scUw3L_q*zZSlPZj3VKI2xR8eyf|=OuGoRLpt9^E`;sBg0@oT z`x%;^=x@a_YuF!NF~D8+iM@7ipeOetUc7lOq}PAa9X#aON%;?UF?I0vu?9rnNDL|IP_I>EnUl^f#!RK7)49E34i zYV(}}6Jmui9Ll0GADdRTk|=5sqZ~cax7^pqr7Jq*STZ0V#9I!txRluURmea1 zjdW{`d?-MR=JSnPYF0k4(*!A69&ti=;Ve)Po^YEaQO+SLybGRS8nMAsdnDigX!|L_CN*0kV&dX zin|tn9_m5fZ@0svI=RCG)6_xB0b{+h3`eo6`Chl}+?0AE@4s}Pu~8JhGLw$XiyC*n zt6L*6*BK09dqQa{d3#tJ$4l0+lTY?mDo6M2y>)47@zLmjXNn{NwUSf>Y zm|1;TCzg^bbRL*$Z8|p^T;tg=VkH}EWjBEuKy~yj&p_Z7ly#c_cKm(UkNEdLjsKii z&H%yyx7SVWN)H>~<|cZc1p~h2N(Q_9FJWhWBHkXYvp;DhAQJsPF_CmZBF)J>me&3@ zVi4Yh!8O=)8n~J&M66DbX;!dtrESF*ie$@t&{=THDLI zy9P+qtc>q*We43SPZgk=HZ15kt16zUd`CBKY&!$n!c`j1&*+K^n!@6}fE3)gZWl`| zwHyynOvu-*Ds6L~4?1_jC$cgSGxFvSs|Iq2oFW=n za*wXlD4h%oWNsA(g$^`!W*B$!B&`n}4p5x3<>DsM`i_{3F++TkIDtWjtznn%H@_ub zsR>?@u5!{EOK-5QUokS)-e95@=++}^>ekd;_OMgPYDzIb8%_L|P6fYEk<_!%BrEO zFUF-CHk}Bz!omSxona6G z%tl?KiUJGwoBk9z!9o?;5&_!WTe3?H?N05A4g1(oVvmc|1PZ7d3CO)6Fu6E4(I}C^W;A%GNfb6hKCQl^!Bdzk+ovI?R13lq?;g$IN zm0Hg_fTr77C(#HxNz^;&AfkckSD2n-Jx|{tEoK&XgC>HBU-Ee|R9g(s<3U0_{is~~ z^myvlbeQ6t;-q?&_~DcgNN_p~3M8D6!kT=Q@6x2-EDBtI}rCv}maO5w_{T zxQ`G?gj!{*0B*HoS6SJf;aQWx8!Ha^y{sr1^YY}4rjtO+kd+k;{h=7J!6_1vW7;G; zM`vH1c3hi5)nQoI#C>0=WV-*Gi83snSHmd<IRH&+%nU;7?NC6XCx>LIn*XSlJBW|pkRfD%GHCQ3PW#){0Udm@wAU`--E($ zp=r_OjhssKC2DN5pF2mXDTJvd=(wk->iKTdN`Fcv;>@If%R5)1x{`z*7- zH|>I9XyQOTQiM|FlCNZ&<*a}4|I_>1$_;)v$S+@Hq5r4%LuzaweJhO*MYhK0>r0M5 z83KGV>xwP3TbJw((#eYJDfa93Et&T7myYu?kbBpcTU#>6OZ@CUO&rQ3qk3Vg#dnyT z&}bW3la3wI!Lq>-Q5uoj#1RN|WDy!7kQZLpxof!r93;mspPxCOpEIEClfU;v#DbqX zUw)?iz!C^NU+kFz#dD$PL>75kw(+j#0Bszd6wMdf&By5|8Z+bEJy)r=>(yFPTibao zkN&l}J@>79GN!quq^i}o9i66QJ&SAfrBrRIiE(l9dK@DBa;H-7$%oXVBFtKnsWr&B zyc{n#>*`-wG05N^tfCeBw-e>pf;Dukn(P+q!D^l>E+gZhBm1(8?aSZtbaqJixA1sz z+9TDqDXF+u>+J4>ikN^!I8A2fYOmuQ+Hx5SDJh3=?m_HTd^em{I6!}ZW>yZzkfq}} zGtby0O**9h_D!USrmLdoV*UNAHk(l$yX@cVun4UeZNHy_rdVoW2wh&&F7r;KKMn<^ zu!tBauDbF;+XqG3(xn}qdc2OhN%e>$jni{M=Ihhgh!AHb{$7;qWBrC7f`c@IFC?!-Ihj{ltE=`cEiL3wxp#-ZXdY0B98AXLY6iv# z;A@j>Z#FoBP$!(-zD#M;!%b~#)`3-9g0$e|`PH03iccGu)h^2c;=c>As}XlKo02+p zm%VhnC7z=Ro|UxJ1xNiSLUQ&ZiaPw>@4fx70fShm)Wx1(ygaiu3G#66zV-Y z4tOGfbVq#!rb_fYmq@M$;=TYLoSEZqT2&?ZBMjqv-_6;|@*3MMgyim75$^~G9GlI5 z7&dT4d^xw>8-Wyoa><>33(?-6f(fw}Po74G`+oqDKyJUb{67N3KwNO+wfx(2v4;XU zh(kftqd{+dO%Ne0)!AzqJY$7Iu9J~_;^bi7c1#RW7o&gbY_C{A7Yj;03|l21VQea~ zd^NQ(LFct3+^bu+6$@i@@?^quo+Ik4v3XHb7A6(IQKY%W-N4ZAnsYqMw3N3CW!*8s zm1)h}>JoeyP5?PDn2o$OVh=h=SEbV@2KV(=q^^L1U^Q&uxS*CQ>t`iM)2U9Xr=7qf znpy0^&TN0a5X9>|++!YYZ`SeRqBn@w;|;p&h7d`--$d+OAsvd4~HAizRP3QpiSfmXo(5q+@^Ly@J+ji#0IjWRGjMEHm`J0N#Tq zsk-5^leslumIXa1neEadyG3h2uo`3FsdBEj9IDu>#0{0SQaKjH2l1f*ei0vLMoJ$Y zPCEG@K8iaP^)bQPe%oP%k2rRr=wv9@&Vhlwgz~v;hQcM-3Pd9&J|097#{vw-UlI5V z*};E(v8uZ~hGV?z44zg+e}c8W!bNv+WW;vt;XTCylNS|ZTP#z*#$w0vh2NYV05JpSm`7OqGTXD640D3p4Xm6a!^|3`8OKBb zC7dTW*6?suRJupkEoiR!bd;w_d`6Y`TPzJk2`iN-vQx7)^=un;llfR3f8nB?uax_f zf?I#0=u)08;c@_<#}_Ca36*J2cPGA@BkW(}8j1o4O7(jQe~Mw#5n z%sPkBKW4%m)uWzEZKp>C@h6&2UcjGL$|vpEo2hbuuT(GWsfxfq7p#~oO?NSw8rC}V z7lLIAi0?VYUk30Z{)$1Mf4M!wa9d3Y=WQ<2qVfL*U(*wROPfSSvJT@srjhV>f{umm zxF)54P)gsJb8>-VHUH#4>dAl7Pfq|}RsKT@Th|d zTMNd2<9`CUf~&I{GBRvyXa6H6u0+w%wB@1Z_Cp?aOOTze1TZCwcp#bYB@2#I%oXh6 zp!hV0O^M$R{nG%@_?2ARU$6?r{Pr=DvR$6;zn@MZwGv>DA$7cMq3mJEb=-U0VK>S$ zM>%>m1f*UX0NWrsVq{S3%#?$$ArrfNK@Z_wH z-E*b9A#B8#I7|si$L`B!`uKtiN(&nwxmDIRz@F88Yl|=Ke|6GGw5XISoWyh6|>Sq!`5!QV2xM__AMt3VK-ivoD(cFhlQQM*AKUFOV5myZD=l)5 zn*LSZtJkd%tQ5zR%r2&Mvp|+R-2n=gG38!CTU8tEGVFSJ#!7V^@V0JeDc34!rRZ)u z=x%)O^w?DeXz>5g-o1=U7J3=UPU#IukKC`h{Ibqsf0b@-VJe37(f!H-x^2GfrH0Fh zy;eTY*Z8n0`?L~lzh)Me0miEgGJ1xrd@`ZLdNU(gQx14tZUqsPxZCB*L3)Gx$S~y) zD-Hva>Y7uWD(U}VP+lX41K22h=Y$Sy+t=6I*Ap4cYf5mNF;j-zo{J_5$}k^il%0_r z?6%qWe>st|?f9hhcPn?M@NJNe%&+(+_0YGc6glBHux+ zGaI%I@lIw7-PoT@=TdfWI$d;sW$+%>@^C;Le{9zepTl)}vhx_rd6woamOH-A@=sQ# z@X1r}VbU{5`lyl3cV?`#ZOWr8?*yh7AM+M1CoyKq>sfxfOyQ$vk!IjG5!_Onn1^ZT&SncLV$Mr4dA_mze2VjF zRE8SP`5tF6Yt+}cJ1!}Y^;+OK@M1}Yt6%Q*2s4-f85T!wZuNxbbVjb%BXJw zkx4YiqyBN+QbOB#+$MqnB}A*x!`o>=NoHag>M0k(of@S#rHd z-pTJl)J)+97&Qiqr-6;`zh(oQfFGjEz&0YLsH56>8@xI2eFUelmCDpVTmq(CokHHl z&k`)Ajs?fC2!HS8yjGtzp8oJYLI%fsCUGL(8b5TXghzKttb|j$OL*hf5>7{bCA>B2 zFX0{0n%1`V8^S(nq-GrNerDGs-V<+nf=YP*1(!%2HsI%YU86fT(M&onN7x-#;YN3i z;AVHc1-HASxJn2@rU;T{gSbZg0eBq<0IfIRp_-cF-FUD6L?ij~km+Ru{Fy7q|=p>Dak`t%b|VQK3cqU`RoIK2Awa z*F&7W6S@R)PikSG&U_@=b^&YDh|-1cx?>xywFd1jtfRv1=)x0(byTz7 zjzJgJQQ-%1*oAde_y`_$VI38A_*Ga(g+GXoxv-83e;iM{u#O6Uf{$K>byWB{Ot`R) z3V&Zbp z$gX&lp4d1kJ@LM&BdfU`Dm~+ldeXZj%e9!XY zM?>=J!zHnvLbOA}W|v+S3rT_*G9VN+K z6eqr`BzbbsT9N_>=BSz4um`nQv47Q&JyY1L`sTkA>K=yl06&0#`BkE%$2Y}@qibw1 z@FJJp)vJnN;b@|+Q9-&EX{Bp4(IV?v5bmMvU&|ej&~{JKCLiY337Gf_ZT4;MYt-Lq z%2oqby2bV-Efj}nxR4w>FP)26PB-py!4nlQ=7Nt@z}+tRx(e9mf~P8A)L-YqZ||N!uz?D6gU(*B~}VF>g)9f>(*NnjG@#~ zxpu`)CPv?C zzl)yaq4b0{P+^$f9M@KG4&@SNaGs%bqh-V%gZ*FFpPGr{3a*ZXF4Ck<{dBb^u9KIk(^7`H+CQ5w z;pPZ=s{#|nB21=mn_==Py(#T}3I_cnM(mUuTP8{<(|wn`6-9}SS$pHWWnF5p1BOH?|WkG*R!$1&*XN{WL zlh3etP;9!27D8`c^dy2pJx?~XY1&P8*-fe-7JMxaK7bD;PAtx0nE#vkf9Ct=^9um3 zVJzTdyM4^xWPy53Z7Jtc8#$5MHNLjk)vnDNR}%9;mM{`8ml!j>B)yJ(6Xo2XjWm(I z${O@pJ!D)MQ?@jFn)4<&~#f{5^(N9rK*6X zCG`@}dkX6APd@L|J=X2XjUL`hVPC-U3OA@Mwv~D)R;0TW4h5w2O1kejFgZP2FgSn| zh7xQU!O<{5!hH{tUr#d{ii;z}r@HtvfneSK=Yah>tnX}+~7;g|#)q4ZK^uhM@QQDQxf z8@n!5gSrGe!4~Cx!YS1|{A2qadORHKSy{7adzoVhy^P9+rS+Sbjj6VO^2 zZ2=a_m=8}rTC0Q#LNTA&!LATxF=k~^bG7Y6C6>rojHPUqZZ@t1)p=Gk;Uv@&?CNDn zlbUE9f1>xY5lU<%xGUdWEYs+wkjMH%@!8LJT!g6Lw*G!Kb)eD1&6~~*mW+j3;&Cl5;k>L;Gh`)PK#+tZG6znqHg&g# z896r`3+v~N)6J+$JJEu*GMccCFugCX_9^jff2a^P6$*oVX=?l@6LFPIhAXq|t+-G| z5jGI&-UFh9HkMA^&kn4!POa5~@r~G2f_A)*Fn5f}&B>S^j;byDgKD3N*+G~+UWnDn za3rL%8n+UBYm`__(>)FlG<66`cuv9fTaLG6dSvyThVYi^55FEN@ZaNVZ z_TPq(j4gt_dAUyA(I3;5{W%)y;RKO;=$78NwomC2);0ViET~*+#_T5ckC^U6L>$nB zsOfa=N>p`1GuQf99XlH?RpONnuoIOufAqyQR-hhM6Ao~UIoZo1ipbdU1i!8*An(a& zfJNVneIl&)6RJ**h#}79g2^k)Y-)-lJlTn>aE{oyn&8QC&0r+V`7#<+Vj({}2O}he z<6J8!ZIW>vuIDgTA`xxh*4UnywlB8P&?+Y*;>7cQ+*r&oAKH6 zSBr95`ylH}>ob$@*#!%ae{z1z(^wZD!iNPnADNQ-jTwu7l&zq|dy-KWeoKEJht$lR zV~u>nBtMQ%6ysy~Br|`R1E1nte_1lD>JEIGSDGF83>!DmpU~B)SD4s=yEum!WRiL~ z(&!H-xTt=Pu(Ctd6CTBL;kGu9e+lo&xJ19FT7;OoUy1fb)OwG%IodC_Rz>^wDv_jG z!y$1upAu3N!FV{MBUd3;xEJ>cUcZnn5e5fM@wn2@>HSM0Kof!=!je1<=SS!$+f4Aw z_==1{+&@vDh6{vpv?}32R^S+g^-7|bMV9bYZWv6Gtg^HC^C8=me?C6$5j-j! z<-i0&ye;7%3t%WDAwo5m+cjDwqGpq83TYXKFhp=y7ltt-;4v8wV_38~{s~-XKpvOz z5S}0`KT#-Du2$KEW-xaoyE6L{o+PZRp2BfW?3LpoCMY+<&(kuV!Z)(@*05bG=WCIr zBz%)|Us$~9T3kq#tDx4*e>$t@TX?nvNAW*|`V&fJ{^`&ao#{WHJvWb{gl}{9?grb@Rb1cWl{sDg! zD*F>**?Y&A@Mq3w8Fq!0o8m0tY1JpeOFkEW#ouK71#dF7>hlvd8GWv5sCD2UtkQ5S zr0(C?%|64*YjxsZe|Ssi>TTi6t&^$qKLX?(o`1AhQzn%YRu^m@-XpiiF~Zz(j)&9W zRxm4gB@1V-O0Er}n6o=IhZ7?HLQb+7WsZtCRcA1~7YhU_J9-%WG-WZ(9+rw_l12F} z32NMtgky;fYQK}5qE|me)A)R^gJ!VGxK49&$_nYEnRrp~f9En}XJNQ>=&F-u8A_&d z&Xo~0)}!|tG1=;**))fBMQ%Pxi?wT+3hktMLe68vLrkebC##f}7>l$(RQF@FP$X35 z@!XS9?1VrXtIkxx36&NTmYy7rL`yi9{Nrj_rZ%Ohs+g8ib#|P|2au>%raF=4XqAJ! zlcW>jX4EM3lP_)sAB?rtmmJ@ZW%AY3J#99r(V29XFddISGffF|TR&PslT&Un2J`6x zna-n=if$x-yz~T9z-Sp*MjHenfBs}^$RJU>=&y{c1*2DT(kAYAX(RE(GcAuzi8{>b zEy3|I+JBC3G(>TC(pK75%%0fEB{Lg#P6~*adziM1T$E^u_KCxGa+FaWUCdc25x$(` zYZr$RFYKO><8!3x5)eUFdQO?<=A%c67|CU9=pgQYIp>+IdbosoqREIJ=0NtCZBB!O zdSvPrv&+H^R7g`39x5j=ZgW$!zh&3fj+V{4)^BWUaZ-%iNQ#nXUW#NjKhB_2j3jg!#f6Dj{iWlnJ1EI6 z#AMZf*j^{?GXZ&avB%Y@wwD!rx#L`_0S)=Jo*of9?-!M4yXos6km*LciSWM8rp@iG?dv?PJu&X@LY`nUq4V@>YSknB z-6QF#P|^Wbg5Qa3c-506NSwT4vuf)AX`s9_2(z>$LR^t6dfVVEhKa-p|j#8dMf{aUSPC2 z;glxY+-R54lzd}i`!T_zITXJs(=&9GaLH6*djcDrh?&e$&&u>Pacd|C<0=>AX5&ue zoT*$UKc$Vo%%~IWk-o!S4*CuUTbM_KxM~EOlfG*v@Bih*S>TY>nx^yUsPqXp zFy<^yJf}nYuW_9EziVxC++ft1_lU#aAC)$hKoVTd&X$#Hl~bUjAl2Sr}Ja zepZ;U&X2`)pC|JP!|$Zm#AlAh^iytd#$ASGF@DaKWwgo6*%22mx!#ns#k~BIekIc{ z=+{m}%%|T9S$5=qWtjxqwA$}wdX-+!YZABgvH`hr{E@qbU@|VQG0mla@mCJ!Dzoth z{aL0z5f66d3Gp2%rt?IbC&b+9$+@7v(VGJO?}PCY@Rarjr111EwuA*2ahAhWwV(92^X9oo=pO|B;#tgxbn*6P^SGZ9hXr; zBY6X9K9po3&LL)c9hXCYG6~}ZIV9T$TdWqFY_V8)94-n*w1n!kNIXr_s}`24WsR1M zgpAZ>bWW;A?^UziQdXOu3!66QG*O|jxLxT=8f}(He+>?c%srdqQbunF$rF<^`6 z__)OqQ)GKG+NH)bE2U#mW=NXg(Y^>0#6K&F;o$#daq`s$4ZGG!CG)ZU|=`22W@HLAW}H(G+N_OQAW9_4R(S=|7Ckghz+5B`}O_ft_`` zTo7=svPRK-8wO=Ljr(p#rW@3R7;Hw~U(L_5`0`{5$#NI-J8Xlsby@CX z{Qojn!j(*+KaDF>xW@eP597MP&YkUP++aI|n~EMsT_9j}-O^!m-P)1DZLYdQxIMdi z2d@r@)z0k7oxJi{u@Z27o&jC=9Ksie@W3!03ixcpcsSsHJ&3QRa5#;xr*UK$PX&BM zUbiicXAa{zg3s=@9|mX60eaqDB%Z%dc#=u`q!fNMK=->P*Q%UClHv@1pn#87j-7T@|-$>!_zM{dA2YLUW#(sm?FI|5l^pRh9)tQEEVtW{ zqUi&eC5Z9!D0kDn8REWT0aa{3-RLe7&g3rMRYs+TH+8Wa@h0A2xl>d&fU>NtCL_m> zx_SK+Us2I=N4di-rD*QG#}BxR${n`dLfCSLA+67oo^=|3$9GN6Sq5U2KuOVj@qD`5 zCfM=}<4~YIO^aN2rl|5D)ugDN%~xbv8|%bV*4FkiYDm%Pf#uR&DD}Ge^^6pq-8ne& z62Fv7Zo9Qy5`x%myM>|cyUg7~!nf1IkA22lMtg^- zf0Er9#)_S(@KScy6xNW_#};R)SyV&LE00354Z>!B6`Q_dx5HhW)q}CZQCH+HHW!BI zT4ACahUvyYyU@lR9IRPw@WYNY-8_K#Uc>4VTm6H=>hxjL@G1IeeTqIYfHQ~bc1En_ zcn;JL(;e*ppH9=A2kG-0hUuO_o458L4W{Y-dbhpqF?tMmMC3!;Im+IWTrpk=Q;@wNjN6q1>hYlmr%Fx%&HNTiPS#Asm@Ulmcayg zCI214x&lCd$pVdVt}LF628sP<$dm1xKLTj>BtRk?8vu)@;&cQhu#axT+!V|6a+W0= zGB%jzyJ!^Ly@mNgCA4J(n}j}OQ7$GR&=z2S5d)U+(z~cJH!h$NE=M_vrW*yTM#(u_ zlosRJs5A#q&ozcSK>od$w;Eu>GA^&HVaIxg+s-9*4{NcH50A4TNjR||(=1nDy5$DU zu-uGN%dMDcc?~Yh8z{59i&@q=D7VhXDb_`pZEe6D>q@w->oM258}qE)IMsS3=3B3S z#RBWISZI9{9@{BcWLtm=TOAhL8c}IG8%u2GVX5sRRM~V?+xDZzb`5H6*Q3sM8}loR zG1{z# zTWdSYEG2d0f(3J69l=GSS6~CK67J5;LDSplFs$ZcE6ri6&10)AWUF1m?-rvVXW-wK z8T{7?qBN}Z06jtv{1;G52MEab+`5kv008(VlhGp_lWTb~e@$+}Fc5{u%jKgy z$qMz3IwO}-TRD^3wZ388H?GTDXDIPNOc*JJ8;rT$klw|&H{C~`? zt;}O{<1Uz>^ELsskD(bqwe|1+{*2 z$BrGE42A-xTRfm*>}vI1ZAtGl7>@;vAcLC(43a;<;15ts2MB5Dzz5EgaC%*TO-lnY z5Qg7a?P^_HTR$$|R>4+VS5cv;co9UQB3LN&m~OM%X!e6-TMwe(Px2sw2Y-M+N}R2V zhzuk%lV_el^8WGq24D}%437^FSIs-8$Gsa7^_>I9^PRfm#bF{9Ph}i6!niM}uZ0ra z2s#{a)9?nuyD`JiI0rsgM%0~uBxr;@X~@j6?fbp{PLEjK{F(QMN>O7Zv>_`snp^u@ zyZg@lG#Epn9S^l9PNfnI>#+{pK=Zy5?ycrYBD8xRnUpKF_Pw8E_gk1`n7`&D?kXMy z?q%;zu#T040n291CCfGzmSkethy@3;l0Hd`SG!^l+k!wjObDSl9g-RX zP9Uk$6jC5&C1b!eNevBYXj0M~4GC$;K?*6&)s#5s|C{&iC3)rapZ_1s&c65Ny*F=W z-pp^_nyve2@=fn~vEeTm_bXe^SBCgP35 ziGBBB{o{A**jsDl?o~78;1eQdV%;$YA4eWTY`FYn(GM(A&Sd7re=J z`S}BxSPUI|tz-(TqGMgtnx^yCEW6nxX7Y9?GRZz`b2Mf#ElhtT2b%|zk^Y#~ye}CU z8L^Vh?eSDP5{nt+WU9PAvNzHki^K<;xAk6c^`#ARGr41l!9gp@w5X%Z{0=?1p2_S_ z?2E?|k$$E#%Q3cZ%#DM0)<`5h)S60J!@V(Cd0Dv$SCsN#(n4#0Uo4Y~?$xJh?d!8r zsm@3|qGh|NbcKItJZ&Z8ky!Hp7@K2>zCBSKY153;vAFxfXgr#}nCaTu)1313=lau! zsJoKMQCoMVk1A=dmkg?ATL1qbxIr~unord}noV;&D&*T@ANgpO zd^?kAKKvv)FuF0CN++Ye8F9qziA0)dYi)&l+qcAphF z+9vJ`b^1!zGH4kTZ0*OMDSiH{eYApBdTBYGJ;mwljSQ+~s?oiWFV@_ah{fQGkThjb zJ(I^89!ZaOU^S+>wRIguPU&0kqaZbUsezin8bgnWfPJfK>#mu4L?4|)t3`@)ndVFz zGw6J#>f(RWt;u9W?`cqrm)45mDx#^4(Ig~GBu9O8fqkG54jAdj9jK}8n0}kq%fT3p<-R#s3ewJ|N2uAmK>Xsln1zX{u1rtJ~vFEMCfl}VRP zlOS(H?RxEA+Dw(zI1Ffu)4rE+GNrd2qNi(y%*EG+EXRK z*4wFDw%)VN5u2I1B%|I_ZRRdbb>dMM1~Ohs?Bw6nJR=o;E3ws<$w=2iJqrf`@< zVEBK}J?n0)J!qxHO6_&0dWw%C)GK@TfiD*AHOUh1f?#!JPQ3v)80L?RbV9K1bR>x= zvSPJCLtcuCr`An9d^|ENp(HHI&MPsk>=Q}KnF>Y@l<_QL8BGWtG0i< z4;EZKvul@M13K7C`@EE){kS3NghX(hDesMOh^nKr^n>?Cx=Hl)9;P#vG(U9!nCVW2nl}=(qS(>K%Ww*V#Np3Lq2+lc6q6e9%fox+jja$ z_E{0(uO&gD~h`@UNOIOBM#>0SSWqF1pqk*BwUMtXRS@A$RGK_yqj)<}^ znjUIe8E;F(2cpU0Kzb;W#)lPHRXVwQD1w{Pof%#g7)d5DF+CcT?En160z)Pq(v_bh;FBTpP-x|%%ea>&B%RL!@aWErj3g1xEZpWZO1CatP@U+WqGMh=BM@n7 zG_JcE_cf-0R!sptO%?Gu`g`&IFCiF|88_%FK6;jpdjURAAS`6!H%3R8&MKRX3%g!9 z1^!`1^VQK28%Tb&jF5jiZ-328FVa6DOj+@?Bw{B26ll09o*A~1+U+3qOZ3me|BWdd zm9QA}FSzQc$2t>9Yg5b`#;S-6-x5srGW{z8Q{390&J9e5DuopO9r~`Ye-GK?l;doI zn4<=L9}**?bRi@A=m!!>UZEcXkIh&UNX*%hj7YT?vEhw`${&9Lai<~!IwHwhOlSYE z5FLd4F}>!cf2W_w&H%6O;XQyUIP1^tkoP(wXkOhLG6Mq=h_+?YC0XP@pedxV(oUw! z{`?$Px)9`hlYS{l>_5ez<#}ue_%AQLLBB#e)!Dy9l;+xw^4gyuQWD64>?_1>E@l=+t>Tp!A=h=b}_YdC8p+^o!dJCDMVo;FT`PFi+qUhE8WFp zBcG_XE^R+g8SKGsNn}sJ+pa{sD-#2FTvw+2IlV{buMK}!u+Ph0o|Ug6b4M_E4(bC~ zw=EV);eJ<7xpcY{ldD+jYOc2LmCn#ygL}o5JfDf1O+Ev+7tC;yerx}>ff?$gb{{X~ zMTowEw}`}Rb8t}*AzuJ6$D#a%LE;-~-VhzkLpi<>fu*+H zX0GImy?ha0BBk76`PRx*S?Z1(kit>fms3_7ZxnwYM%`O^+4k0r9h-LV?B29}_m*v) zn@qk;F27da&Xkqhl8`Vsds=zJ?R>e1&*H7n<`vtwUB0Pphsm8-4%u7WuUOFYHk+78&mCkE|$$hZW~A^_?~uPBrYG z8%lqCyo=wBB+S>&aWP8I%GzlRl^kJOR@8VjzBjSQYVObicUtMlKt!v>U=*Yk!^IA! zua){qZXRX~CHl7jMWV`C(UC~($&8e_{xeP7F*;)Xd4u+rr=MGQ>S$Xe7VAblhoUSV z@Nz#7&T%u!K4)JFj3xsPw;o4h>A?{Sg*GiSr44&s-e%NKvn z(AaiZFEMPtaB95Ax8?U2%+NO!iN~#2N^7>wZWw$=9vz>;f;wZ5!FQs{P3|-KE-&9M z4=7$qAvPP+($ccN-1^Y;`}rPBWfa2(P=hQg0eGf)GLA_f6_fQq9+&e+`Jk8P^T&Ut=oHZ1k`(Z9MBG%&vPPtezFgS!fZ`&Bm-s+L8_Ry|idiA7E~T^tMEU+XY%OJFCcY<*OlI=ur#u=IYZ!k7&TD5<#+1 zeNhlS69<`q%H@zIKLsE<;|~M|KP~Td3FO&lsol%Z@NwLO?J$Q_yC(m=?S8rUCLe!U z5dT5`3c8`zK5Mg;?i&)XJY9bfkyjWdGWi7{4;pK8%u>V+qMm0$GI1N=3n zwSn5C1s4`8uIvv;6`2a!1#Bc9kl#O~8kS$Iq&5~`0oQP<&g8EHC284YYH2n!`JdsG zO^73DlfMbXwz1@~z~q;wJuS~z?3TLTzl!U9yI3U_g6An^pO?QYCu- z=AJrkC{zUo?r|3iWuK}*<*dBQhieqq9kUUy&;dway-^+@Y{!4fry)~S`Dlc05NWD? z{6&3dRZTw9PbuXJS63~VR`!lg~hwmNQ9AOf1BM-8R~2srSDz` z6?B}!pW}l-wW@#4qgJVUrgMuiq_n!7d9sd~D(F=!Riji_h3U4ax$$PNTCUE~7cSD@ z5sh0ubuNM!I?uz0RKNaW^c;|oVFz=`8m4N2h7+kKxvHjGXFt_nk$a`|sSv#ee4y6n zlb`+5n1pT1P!}PM@9Zwr1tnuR_9g04kGfd3>KEgV#6Ew(DP2UIU3)5bxhhPxsg0;< z6bcJtUlbr@U&>UdC(QhhJ*dt~{Xulrjf&Tb_sJ`nPqnMdWj@LZZ*Mf7OAe;$%!3;} z3n!Iing97tZNotYi~uIZqmWIP4C-C_Jy~s+SM;IbM8BzaAiS(yv&K|c%55z3%x0VB zDzCaig#~{j$S*+p$*$v+&2iRi)GpzC_mo%n+_0f`iwcnHHz$%l6(O)*r+S$d)Xw-? zqWXo$n(}k$X8rn^mQxOT)sQ^#JLR$0RM#_I_3pJY()mQa2cR~eCjqRc{yd;@x4H$E7T^?5 z1d!pQ{Q-d?GS+0OTcOLgL_ZWh3xT3vC`DD| zA_IR3&xvm)dGM`D{-S`UKANTJY?=e=AkpAP=*O2!dJZ^wZ1Ts!t@TG~Bhw)| z`xtEw@0z=1oVGS(Xh?W@3qj_;ZvEfE5qzKO8ttSqvhya z9u9Zb&m9=2K?r_`YJ*yqrNLtq6=APOdpO*Ag60IDb$*IGLC3jcc>4|NAVBjlTeX%zmeBbtQHltIgLjS6Y3(T#U#mO*Qo{gY6vL%7it9u}Tf&C?K^ zq}Jm6OY!AxTB#0Tn|hcVn1Hrorx@n%gINz@rO#r+FJQITfh-kySpNQqN0M;PjNiq$ ziTSSwpC{+z4RccyG}@J=n;p;4Urc|{ogt^A#Z^^FPn#ikjY}P-fZyqNXXyjs7T1H5 zZ#DYejvCnTBV&`V!TJaN&J$$%omu*LW0vj*Xnxw^KXj?4TpBaPhVuS7$!<k-Z%!UANxWI#O zf$!tHM}DKp-SZ)wTBNlJ@FVa)`ba@D*y$arG3Xzd7yTX(PIh-|_d|@8S}f;WzsY|^r7s?%7vS=V`hCZXm-M~?ztNuiHk~nvRN&MLpg*^UAzK_QEb5fVliPm;YdMRfI*3j4JuiH& z9~15Y#npy)YY%XJ6RgNa>SRNG!x45@vpK=DpX18TBXkySHP7oQ?S%(qc>&TxHT$!? z1ZMms5(&-ryK3Cd>$1EI7RK0${FvA83SoSfZD?KQ0y=fbqU>hQjz7Zk!5k?#{ zs(Fi!L%zl%+z~RyXr3Q^IyUiJ=zCYlXz;uJhAh`0s~zDTg$2d`WJS2PENL9!o^Z&_ zFIgc=c8yWB-^{Ogv#i)v?>8Dt*1LM@dbzKz!qP{`j~Rb{cd+3p9wyo<8rCVV15uSn z^g#`MP`*LWPZg$OINXWbb|0+{>dZLH&WuoSKmw{QMKx!T01<)n^Nt?pPVSoEo9FQ@ z!_|E2osqRp4!QmA=czl_JKy=SPtxXNe0w+?g0bI+v+&(n{(v~ZN5Y-Y(-ObCaf0u~ zpDKFc&Iy0Me~i3a{qD{&iU&bpEq7hdBJhwYJNzc_|`0cwwlL8Z^o2!)CY4y6lVENocC|^c`@;Ci>w6(V+IDDf~xuN z>c{zs<209&v|s%vKT0>z-I{)g9uUeuOFuPDoB{aQ3r8V8F-lyq3>S3XFXyK=+W|A(Fa!Dyp=9Do#VA9dtaOI4{IzU}Yo>PZ0X0k80z?{+r z^4WjT=8)k#HLlWYEe0p=(+!;?b*Cg=-D&Xc1|M)5{NBRWl9!M&dalQeBxZjW3W- z!3loy@o-mt_y{e^hl-1E2H!aR3_1GcgdVngb`5z@&pa5Ai z!T%I;%ZK68FfwF1{O)c5&+ck|$!`Ke0__4yXvB}iP|e@4?|6?bdXFt2pwXRcT^w>c zTZ|fG$;0&JhWfCMwm>quXxmU4Z2y0y7;FJ!b4S;q>06>5ow>e~*UOUUxK7|N|C^4+ zl?^fn#|k;e1TABOAM`wqZRLP|=aL#j4rI!Q2U-ky-Ey>Dp$doJ#L+z6F25JYd(~fo z<6(n`XwH1sF2ve-{G;ev*8u}C?;^kRIFx*V-cLV=nmB!Gbib64S}2N5MwjJ!cILkTG}iB zCxZ+L6e(q<Yn$o!A_T~F{^esujh%fA)GPpFOHj(`kfH18Ukb8V8xDD-qHggz zS}JNPYP=88S$?CY0!6h?OIYFe_e5(}bje~pY zbDrq;?5wa^J5NJcUaE>zKMisua~7a^vpSg?Ck}osQkz)vCw28@JcpJ+6PQ&Z=vn*{te2L}}4O zmuLZoMb`inuzNRD;5VRzV`>PD<0{$+b!hjj^}DOpo{;I3xDfI<5bgXX6sY2`@ArW{ zEAih#S?LCRncp~}GFi1BCq~O`6xw)#0tH`jLTNWn^Oax&u7|q0M@Kh)Lp%YfOrTH@ zH=-0cAv>tQ&I_s#@6q%Yx=qtN=);;Gq>pQQKONTebM&aD zkI@lLpF>$H^aXlR)34LFG<}tRrs?bSmZra^KMK`RMX1REO;_;wn)dM3`~yI=YdEE8 zhWBfF6W^}s9e|)h5A#EseukgWG|Mk(`VxPWe~iTRGJk(tw|~NK@|$SC$-k4fx=Vf7 zVe+`9PBo%Fs=lDxLZ$tXW(Qe7IY9z-+@{Uz9z~jXM z=M+KeiUR>b-1cU9>1ymZho0Q)sNJohZk!g;U0r8b6U`P5y@e{BK%{pFND~ApQk33% zl@1bmP)a073rGt{6$ni!(tA@xLBhh?*alh;&-o?FVC}oX5O50W^;CTX0qpf zIi+CSj~c?4dvyro#Q26`fz7C%3m8#0=W1M!X`xUNoq7o*PXfldZ z`fWGYUOj$ECEv)pSl7KVWPHvOaOSXm}4h*+blg?`Gv1phV%dU2!&!EF>DG)B%Qf+Q-d96H)+)wSM zu>NfB`(?=%Y3*~{=wDf_RxGeEZX`0;oukg}bk~>1)HxDkEV+!BwduU*^tQ0P#-L)a)D61es zlw>0aZkTY%Q64dOw|C&z%3?{3T}7=$1CN3Lf+sH~mxrn~%hJiuZ_LzjG)irS+mBU& z?(A`hfv-Vx0*+Jv!{vw8X~)NBC-|O-2N6d--_Yl-P19>J3RA8o*-%3P?Qq@e#}FMH z8G{Fyl0-dE_Lb`1ryG9ZY$4))7}{e@#G(fr|B*ZXx= zc0^ztVBaj62JTi{Y&T$2_*M^LaK0jO?@WJ_{LPwe{at0VPur#P`o<^Wwi^QUxScjW z5i`T0ZLKMpz$(3_bY=WC7mkEpwrB(5QXcz6D-9;#j`|}}WG%-^rm-^{vgm6p8l~%y zP}r_KMP_FpAM*P1$(?f>q@4ru6S+;?`|p%hw|~EL(H@UQq=P8}tNlY*zWV$&oXL>- zoJ@yHOHsWkS+DDTF1@1AD?wy!U41gKggmE-M%(Cb{h+kI*c(ob0jsXy6ZRira)))= z=#;wkFlhTJ(=XC?`Xh$irpM|vWA=`R4bbIG=+@lD`I0e!4zn)kJUvQ%(H=u17W~}2 zYrvuWdt?=}3Rf`KeY$`!s9yA{h3iQ3p=)1^K=+ox-a3X)22czVoqJ$FDI zWsj#sHfbxV4Eu-$<6PiDxUD0t*fNz;0fTX_s4vs3mHG3XofgVFgZXT+i@>m_6AV*g z#iGo8`h&d`Ukz2=^;IHJJBMtP`)KQ~yFds~ELSCmSW9=ZH^L!`98nEplX_}Pqa@V{ z8J%8eUHZw!ZBcGW)5--etz(7|23vL!_KW(a*BjYO&3L*Z$%E$PHJ&jzNEKz)ce4km z)3S8XFL~@szj#O^jYKpey(SId{9LsNdnh8Vs=vXnm$`Es{eNC@YwN*n5X& zZ7e04a*+B=CG#5k0+-HHBo+qg8HCXrZ?l9cx>|lSXG0tfU4y@;E{evF0lqs^7~|T| z>F;f1JBvC_sElUpEtk)8txjLd;BPflGEkG> z>AYF^?mML7D|=AiHgcJ+^^6vOaZmLyp$oBZ#Zj*5k>X&N)E9)?uoI2@%-q1glhD4T zX^PnBLyf@ePbTnR5K2{VAA(Xoxh^MawPxEiX|`>6QFF{dp?|xL)Pi{}du$S`^0ht< zu~`YxNYxeoX%zeZLFj(LvsA|h-^~w;ygVR)!sisE8+6kpX&+vMC2;x-Qqsy>=?k&d z6tF%bt>GCb^^%cZYp!RSDf8s#>yDm(x##Vh&zaH8&k8R)>m4V3KV)E=dv8v?Tq1_? z-WkD#pylAg11Zri$=&$cRH;0@!>G=!R64$o+b%#y=Nls{OSpn?J3r-wv4i@U0bJ5fRrnvd@0XHv{B-y!7=d9n+{$drgA4>-ZLe+a4Ul3eQvvLhH` zoGn|}u#AN-LpL604aj!407-h5bHh*RNA^8aSYMlTYRtKlHn5CeXbky48zTyT`5Kcp z<@|tiM6_^y$l%V65H`*kg{$8RI}|&`MRPd7t75!E=v3sqDxkpxMx-93v+u|xtaMN~ zwIl73akh8D0;%%@EnA^8SYA)L%&)BP;Qk;Bt0w zZbyf*Y$8Z%0-@UTXy7lkMC>y-J(MipBCv+~5(HAcNtg>c8#ZXgg?6})L(_hCfakY% zYIc(S6W4o#v-h%mvVAMlB0PgGWk_5UIV`C z!yi_Oh6^ikVR)Pb(}JrwXmU&Vw-P3N3@fBT_t|h%yt?E34*Ub8xvkr>B<1r+NN0lp zRooNbo|Gr9O?n}Ey05u9==B7AtxiN6Cc8T(Y8y&XQ*jT{f~~cT z)vxy_-2A3$QPVgx&N!ol4kYRm;gKCVD40A5laBBWHgh1+c9VqV=#RUH#B6pYlwQDG zb^AASUIq5HVZoI`j9f{7K3X#E1&eUVWiAf#+SoqG;9`;S{@D*A>OP{M5bE5eZ0KV- zdes-ue;w))GtXJ;n+`rqFLyD+W9Jd-2h3{?zOe2L2zb;2sZ7!58q zAc_VPo?mBW-N~ndCnN-@i3PzNnJZAFiu@bTeKuhO$D2*dS%J5Y*Hgh*jU-?~_$}D? zzK#uTmF|P}0^NS))BMRGG0IP7g-Fv;<`F(_b)){=SqWr>mAKSRv@vRuh+W3?RZ7Xw zd8<_!W3+Q&g1~A@0q=w9#DVzbNAIKS8idOR#*xZ=T8#cFM#U4eGvhN;bivYutQ7am z#bw;q#bYAmN2^JS5wDh!Dy|>L;QTb5TC(4t25K=(X5617{)NpSzs7xFpeszLciJ7# zWU8t_qG{+Jqt-1}g6($Hl$LRyR-d4J6-FpvednQ8YM|Q4y}RjA&&mw@Gx0*pgC&=1 zrn*MmB@MiXv})X?k4Sudh3dw$RL3c?tewUwX?m+faq%8l9K#>!SMoji>TlyQW1r_0F5`{~3qdQ_%~ll!o@l+pGZGKwQ6t8!RxWjY zsn!GWTOp`-bPe-t)ehAF~(IPb#6GMLa_Z&F)?_$G9 zrl;(Co@V4rR#nTsOPJ&LEGh_mp!5aP&Dqe*A|UK@#V=E01OAB64K z=^V<jbONEho~mWQAXreg(+4zBkxPd zErWKfrKa?49X%bdX(dg#ci8loWCv{)QS}Hz9|R9k6e|s<36-wta78OAZ>7sxfmIp9 z>E7;iWClxNm>HpAmOZIZKUoTMzD*@%HWi_fZ_7u7pGT66{SIz6a6v5FE^#C$eS~?j zV~u15NK^yP>ZnQD_8B(W(|~O|++E>UCnPnag%Z{m($R~g>R7^kuIcUXQ(2KMC@4)+U;7$x0} z!z2!+!v_e#uLHKWOEqVuH$N~%FN$vCRv|8K=WT;s%>g2C+W4;f(I$?xUv3#WsW?_~ z-F)gdLa2Rz5h#&E*Fv>7k+gcm{q|fVh<3^`Ax$BEOeQR_e10TAV~ysVVV`_p{ygO3 z7QSfuxx5{1i_`XCw~1uDX1b>heh&F*x1+*f{#mFA*9~du3?M;~mr0<&F>BmCoSrHd z&}S81x%TUF@bcj3uZPti$415Fq3lFuA zkp|hH+f|m<)DtV*pnAuLdP`GuB9I_T^W_~a6&dhPnq&=?wS%XHXFL0#+2>s_p-7^w zCIPIFo3dYHUes-I-|V6PwY@vQ@SUeV`jb?5OkFo~Alx&R-qSir#_}N@i+?w(gwM^m zd0p78SX~&XlL%IZ{Nzo&VfTia;GLE6YapG=2EeRb)W@6}m%2VET)B8#uEX#bb?=XR z==rQIJ_)rRLyBr;*SVID`#TTs**%8aF^6cBzE;h-Gtcs9vhy1wBIg%&0J3KfXq@9v z-S);eFdW`<$T+g0xp@Q{*}&G{u*9frm_#3?Bf#>V+Lgq&*;%W78%)`eQ^^2#q`pw(%CX}=NBaLFo!W-^&Vz$^eq|<} z0Y%!)7xVClhCXs5ksejpyj93}R9)<;O1tWpA$xv&hSRob{mdY6K;dKJBdYlT>vg3k ziyCfRr!=)s&Qht2Dby^!_+nvs-OYwzF@blgZPJ$4;|uHdq93s|(r}U6kp?{w%?gps zGL-Ik&GXT@UT2iscn8b}q$bTPFn#Dz||GY{4VA>nu(ci z-MI2)=rs(G9hH8K=@Lkc%j02*VPhR6003@X0kY9v0fb12} zE(%(13R)W@iIT#2LtwmJH6qMTsahT+K?D)qF1c>^IrmA@iShI{!$nh z18T$se)|g0M(`2{sgeZ!PaG2S8*p6#$w4q6HdJ91nCY)%+P^_BP;*s^%zwqd004n2 zMz*9eieNGlzvE&0t9bM85vHJWsB&i6SbunhB$gBXVizFRA{& zlZOozTB8Z7dh}n8cK|^6ilL%=D9Q#J)J2Ur=#s-4lN)##4W6ssVl*y6Gqpr0k6Jn0 z-;}hkQOlcQUL-0Sg&}f*Z&7Zh|;M8gZ^^u004qlfLnH#z}&hU zp#SHjxq2~x9R2~}qloK8@GfhPQ1bOO*zS)1*Fk~`t_S1&Db_(_IxQYzI%!e;_1sLq zD@)8U=d>SlT=gG@qG-4a`n#tI09?NkpF$AE2nh8ci2O2{{)O{+%c3DAq_w{4jw#~-2ZQC|aqZ2e{W214>*fvgV+eVWndHTEm&ztLWy`EXK*WTYX z6Zr^{^Z-%sfC6Bio<;~`Lxb84AiGJ#T?IY=G^kLAN)rdvZM4~wjUp-s zbYIb1wP`4NKg(Yp#s9WPh^M*7f4~@6vX+t0q416$qBMuY;_J-$LA$t|r~;+cy6EIW z&rk@Nm9$&0j;xO=6G>0cJ}QPHA{qQm()l>wurnu(#Dproz^G>jY8_9%wWD@#xut_1 zafbIDLRA44&VF`MKMf2FtQ{;xOqD1_1XUJbU?D{3d)G$WtmIKKtSgmRRGz1Q+jFfv z-`ts9FBe0G^H(kp3iT5Vkje`#WwYD={&O@ixElc0M}NuTyXJU&huMqk9-({(p8-$m znz?33sCGUT@l8XvLnf?9lp~`E?g-SHku6@#WxSAeOOp4FoT^pY%i%t`n3(9 zO=G93p2J;~7Z)T#m9hBTgdQQ&wM@(KtwQEFwGoa}XHD&wB_LMfbcoR4yt-X4Pm|Kw z7L=d5c;N?`yS|bwQ*aEF&ULw$w!F@|aN3&KmrpXnz{>pYJuP}%FU1}85jq|41|x>U zYbtwWx$MW8^T%Uu4;M0j2Ngiu=0D=^n4Ub*&QUpvJ`nzIg@_}4voOKIz;wX>TcHj# zISeZ;QF-606r62*TzPGwtw+bTLUGL2BX7Bx;6-D#aaFb z?7UY@ivvPszyQW3uIK7*@SH82#i+|#kM{O{IxB+yZ{sGv-*gi3ei~D0Q+5OXr752^ z=~ilHI=;dU>V;g~0^CAX-B%D6vRYf|Hc>M37)TbiIx80>Jj{iJ>5I5V8^`f_fTVa` zCm<(%6z8MUgHf%)=C6yYW+yW!1Pr7YLh?Z1r#zG`^!$9jYp)tM_?FI*akmvBt!}^; z)i1@PRI9oD9+ku<`oO^ybX18JWHn^Lvqkvi>Hz1ecJeS7iTxf=%iHfz9!@dXXRwHW zynwnay|qQSnOInsa(A1W2r%k+07-2r++;b=eF%x!U#A_6O3;)@cg@q~s9J{N{Lw$! zswuFgbMdt1JT`fy5AwdTKo1;nE6{=PIxK%e*N(EKPPQUQ2>(1mb zjwN+IJ~QrXp&4DY$G|I=CCP*UJ&Nx8;C(ha(H^iK(R|*gs)##(?Xonu0TwLrrgCqP zwXE3)2|N=tan3l|J#`@8>L$C|akdi*dHxbGGI!E+RfHmC7J_gSG$hN^hs0ZpjHGz3 zGC{xT%Do@w^ouU3F{0)^ecjC;hQZ%@&0HVAB;N5p!x`V(-b8x6quhWKhvYFxH|!i@ zbHqT>pT?iDdjq&rPJz5{tWQZ5{A?-?IK(n|GLfOm5wU$Pu|T zkx#%@^z+`ltaG8UUSMO&SDczq+yKmU7MrQi87Paqrqoqb(CB8FSWQ&HS=m^bg=ckBVRCqBG5=B;w zhlQX-OT6Eo;&`#=e4#9x*Ts4kvH)T45=>#UpR>r3C$0GV?SH)q_~-e#V$0cMG!ZC& zn*MxzH8V;iz?L30{W+uc@(Rvl#FBliT|u;FIC;Ys`+iTlIRm!&ee3~&Rhx?9vI>#d z&B}I>v7Pm!^RZ@uzBuGiba z)Rsj~93eXP+8hi54)J$W1Eb@k;#zcY&#CJ=A@uAS;*7Z{fB45%L#^?T*v`!5hu|TL zvMwqo>Ai-I&l-8=9aGgbIO|#BWca@-uD}lM=Y#$<{v0^k-bS9lF8A6Ou(prDRAlL* z&)HvhL-oxCL$4RDN0BR2rCfx+W(gb)TNwplTr>%Ydsk-nNZ?ES<@uk|;;QMt~er zTb$v5Zj-f^jNe!ZDXQ@Oy_YMO+VogX%XIf!C@+U%SL+?cH{v@t>6(wJJ=qn*-&mHr zos?VvZk<+o-tCN3o}8YYT3d34mZ}J5X7}MeMcEwzaeFhWyn!MEJQ+TF_k)aTlAwh! z$|z~_g9;@0Q9OU<6ufokzHBq6irYkyeZ8>kw97_^3v22fXMB{>MfssxB2eC9-6Mi_ z5}JiP0CqntLu-cT&t7$0_s* zv%~S&tfas@3DQGn8I0JH#8p6zI9AVES5(W9FdTW|1<=isp<)D+X>YPs*SsH>**Rr~ zY?+xlsQryXB>7D>XlZNy@_M6o%r>ZYCGr>i-cd&b%g)kYR*WHx_cUCbG91yi)iV_E zFg+m~a!&hUwPV*gUje_+{iKUogY`M&YA@2C33N^sA~v+<-H=!6N`qP|;Jp(iy0e5j zKnjC(WG_ffVF$`pY8%&SL{dgaj+xHBrH%^L`jXdjcv8twwDqtKocIec4K(4J0m=9E z^GgKT7O%xuv)hi$Tqg0JbR%1T2bciRre~49!e2^-jsjPcVB5arzP(s(N`(|Uq+kE@ zM$|4)MOyd=A*&ycIBQ|E5`zsOR`@&#W6ShXGJl2R%mnu5zi+o_F%9WACYw zjkoLH%*auS?#{CfITcc65T>tSIj_-UOHogUbBRKLMAbCo=y#ewwb> zN>-3yU?5m9FjWOeC@e6z6nRz56eI#gfSwQ562TVjnW~%yMJ^ZyG`#_v9BOooC|p>0 zRLmKbZQ?3kg#3zJxvZC#fmSXWEY^wMjhAHirraLN`Th3N8Iu+BP0)S6&j;k&E~+bY zWW>mgrdH%PVr0M5T8)!HoBWR}7v85ov-yDtyD))cuJ=RH0{o;=@fDeF&h~S)09$LW ztSIYhLt`FIoz^MKnR(vnjm_Q4K|41^ujEYjJbWJnKH7EeN&*xW$%i#dMcr&lVTEoi6G(RUteHUl7A8e(-N+SD;gR?$y|OrS)xm zY@yGiZhMZsJ^j`ptmekv^N8^RAd3t3J~Us1^R$vB1I@fGcy1c;(qM$lVEeJ)-=rpJDd!1<DrEO$F0M%lD=jQ)DEI>PoQmo<=22hIGwyKD#Iv`e#f11Q**A0B zq0Y9O>7Z%QniGRyhEI8FWAa4FIi24|7Kpo+zBs)GDk6jH6mA$$r&n4^BJ}W=MP^;0 zDah<{c$z9FYgQxMQkI2EZRz?u0bd~FGh);Rp1uD1MN(EITZVRTB#0Fa2JhUcYO_?k zS=)ttW5X@7o*2m@!L+->#w_Dk@^Uj>)-dsKli{$|XnRqX$2|IMC*8DUO;yXrY(gg% zI*gppGkkRSWnnd-jc|~H@!D*)!xrc9EM5ZZBbBzP;De+v4=|T|vNA0yDT91KCmm&< z-q~8I%;ypgwMdliusOc!56U6Lop->}Q`6K$8>em-fge2`IBQMMbApj7mC9V)VlNFf z&d{YujwSJ%D~Vd>y*z(bTau(!Ew?W?Cx-jm8Ic7?z$R zByV+M!Tr@yoZuNY@D+&F@Quv5jc~Kfc}D%qDVNvUE>VP%n(@hyj_^vz&6j;r4Igvb zS+vs||1!3oKYv_3`?marcHv1H?te;|u&0i``6&)?4&Q)XLcgQ;fnTz-?oyc!xQS9- z$T(g29WU*Pzd%P(Yknh#+wfB2uo^Jme5I%E3X=T#Dr+@r>Wt6JHb2|j!>{IxY(Cq@ z=Nc_@lL{obz+6~O%o)*|F0?cJAmy<1&Hv1pZKdJcg(E!}D2e0!_?v%v z8LA2!wK@i3l&kw;Ny@WG(YFro93EHUQ_V?-v3VFqfpxFWOqJysAyHk##yfvs>u=szdUcGzBCX*4a z9aQ0$jdSQr8XgI&D-lB{hYns>OA=&S_~|eyVLT;^)9d3>JWYScEi8W*k&vLEmy9XJ zEJ>*|HDOAMrzP;P!e%xZhLnhV(K?m8)rq#PVI4qkyQ8F5EYMTyu4fyey*bKN{WF^ z|D`Kf?|4R;{4p>nf{cBK%fT=>9h{UCc7cF>_j0TXZ=*aNoW``DZ~m7R;w!nYt_N*t z%l4-fuKmRvlbK9-8kR}y&E@W_TT zhP`j|pHQVo+F561`*f;7%4+rb#q~Y1OMkvCo!B)BN<95R);ej$`g=p?1(3uvh}3d~ zG-_!LXlo`}SvUD-b+B-A5@Ze_7%(s!l>gs(Ql~hRvjX_#X*aO20fpF2@58a*71;>L z-|R!(U?^{#ubed3FXp|eMr8ND4iM!$X?ku^Zxc9#nRCGvR`BA-hkJiX5+j+#OzYYol?JXSAaC>#_Yz- z9g&KEnvQk;4KhGGizFQXMjLl5&@*{~lCkB__(+e+Sjr4D?2D0V?~%qdlX7h$*-u5z zf_iocft;bL>4u(x@17@U-s=_n!45u~UiDtCL*FKSv?nJX5D)iD@EZRM{lnD|nTM@r zpC90C-xgY>mh1&|Q>hJbf8D4WwR-Y2+jmlDe>NhQK=xrH9PUzxM865H2&7>KCUJLQ zBVo=B`0qF&esQ}9KtS_*M_khs<73qig1#4wk0(S<8Jz}witwCig3XW1# zb9`r)lm~rMB>99UE@Xe;nKc9!QCAj7mnc@eBWkKVDz()QDzzIbf#XKS=Csu@EV3*S z^lg-~?bEs8EzC_u<<@-XhSAyafT*e^_ksPT@IAMwb90&+6%6gN*((L^1*-nMM3jbP zvfLbHW#y{;KvSfUIC&-e{WLQKU}g+c`9$(XnXFw|l76i&PU};(*$-|OVsaUsDr4JB zRKq;EZ-&UHWzPzRD*s)BIyH$Fd+82`@+?Yw1p!=hcKbh5$DhTDAOnqm{6|h zoS{Kj%XN7l_$U;Hq32!{Z8;UWVd;ZiWEw?=fteL2~)irONhj?I_(B8 z*k#8~ym(8SsK)ULKUTL2jUprH!;CdaxYVaX*gv%PV*aw`ZcUr)GFsE0JbYlh-~$#S z?#Zd)yr*VdAlPX{$(8(-HQiU=BSfe39oc0>^jSMZv05XHHjZipuuz54E5foxpCCn- z4bP;GITV}4hjIVN%5ULTEr?f3puHe-sYDsc)OtsDT79W7V)-E2v}H{Lij zlEF!*diX7*Ak>#myW00QeJi(i&e40^S_%yskWAYj_K*8e&(ub34-r{&us&>eO}&p^ z5P~`OQG}I`j{e>j@`>;XTR+*@B)p?}DPl{P*Tp~6#``1NCKf><=HG=OH`ytELnAjB z?vfBLUq60OfDMRgqvnibCglkTj`UZ$$rh1Mqu9k!60d}MT;K98pCo1I!_$mZvlkRh zM!>I@p>c(v42*~`FY&;OC1|j^g!n0BB#c3T-6NjDY>z1BP zFB~dpVr*^-KthO1`$1pPCy70wYNh&v%Pmf*U!c)i^6AYYAU#sSf>xmR6L|YHsU%N(2`}On_p?v zfJ*;J-51nSNYw+@_VgWc8sRaiG99r|u45@mwDc299k!$V_RE`eJ*QVC3LBvPUy(8S zxOemcPr0V^1_&-nJI0}YyZfb2Sq5V=RWHD_an1Z&!6a7cFa9Tl3Adfyf5fdc?+Am{ zlqTSL%7^7)+UNK^fprILQzC5dV|@I_vO)9Y6XhPkc}wNXVn<~_yvv$klc0`=padFA z1hX$0-Ren5*-Jz)7E+Gr*0q}en%5E!cjS8(_LDEbH0qt%BE{XxL4SvE^&9x8ILu<& zcWsNRhW64Pg)nx@^=;Af{b(yhnCTSjh1AV@DUH^eWH1n9BZM*hsRH0$!bhcC_ z>|zT*T}agxeGN&yt%NKt0@|>zD0FI-k*!3+#jw9#^Vf_8+Cmc-%a>G;e<354ESft$ zOkH*TY`tKl{!?=(w&CP1LoH|OXPgL$ZVS030?(I*a7TX)lD1p65OQ~ zu>ityJxOz0u_^pyl8&{hXB~rtc?QgE;*Sx)v*WI3MBsSmobT_eMB@#O>xAdA6jOTt zc=mf)x%>O1c*<~enTSi6tBI%u2=`{?6QcGw&ch2Ex?q~9LgjCAU%Wwoq9#jZWkLlD zv;?GjR0;M+V5} z)V#_0fJIpF126`-PTwW9bJ4u(9Ph>O5 zNzR9yT^bXBL5ulcF5>1LhX5a~#J}Jd^st`!=QS_@_IuE(`R)DRz^%Y0qH7dYKkZ5P z0PSISFE*@X8SyIn0wJo+0!b1IqwR-R2_Lr{PPDS-3U{c%%p=Xqh)19P>^Jv&@rG`!&j~|1j3T*( zg{9w;%ouEwg)sme&E!k-aESl8+7f_)uCey@@$mKF5co>JW#-kA%@#ARLaYhSkJX|y_$ z=;j%?3reUOA{e-usJdsoQwQmsz6TH9EtL`joZpKomZy^kFQ^$0b-9Mbhkq99>+ucV za5-i}z>2>O$%n}M^$7CR1z$m0$U6BV|N3!~&Wwj6WM^M?2xR8kYH8A%b!&6bWY+20 zpT~87tLeP)C*6ATuejxELBAEHLbvf<#NGy9nM}Nu$S5?tk_W*USx33qaUlJVeXjr< zud~*r$(UD47QY9FSgEpd$_Ppmiir8=>(*imTA{Un;df#hDg_3o9G=`~Bh0tuiDTeH z(<41nhEzj9uZX)yijlraxWtk>#q2`A=#;Fvh7(HOIw1tGuuqsO1tMq*QKY zc>F4H{vk^Db4W6Ve>;8vcPq~eZz2sqsN|Jxj=|s*-9qt4&mrIJu*r+E-qA@){m-R8}xkf7<9H=Qg6?k5FM4C-os#UVf(HT_`&0pX_{c>;w*V45@Hpedbs&E8^o z;Gjc`C{Q}7M1YVoVJ5Uo(N{UCRD&WLjDCcy#_BCgEQf^5VAcN%Q-X68kA=j^qRBJ% zjugupWP3$TSj-bf*tQ1oZ+2@Gy1NmT1R!G87?!0eU@5zvRHWq%Y_)jK9z7oN`IDbR z$6~j%WZ?y^4Y?LZN0KV5`mYSV&b3!_VWT99$U3gUVIe}3KcoInbVgX3<~IKsVHLr>)=07u}_IQ?U%#M+|_&V&_A9>k&S9(H#I7}a zT6Pb;rcS>%0JyxlI^@b?FjgiLiC}RS9bGu72ONunt?lSYYLrr&Dj#XPK-eHE!HjH| z$l=gA>AJqPldwg)Bp76=+Si+9j_qu5#)0pQIic{8FYcniZ!^>N(3#R($l~;P;zZg| z`N!_^tZ%9LYk~EMonfk=0Z?OzgqQg9o~MF(iJ{Y2_c8 zn&+>TmL2jUB858JeoVr%x}K-5$M@H<;G}j=_+g-BR%wnYCw&nqn~aH&=njm&Gk+-tSnFv4cIW{Dfd0>XvM&f<59k264M9hTwqL;357c}L zf_NdBeR}?jT^CTqHUH12xjCA~kFBTs)SJitmYZ8Ao0a}z$WNP9(z})0ZHuTVk-#S4 z=P2(TCx7XkCGwe!?dSSv9uwf>2_o|^BGOSg#i#h|pj4oygg1vEO+L3G)BeOwHYH7~ z_#t7`g#%hdU~tm~-w=iTtm&d@g6BsJA5exDBv&PX1lZM0A5ivQxg9=CDFt)@N`_kY zz=dYT|KKXcG8e8WWW=~4*}9mB3$)PNs=Gq!iZcdo1NocCULs|^eF+@|OTNe&f>Z`R zcIhu4R)fAE|5q^eSxA@>Qwq3s06K(LXu-x9x^N-sO{%t1^G)|jsOFP0^h#BMk11#j znrzqx8J7yWtCzqn{4QVhGTWmR!iBP~dk!PwV3Y%|jwc#TYnMTA}oL));hu`j>1QHJ0 zhd3)&?a2Yn8xXA#+2bA~I6B7d&G$|lskBp$&fC>@n=)h**@i&xE|lue#CK+zTsrP2 z0}1SNS!RYp-^5DI07;@le)rG=e9I=tzt`1&r(d*@>jotB_v*1Z3KufIAZgjO8S=qn zGU6bskK}Q1#qGmwG&oX=LY+)yw~U8Y(&wxT9St_oG2gBa?wLy!Bf3}Nw?eCV&6!eO z&+|_F5j1iI>H?nVmtZ?1Rk=B3V?3fOO1{ zZtl}Mxn~59P)B>micpv$E=C8dQ=V4NP2y3lbCa_QGB1y~XE_s9p38y(9r$pL)hI90 z#-dLeJ-2^DgRf?B2@hjs?GXUWs6kUcO%DUf=G_TLGo>6!Na|uEK}pStq={1BScaEz z42QCe4l`>6q{VlV_Mn7lhM3P?{M|_(PUF$MKoap=cYy7h5L%c5q9K?9wmajdCzEY4 zc=D;&5p~=$nVpmQ9N+wnyuJn1h1edi-0bYx#w?Z=K;?c?mN(Z}xbGKbQ+W#PX;fgs z3}p`X8{d2<<6|CW4z7RHHys@DgBZC8^pJlqz8($ zM4B}!08665%*rAd>iF6%df5UeY}=U<(LTzcwuLN)juT9Yo*X9Of1YJ}v z3uXbQ!6Cz$LWWdHn-ZwedfNvcQjp8H<}>tL1;?en4_UwSpvA@x6n=>O$MCDeYO%c&-8b!s|0Z)TXVXQhlG}ez*=`^yH1)IW3LXF;J+mdEviVH5Iiw=SCLUdIJB%Rd{ zNk89C@zT5ab;5c#!Vc&D*Z;;plrvhflsG|WKu1Jlph5&ST{xGMBwgx#A)08hofyQT zY4fJDb4qFUmX*(fIyzSGZHi8$@ZCt&z9NISbfD)V?oQw${wIPBS6-)`0=?Hr`(|74hvLoBD;&>gxJK;D@bsK7gL4P=Y~w#lqO3E_jF9{e z7{C!kD`9#lm}ElqV5-m#_1&Rsg-nVGD6K_!>0pX>E1-LR!Ffrn*%3Gq(QkIbh(#13 z)5Li6e(}6Hi#E~d$20e9Jfe?coz*~=8jaeO)=%C$YNt3YgdNMP~(D3 zP`=7>_4oC*3^BmGTEDQrrCd0mF$ur*JX{U4k-*=r0Q-Gzw4N!+iOPcxNJ3ObqdP>c z7Xp1Ix!3sHUH;@>)Vh^0BNe1rM|pUnP%Q^s(At z!D8#4mpzwV)Eh&b&kzN&1aeE)c>NQ)_i~F=^v0jW$o(N~4PW zT{mFdNe=sHt}D_SfkmE05D$4MS0;0m8(E)j+UbVuwH5Xww^JcqXInWK;$cnXa~ba3 zMtK7zJd6Ed*flNv&&e0^a-q$4{3s#`JrxMCbx3vyQpD&O5Voytsh9JfjZ|LuN`O8f zf}~Y?!6%|2Dc=_WJ93oc1*y)EGRg*X%R=CRjxVV zwxOh7X~KWNJ?2`nU|(b39f`HxkVz!t{Vef;mt%SLuFV!KZ-NdZ5bV zY=y**`7|Dn=;HP<-ODim{D1v6qJilJ5;PdtH@N?TR%&rW0ArDH*A)CWCJ#|hXbbVE zJker2!I#HHl(t?X=Yq0+>w^0xkaRl#?1&u+=0Q4=oXndwj$dsDgnycL12a0gf8{WF=BA)fcK4a$; zwg!%GSm)JL*>af5?bum{)1K+!b+c)|a`Jc}-{q4$WH?gb^-!Hc(m44t3X`S+adVRx zggl`;SiQ<7bs%b<;!=ch8WzJM^ZMA*aM6i>ZqB{S12pVdph#*-zftg`W&>l9uAFk% z{)ns&3Wda1P6}(+H^qVjA*#L<>x~%Bt^9SdZAovXpWcHmzD{cM*t-GC^Z@?o5n>N;4>?@TO zq?XJ>@&j<2grB<>dpxkq_jnfdOM)A3|BXhv5aoXP{{SZAe*jYi6*Wack{z(|??xux z7ja6PTvQemb{%sW&Hyk`-z6#oht56=Ok0$&X4uLDqAGiSudjSx2GR;KWKI~PC#YaDyYHmn;aK2_vZxZWyh}Au2Kp8RT zYfL}Hjc%cme&amrOlwdTG$Nui?}d?bqMcNfT+r=gv+JwGus%JmfB3g&=bk~8YE=IW+Q5)S$73-Oyc5sBqw7@{N+W{ zdUd$91nZ%S^dh-fCW)@}GYU0s%>L+NrEpm`$Czc)3%Nn30l=*kOr4WU3R;RUA{qXUS{K5;OViP$FgafP4`H_L zUU~t&6v)$ELr3xivisMzbqR&mw0=EZ-n*zm7#shmS z87cXF?8){XLw{7RqjRs1WX7V4U{!Ed5C_>LT??!Al1y^IbH_Oe>UG-?wkLXVq2T{D z4=K8JEavw)`iA2)j5EmFVTyNBy7+f&4~ZL6M2DiBT2V#w^=3JZB6D#zn@mO)3!iNm z*}xQT8la{LFACk7mlcf(ep>%QS0t%-hD~@;M!@&tn|#ej?uwu_$c^8}%L2bL8SZ|^ z=(+ZTkqhb_=3PT52)4^N8(1QG%KB5gh218my-nJGdhGtk2cJBSQq0ery`VW|QnuF^ zYPARZI;s@zfFpikjAK$nmW;+%P2E6gGKbjOZ~$w?zoDvv+xz(*(17&irTX^)|0IOt@|4sC($E7LNg_Cx8KhkRRSpGs^Be;Tv9) zUJmIPq??5@tMV@3rETATEMX~W!X=r9U=!hoz3U9fJEf?viYG9AU9i0Z7_o zP^&2WNRo{*pjppumk=B@ZXRmi@) z(CuTp@Z5c#1ynXA-z^NmwC=%AHm>d)7Wj!AIoCW$%ynSqhsfe%k+vBE7P*ng5g~cN z9-=p56KlCzWWL-|v1F+jzpB_G@jF*ZDKZ&(9nx3}RvtT$qm9 z8~5ze+XwEBgw8y7$M~&YjQw=cSBVz5BY^X~e_gq?=l%Y9GK+3*#KTl-vfsA=!BlAN zopa4j%#b7P_o5htJe^%Y;RIx|`jN6}tJfz85cv5O7xJ4wE@2}BLcoU_Qeq*M$2{!% zv;6v}BYt**{g$5KB6L?ckpg1K)v`vRmZMo`C`=XvrvaagIY8xq!ZqL-I*Lm~J>Yom@DCps^-v6~+98$3FF@S$Y4*j2zmnvEV zkgJ=JksU?x)#Y`|VX&pLpvs@450)D^0ZpY~`In@Z zF;_w`(md>33c@#|e%rI7pI5^vrnS0p*FU$5O_=KCA7};24ErU-GK@&Hr)t?+2ikA}1Pa90K=YB)jWm)Q9iq&GE@~WyWg@e%^@~^K z%Z|fU95GqFakcE+{M>Aw9uhDMkbW@H&;6_e1Yig0s+PcRI%A~(_|1!yYWMk#Z|!W2 zhK^L^sPp6cr{L|CXt>z|Jmp@rs8kkt>v*f*(ovp~(bg)Kb;-XwZf-#vIN*Z-$p>cW ztZ}Pcv%VrZClWPE+#Q&|y}7ED#UG-M0>MO|ap+tnxa{>XPageuIj0uDlwlt(O??|# zY=sMcVB$i7x#TEVqaGdJQsJJ^uOqKhywr6yVAoTnKI6U`5sQx<>VUaw3?pOd(^ToV zT{5sd<1E7z&zV2(mFe0kYPSr@0*`8Bs)n#D ze-)3sOKlt!0O^QunR`}1 z=Z3K=Ns)BhUDrKi?~hd(e#xsh<9?sOvw7e4>CtViwBi{+#%NX_w4T|Gj}T{6+IAk|L;T3D8r)RKxmo zMpVQv3vJu&iLF==H^5GUU=cM^6@`auxa&8xX1O}`EZfcu9g^KAy_>V))-R$Np;h{; zcAg1Q#!U0Z=EHX)aPcwo_w~;i;`bjv&3xN?gfoI_S=}C@U73R=0|iB}Fy(hEGYJ4P2)bBpR`Znc=y z@}j_Nhd+)p!Z)7vKlT-TQoxK9%miTD#bmco;?3tVSt2AG#E(_W8?zv0wqMtD#E;f& z>zxT15Y|CS?ZLE+L#i;?nyr5Qth!cceD%?Vse=GDibibb$b>QHPNMPlt6|+{%I|poU!&q;s1g^K zo87Q}K+Z41Lo$@+0{|Eb50yh_L`1tC#uBXp#sKMX1KY0yB>isbemP)#z1cjX;DQI< zvooQ zj{70m@5xuP9Lj2mWCzsUbqeLqLF8n2%_H9yC4`2QM68Th2k4T%Y|mSm!63V}N)o`h z{R)$IiSl30NeeXt8fDu#a#F*X!>U~&4Bi1ivi;oWj=M)OhVcsHD|q^`LxVX$H^mA( zv3UMJ8P9@KPWNxX81~J@dcv)}L8WBBcxxUaoDFOr#!_nXn}w?C&)k2nSYD2scQT$jQ3NJ=*k9xf6Vf?bxLl9n9c}51dgO##yG=s z?_ko3uJUmeuNrkrkIae<-k%;BLRrnE^BIu(a74nG^er~!pG+4#Ws-@%<`c>?2|2;O2xnK`V zsnyT~XxeyvqD)M&%QKi%@{C&p}^v0_9{jzE9s7BB0l?3L>POhDqP-W0goB1#gax!%9cUGH=Nat z6BcJ2yzuuLE~_0?T`EpWLljrPURoHvalSS5(V3I?j9T$ zRX-Utce_2}Q-UQvsZQ)_Z%xVVPN1IvB&5UQ981j`6K)I1u|~XkUL!Cmb^-=Wa3vY{ zPIA`C5jCON`nBYyHyt0Ta|Qy==Fqd-))ayYHIOWbFxv37gbj#GU4}c2wK3V5Y13kx z-w7dg_bL2ts&uK-XY|_B>mJ}gv3LDVY zLgK{|a!u3P@1oeJHO#r>)3qg&Aem!cR}V@{Lifb}FxEG#nD85)!PzW*Z<(2gOKW*H z)(Q*JnQ!45WchypoD%wHx zdIatxlR5RAO)c|TnvYqK?>_8SOZMv}Yl8@&t`>gWA4Cw>>1-f~CEjc(h(I*IvKeqP7dX5$HWopVnlzP z?PUw-VnN9huvPM5#-S5q4mbY4rsy}ETIx_bR>KC4 z3u>vd0ak)Eo$91|+6g?YnZ+*b%;tX!LA=((J?i21W*sjsdV_c!Uau>Ul~=67Xb^Ao zcE8E<)%%OKLuY@phx|D~9g#a6Ya~rkf_STkf14oJGT-rk8cgrccFeQKJM{8*Qq9#m z8913KSSRj15w~+%I1RA8uACvTuSgrD?W*;kXSnaMSn?8)LN=1KoV*<&9TR`=6SQ7i ztie$ydt9?+nW6Uw@LoJY)g{VK=GK5&7WANGwo8lbW~~9iYK(y=%emfisA8`YH&oI} z$)p?!aSRd;y|_wlYXcuE!hN!IoX7v06-VcW42dx`}nFDk~i zSf+lBWvQCa*{i>zd1Vq`V5?79%1dvY|myg z%n5EcuyT$JGi#J)91{VQaGu;)i9}UYx<}V7Xs-Eml&492R+aZ#EJS~CD-|!YQ?oVo zY#VWdp`x9yl>3u{TYswPQl2j1asXez z7bzVHm1$3RC%%?m-Sj2=zHa(5Rp;JjCCWR3_(N~$k18>vOzvc6ox|uKGvSWtQO~8e z)1!j;6HO=2<4-H)lXidX%~UzSSF0EHR7K#Q3s%gPrn{I-CA7}`g<#nN;(JcR#U=xo6EFl{J+81^~B%OCXwN+!#MBLNccNJ$3k~plhQvZrEktTxj?a+ zfASyo zweP<(&_`VVU^OrF*_jc|{r{l``~v<{u;{^V13#d{@EEn~Z+Wm=3&wxre*(CItFsz1 zGHh#S|6?Yuc+t_c<)P;GLmqZ>ke#jsFeQt4Aerwa3yxFF73@S%e44|i#P5gxX#i;a zN-iBJScPJK`zU`&*)C7_-A5;oS_!bnkUHMBQ1&q7I_^F0up4EWqa3{&0#Yvx0a+rA z%sQ2ZAadNwN0Kw#bea+p@WHZ-^^#APpsY}TTQAp>U_6!0+d)~WJ@ge>rLHIoVZdQLscw$z^?zvLl5H{jV9HxY% zWA|q>{d~a%rG<@;+$!rDV9)BlwZ)hAI_bc0eXVrPX{BD$uQxXcoABltbTV@+J7#zF zS?MDQYqwpnhOIdJmXn6C8?Q^xAOv5ozM*`MCq}c0omPgOSAK0@Hak|#UAwT)IyiVT zXa8)A-j#pXKD%!I>UJxY8la2NIbR_g0RuX4~hax?$`6Jc7*csXD{+Oi4^6Ea3 z;xvN;%zk5J-QtZ|C;6~@;ZCM+{b^;ztoMKX^aGjpOiMC=@}$_)X3&L zGgjI*S;ZZ_pK6Y)BQ`Nk3ENvXR`$rG3OTBE` zmz2kPEpQz9B6ml=j5+$X=3zN&8c%>q_2ciyGwY())G!feI>jl>M!B#(VEt__8Y=J zYNTcy?|ORIB;FfqdYnr5zy+5`9oFMLyspt5n`kB-mm}2E8+7^zjL^R-#ZJdgg+q{~&tWFXH(U{w%ho z5nucmYR+K8B)%4##0#-ihdajc*Cl+T>F-PUR=B2we=gzMThhpXH9DFzwWB-uH9&c{cR=uD0W9+(MNG`LKc^#=1J^WZmwAtIDM$w+Gwt^ z4s|aTBVGIWhf%lOTz&fSC$Y434N-RYSsY${dbwGWVAyC|yev?XCZ61AcAuI0lrk8S zrOHW|9@=^q`$Dq3B;k^*F3Eol&-77RKArhUwCw`cs1cE78l17;$xC@+CFOke`E*YMkCPZY&<=sPRQmS>+PmP=C*}Cfw z0_C**6nvVkWJMc2?t$2Zv>td)wg9pIwvcR{kZlJ-vRwxzqLAF%F(JERQF>zIr1Zr4 z!#)|8-Dhx|(RGUWklgR>@P|1c(0TtQZ0Wd!P3r$)|6zsz{k(r6T9W-8p2PPmrKqnX zBoFYOi&P{x?GF3;&mhuPk_Th`<=dEOI`p=wZ!+hcnfiLiW%!=q#gByKRfkJrJ&9z(#B#b}mkXY#fPF6ba0T4$g0HQB{VsT_0!C(iF8umw*nbv-(VDZ^uQ~mN z*rBM|9yVib{NKv|T9IQcLuRZ)Cx(7%!lq|qI?fw(@TPB?nZ9|!>05r%hPQdsZ$ItU z)l0^8W=;O9u#kS2FP74gdhJ9Fu{1Gm{`=F@I1{{9FV|!ge2! zSdo~Z6nLn?w3LLWX4su>m(K2NW_K~fNa9a2CTKMA2l%6mw?$(VAAFg)U+3I&@65OF zpT7clghhsTXJ^gY8|#R>eGz%qy5)AQ70Xp&97vu9DzZc635shW1y2Medpxn-o^bm~ z7ADqum&-(~Sg~)1Jb$)>0mlfEPK4f7p=c=8&te0k43o$FggcT)zO#39EZo$< zIK%8#eS5Rr*zfE#H@6z~PPf4tc4%C}o(g{ImHz z+#etxlwcyC$4VIH8Ae~wo6_#bV9-Be#7?=PWuky0-3#O`VT3GT?h7UkKVq_6{)E|c z+%Dgx;~drpJbVufVL3WSkoy5pO9u!Z?6ZZ)0RRA*lkq4WlRpm+lg~;BlOSUTlYq7% ze>IN5PQx$^h8+rY?8Gxf96(!_!o>1QMLR#O<=BZIrYF2{DP+;=lv&PzaX^ zA1wc${r~d!&*v8aT*6Sm$8PtKzsV))4b?`@r7q-H>ZbD6VQZT@s~SVh13AJ-yj)?- z^osN*c2$(~0$rrB^kr6|&upJ@ZRjdDe^b5<`MZFSgaLseM6*s(QCS920|^kB1WJH7fWpnpO)@ewH_igY z9j#SsTf01KQ|+ru+q5oKDclLBidsv3O09j?+Qr&^v#nk1qOFpi-@P-*Ofm`h{(5{u za_{eV&iS2v`Q7JEJoW^Db1bEV1J_)$dHIzUe_cv&j~WYA_$q?k74;QCE!r23D9NxE z^G3Ch%Ik48qACfMH+z+YH`uEN_asu$M1`+gi6qqeioPCiRO$1E1&tLUb=mUJ*$f+B zp2hD@MIyYgSB)o_DlaT=IJe>Kb1SZPfCzGjmWl_}wc&_LsMO*;jXiNC6j2-Z#g)E3 ze>L8?&Uo!K-`bRLOoEM2dYQ6UX^bebp2m$`m#M*|1UtbN<$c0w)jRxS`yI)6IM%bW zW*cF~*y@^yl1NC931vNMa#LJOG7;vlSu<%=RM(g^5^69N4=4K@$HT02!ii}zN-&+U zU}{(itnq1l&>Gf>DKX*n2}g|WGI1Bme`L(WEW(`0pd_3|C}FaGEoj(`6_X_zQg6eRB|>?%f4o^&)@*m89$p!BXkc5Jf1HH1 zMnSW2lalOZBfgJdWro;)s`G`&%9IPwtS!OWGS0#|1lPoU2}_x!q-J{Ls9qREtT6U+ ztSG@U_?VjlZYJTptT!0T?V90f&1&~rEfov3#N%3A!ueUHX2?pokRS<1WDcBIZR&0f zGjd)y7EYc&PB)`2?L-UK%4otmf5P;>xZ0=0v!O!RR45GcrK$0sO2kz*8LrH-x8foh zMc6>7dk=^b+E_YuKRa-eb!x2^jBmuI613y}gt=o(ZcfFL;i%fOKdAPZm>q=KJ@w4^7bCe?T}98*0(q3=G{qo-SodosLvE>hy2?zk59O9VN3x;USC zsvPK)u}!GXE?{CIg13+dPEcazeAdTy><}b&5-e2>4qQ5pLe@@GQrImhCDra3>V#&l^|3m3He9B} zD;;1bDrxA8YplRzSWP&cs>DKmb`C~J2*=5%0^^`@ALw^xgDTKf>|OY1X}@3Ml0$3Hng z=4q^p591?(n~zS({l<*NKgL#2;ytM-3%{kmk3(u^&aplNK^}h)#JJe*tqj)ab z*5)zcJsFqi_f(4zQ}-*;zKB}y@is^M#n!55|6V1MQfoLQ?&ecMY9bg9XLRH$LSQ3Pm0@IyEwPs8~UI?6T^{1Uz_V-WXGf7GYp0-+qON_dbJ zI7VT;lIUfTC47Y&2Gg>M)%_-`>@5C#$Tp>qk9!1<3P(9Ofe>#?IK%=N3Q34i&EkP=_G9JPcgrz47h04_`o6rpAj#O7> zU&51wb=6Ziu8F;Je>}tliH&~Ex|GT525~~Qkj1`l1h^4Kc77}kD`QcarWbuwq4zyG>Q@j{I~I930}Z=xM_(Q z%lXYs#^0QH3EvfPFSEZ!mHsZ3Pl_iuW<0!Lsb=D@;QKO;f8z&SEWBO^e#FJ%q=Gbc zGrO6bB;m&*08UlsKaueoe#$a>y*n;lbxEBAKj)XIlH|TE;lwWmH+A?G7luTtE0J;U z^6J)tx%xNwt$_X==Upw9C++rAEC44J)g+i>IX?FH_=8Z{9|=p|JHCWJaYoCqE3DiU zX9?d|eG0tfe{=B{{8h%E@g`HNz93PP(dVj$S_l5lDhi&)0>@%#qRww?6w}h_V z7QWm%nL7V1K;Gf`M~gLOQaNFD!RFyTa(euFOB%4v@sEAW_2E%)aK#;Pdhrv%%7SrrusaPgif0WOXpvE0ZIF{I;_B+WbdiBFJ zjnDTwXa=i{>og~)tdLHci5CTbE<<(}hD%3Mbb zKUz+cT5d503+O_b&Zm=+ZX|zv=1Hc2(K4`vHV8ug{K?jkL85liUl~^mMz7?gP2BI& zM&gNQS{|Dcb(qszg5zVf{~X_Fh~n;~t+cI}J+YHZW;X1c6c91@Fl`sPDA5w_6Nl~O zD5E;MgtJm2d{UHVtltD>NFGh9CLJcXvSHSI2T91=HinW4;+r+ibeoklXo*x$pzllCUs3;&s=DMK8xE&C6g$I^dg|sEn zr)0WAWb>+md1$jg^6uk1tFtH}11I5hybQgWLn9+F%npXg+na9P5 zri3KAyRZuvqAGvUy&UX3%kR_Td$xq5swT2Fah>}-eW94{qc0jcZ&GuwY>jl1%r| zmrJpUFgv0|yFyB3{{2rS7lzvk5*nxRm4$AJCO^N>R%=!jD+?KBG<9!t(u4Gnj3WBV z3_Cr{{xEiMIJQ^Yqc-{tQQFj`(yauUxqgWr=SFh|k1=|+kXcR=m1edm zP@g746jSv7ZPPq*YTTNMyK9t4q$4c)HTo(YlIb8FW|ioK;s0MIdyn^n8)?V|&=H=| zGW(vf9>OE;SUpu|B&x=$fsrG)2=F*-=RuUG6T*rD*?RJ+KR3*!aN$pRU_D(^c^#K|1T%b z0*9>Dv?Py?N}q58W6t8lbCyKkmFZ=Xsf#1bp*Vkvt6)vODq~7ieDz5R*>;R)>lOOG zIQ0kF%iqi=3*&0b&k7UP`H{Hp^JG3@_?`5c_{_1Ge!>mTxXZ9C#?QF2j5e7$JL1A6 z*PC*-n3rGBFJ<~U{mO}m`Scqh%Z~i7ER$fHR{NbyuhQ#zP2!ebHXv7yKXA7YOvS}D zrn!GK{>s5zWj5ZRKgsk*;=!&wA-*HUl04Do2{E^NaxUnv^rk@n8({(4u_4pN!Q+T=*=%M&!i6V^XOloK$++4r zuDmiilxe?9CuEe+NZvr24<%WMbBLK<$K`*JOu{%x4$1bx7OTZ3TPzkHhl_#{EulIs z5>J!#s)glhS)-*QAtQAeos;TG_Nv)#DXUG-MNOM?ny64%+^%$`j5bT8zXpdz=AKP_ z$7Azz*aY|R7_dcjeB5G*DY88k?NZ~JmC~^&GbBy$XkP>g;-3}8aPWV!IQi`rezt$| z->9od#|$0iI_BwE6!7b)a@FW)96)W|5Y9aUp3eM(aCHu&DbQA@qdATB^?tGGKZ4DK zM~AT`FpO=1oprlh7pD=>aY?6ZmyWJZ9cmigM>hzF+BEhYg@uotjv#t550$9K1+ZAA ziSNv?jqoe0Z9o%DPMO>tok=}{LNQ1IPxIx|HIgC5c{R8bo^_n04!(F zcTLdpBHM1`>2;k*AnkKYWn_Ofusj0GHU{gK>|<~n50d>ed@w^h3~2ixN`^@eq^abn zm+=_X=U} z)|b&mI<=W69D|cz-S#2c-06R|1zflA8uQh8h%R>X!;S%TxY=Kue0J9@yw3h-uzacQ zdf`Imi(nitTy;zN1ARPzO zCkJrOFx}3GwH(iZ`eC|*{r}Txy7LfyZo@F$6KM0+9-_fC-Cysv*F8p$0gs4$XnPz| zV1u>3ql~^*J48>4M9F`N%t(iH8%`g=y^zfGXZ#$H@Q{S#5?%n_!BPoz3(u(8aIzA z?Z)ZWt1#bs9TtCBpT$D!oAB69!y?-PRM_gU*w%m=vQ(PB>8(xEbL_D?y|6(9S)i@9 zj9%mzGd#S89sQ*NR4~fRfwnY#kKcb7Xgh||`X_96L#}_fE@>O&S-q|P7&?aO)xgdT z4Dn-~e%i)vK0m--K19FP>9=Y6{Sf`7R;Rz0(LZ(iH}95k5V%%x6ueWbTaby0x(?zu zd#7$GesnZg{MOozGD}I_xM0B?SVwTN=oQ$2tAx9AbI|lQIt;71*h+KQYV+7?3)yOy z^1H?88TcNzWd{Fsf+!6uJwT7p1OEX~O9u#ciRbx{5&!`BCX+!>9Ful=F@L>o!Y~wt zub~jc)CcI~nc#tLBw1InYA7 zGOf|2)%{~*=p{3&*!9`}zE$X$Bk0M8_!>dBTrXE^`8fk2V7{}R*R-ve1Uy+^s?wuj zQiDh5$g8(kJ2Y(3B=8{M@rXSt#->#7`y=T#f$3Dh1QPg@fN}H@jBij&2MA`v`Mu4P ze0p7f%Sr<=6o&s;wY845t(T3vQP67JS=3^^;6@MyMNlYo8E4XIZ015TtqZZ>Be^Jo z3m?FT63w&rsnp9+c$&2*auy%jHudwv0n>#T$8C|-xGT+uR*MC{mgdO7d zv>_`sTJ>hV(X=0PU<~=2XrNtjDwSZ^jI{6gntMt(_nODC(9U^i60X$7_kNPzpT#so z@s1CP%h#_wt+NGP`GR-Atts%;{;a_FtYr2 zEFw!rM-rzrD=VyDZ`!n71LY`;x@&2h&>$N!j;$N8L2uZ~1}mj>ErqtM(5EOZbf9v17d>|6tV!f1it($yI;0eAx&~gaa+>blcn*Xc3w31eNXa$`!&FHL)460-D=~2)Z zZ|O+JP;Jj@eAo9*$+#bJ)LlaE@d)e=}p3&m)dDn6}3?Z(}Gh4)SivS2SxdtaLr{} zAA!L#g$7o;=(1BNDCkg|UZ;mP)8){K=r93dn<_0mZ76>)bi z4~!*BF(w}uJMt-wX>A2F`{QbXSM+XaYdU{ztBWKH=_P41noY$rV=di?e})K!8)Lgo z@gYdG3Ubx@Peh@?^JX^f%4a|ZdugACva}x_l1WMg2TuiSOn_7!on;(r572u>UNY!xM`w<2b=DyCvXf^}l%m}2kxvtVpS9i^He1RRzl$`_8 z{+vGGp_}OD!sXK!81xrRf9mRXgZ>Z99V3VXj=HPc#o#|oAMwx!>7x)pc62b3fgBA? zb#{uAe4IWZ(z&&8td{JK;Z$;8WI%%br*KX*o=lrw`m|)Z57K9F&Y%`9ZbBGt_tIa| z=j4#X;>)nmIi-_2kpp#V`#e;cNp3Os7b3zbu2KvMchX%Rx`XZ(f2d+6c4vmYbPw(F zP(9ttw4|=%%#rLqA>oUXpl-+lae3)W5~1tquZ3t}vkdt7iWS`NN1lLjwGE_`@odHv zy?E(s5|CEV*I7BQ6uXZ_HM|QXhbnnUU?>9Z4K7=iSd|C^npIpg5*Z5&NA{Y50nrwSZ26+e^sI*nHY+tMgp1PNCqEfV0HQC-r)#5r8hgWIxw0_Vq<130Cyde zyR5L$ln)!t`FVt`fS6PumWFqyBt{{G56YH6Bn8s)ehj(k>+;(JL#gBl!aydKO-sB> zYoOUIjFqE{z|K@W6j+tUX#Si)SJD!ZHCzms1?U?JtX`E~f4wTv7R^8*Wy68|#Feph zEE5>cWJc4WmV7L1mav$I(j^exA`#1qMw;~EglH!@1YN~&L%H}6!8?a6X$^>x1hQ#} zClZK)Nw8bKG#E&mrdCQImPw1W_r_pUh=E68Iz!3SNCftNL528pbp>cp$*{jc$HWpJ z1okb>ZqFt%f3Xo0pwP@mmkAF|N;;nf;L)2M9ZeyenegU?9~gu(cb_f zSvC$NBM@nNG_Jo2{u3PkF>{fvj6rf(rkf0>DlBw{B2G-$XfksUEp+U!8} zcj&va{(I9dDqCXE58&#k$GVd#b5qm+7Z+Gr+5JWDj5pc>TE*@?J#*Er`24J2WJLXj?W@mPKBJ zq>#eO2bnJW({ot)L7?xK^eahXzZQj7USk=+e|qS3`VG>l&i-YjwA6J~mi~-+^DX+V zSjT@sv6U_~=yx#XeLxP-+1ru~e@}mqkI%?1f8;t(6wAb=(w6^MKx}WD|uaFW_-ma7M`)L<8i zQ+5~XxcngoS6Sw&6Ccllxw4mMGjWTHYv2^v4%rWy`?n3vP=0iJc`nx?sPR0ez#prZ ze+{0G0F}-Tr1f?$FO*DQ&wgCl?Bc}&%Q`D!0$#{VWhE-!#T9NTq#}a@;7B}YXDY^Y zR^4==)t@TE<-Eef%XlSXZ6?{)+tJzCo2Tp?OBkyG*Sj~ab#XmRWao~}P3v9UXi2<% zEF)oQekHL05pfeY3&kyY6ed86QJrS_fBb?SBA*Pmw8wTA5E7$Ab=GxSi)*;m!)tk+ zR7iKpw+m2OmzzX8G9wD+O2XQ}7m8_IgdLY{Z`;_lsefnhrtSS(wsmiE@g?F@T6{ax zW@<}PqRZ@4ss?W3b~i6zRIA1n+qYf5sbhzWQJvNxDM~Ob)yaIBwQ|cT)LABVf4N1- zRvZIQ6^gywZH@ImYOjqom>Y-lV!8?)L z_{wSr_kp9jQx?=PDz@b%iN_LqlY7jTE=_Q^nTZTVv{($j8tyYv8en>++)ncQuwytm zxCP)0#ly<3WO7e-v_i==)5aZRf1~D~HfddX=Dqc&&vu{$??sq|pswL-J-mzitDSru zKt&WYOt)5yjrrQWqQ?IJ=uf8t6tXD)Qb^#MP7RH`JiyVpL=5yaO9wicmm9r2#9<^v z-Yr=XDcar~kM42t^-Sk>B(ngD`UOOrU|>{lE7kh}fWd&g=2m0~M4 z>xlx~`5S#x%i%GwRovC;J*S*CG!NJMXe%OCtzh% zBvDkp4KqH=RHz(k_)91^7}c>yqj@Rj;$C`+{!s$q{pD{B+sX*}8bn_rMDGM6M<8-x z(!~z|-_7{$ZtyXAe+Ek;&*rGp!w>QV{9rrOAw{H%AF|9Z|BU11;{v1)F{&F6#2Pg> zo0;gaSml|5h&*mEk&EFv#@tz6|5Rb*Si=ckx$bmTg$Vde~X`jQ8puvWL%7z$Gfqt z26yo@r@YwCSVNYZ-nYf{zEdi`iox@=YRbdk6O{9P$T+1R3wI>F{H#=QEBHCUwzf=0 zp5Vbo3plYJG@1hib*xIe_+PA8M5Lg%<|N|ezrlQCX)BVxs7nj}cP|BKnOMzBqCc&8 zffrtW85n{3fA}ZZY^G3``ieAO0j`Qboqx*z;YQ^9832h;kxiLJ6L=K}0Z06z%pS}3 z8vHtxJSqe6gqMFMM)gZZjp3{Kl;Y+8v;fC%bR5VRm<%xc7XMbpQ7X*VHqZ_&ZyOrw zPJ8)x*3|F4{3uHS%zuD9kqnWFlBu!>MV$gV^W$LZe;qzqMQuC7~$@pI6pXDna(c-gz^e-=FK_) zs%0LvNG+G*skq%DH9x=7qZX=F+Iu2{U9p7eRjUz^Fj$8VsSy38P&g$1Z$)NF0WO7d z!APc?#qV6I$$HMdBL9--RV^sj`37}vA(L4@e;-KCHz)~(j4 zR{g5lmD~qxq$`4R>P}x>siact)deU}R0#C#ivdyWOS{yC;Ii<82uiJTdk~rRnm1(4 zL{wgqyy{|giR`}=&OQ)JfN$(`?Qzo z{IsEVhzO9kHz!kGbtUccsGSP%e?i@hFA6FwYp$C1lj3ImLYNUQal$q73~rYPQJ3mx zy72$%*>L)K@^YvmI8qG&QcX`@sZ@3}e`5}ox%Z&l4UT5zF{lNLPJPs}Mj@HqY8co^ zp;~sw3g>y%9>GMPP=J07CDVq-0MeMzY(~&S02VwFOUpk}NQajS$}t1)wrxlbnt zHL8@ISBKfg+LMAf72Zq z-62FURf|LRWJde3H#Aw zwGX`@IkAT*&=3yqnxJ{dX?}R?1TAQIko=7g(&FHQw6y6#I{T>3jc!SQ)=qv1H#*VI zZ^+T=+sHFXEnCOw67<%Dw?0YEfAOg|8jjPtaQ6hQfAnHotKDZ`dJipa@Y&A~ha5TD zaF{$nKfYYjce9Vle!EcAtQ^i|0GNSE;l88BJU*U)Qk>~#ctolU)SAMo2JDF=m2hM|v# z9uspVIy-y^Q}0n)@<&9aFS~46Tb5`ZH_L z2ic$xiDJ$ojTtdQe;7J61*tnuSxWRaxu z4s9zfvg}a)LiD}*R{deuCNLWWIAH>J!USHxcenhGk@xdqa4pnc*r2U_4VrI;yPlwi z#z!x*UA%P3JrrnMdVa_*?$O|f#&%EAledw>cK^*b=;6m$fB)6{i;}@jC&_2fKQJ%; z9q^dk?$qXo7%PQU-nib%N2PBZr>Dj1Cg|Ha`o8%2zlfiI6kmX{AEy_^QdYoH8eujs zP11j8=lt1WOLxEM8=v|PtZN|Hbevuf_sCe@KHr$2H*@sgA^RM=n5YBB z{0^HBwt8ZGf9e-aVQmFDdIx{Tr{2G{N!p@Kz~@gMm5(wrsez}=qIS*hJl!sU*+{#b zR_V5mT6KE?U98*9@IGm~X_szyQ9pfBhkzm74pUOMS^9`>uxWUx(dj%YB;`P9YW8^#K^q zpJB)qhl+>VCGzAi!CCgwtPWzQ*q#SAH;4@%K?~Q0b!!W7+>52iM(WkZhQ?#;>^??k z!Gq4HXulVcyk$>)(XXtv+(bJ(xT@uHC3kGUm4G3I_CVqSk=J?zkuQ|?Z!+yiw3KO`^=j@;0^))MV9Fmyicl0AD zA{(D61MoaXs4wpqsb4UBb$j7GoHZ^&_t za@sNODjq0SC?~>>}l{DO=ah8pMGBctG#&i zG4f-F-x+Ltm`$RsB3GT?x)4HnSTky9MmeVU?*l?I`LfJ9G= zidr5)wnMlr?0e`j?&h9J&d%e#BelH$mdH9ghn#-rlhm6Zop1m2=V|kCJ`fIzTHX&{ zfB2>x-y&-NP`LX^TIzQ;P4dU^XAV7e%OoEfC(l;DvwNHpK{O#ChX>&85x?^ZIyZ<6 z)9?_Z00DQO!ydW)V-3jUM`WsOjJqEz5Dt!O`8Lh*{v6*?%Xf*~jA?u{1oK1`clll5 z_Y0a|Y`nL$w79^SVS&y!wLIQ1!C!uif9AsLP4xob2Y=tI+Z*Y_((*I>1Jw-Ac$r_- z?XUSay8SOcsoRr^)md;Bn{w*5O3l;lLUopI*QqwO9c{bXBJ0)Vs#~|))h^v$t0KCM zs;qAJt6QbD;t1bOzA1z(gan7dsJFcaFF@m?si)5B-182Ny6B5Ke^=Fz z>+}_RQb(Li$$k1X=3Mmqd={%NKcJA_tQ9hSiv~*itR+~qKfG-qk~L2LPe{BoC$fBKMO43+x|V^li%Y&p4XIKN(ic5%2sU!&yh zL&aecsXPC$Q~-bhjkl%^KsyHCf2RX!3VhoTNCh$0;9D)|T#Gci1HANMv>&@aj5EK8 zkoy(j$D_aj&m&L0j1=)xNcSYwFp2=Kr@4GS3beIQ><;qrFwN&@XaWD47OEcdqXt~0 zZllHOMcg+>-c|BIA%i=lAlT6uoaC<_4fiyJkI~{nI5=7i2eteSdwyVz&f-&}L} ztycTe2HX&G+(A{f{I>`egozLmDIbPIb>pfrjvY zCusXe%a8jk()%m|fpuiLdch^O=T`Pe$g%?5DWe?@-#V-WJqFqxl2 z%pa%E==QU8M7KxjMcuwcujuxd^rmht<=kskE)M+%9^@7E2`VU!u#O(a2^2^tXpX@T zo}hDdoM;xrTUZ1Q+x6C1`KbK!eVGz$QNEZ~Sc{DcNBn`dNO@%8ZV-yHR({a5EXU=C zZ=lq*eITgOq@$wBe_kk4nIK`LR{qgplvXG$j!g28!vX+a0RVV{kll~?9S`u&{TO;J z$8U(j{Ptt~X2@x4HJq)kM!nbNGY-=tz-Azl|87OHf4kM)*yJ;O(A$ZiE{~z376B1k zf>Ixc3|%1qO3=f)aL6qsa;wMITIH+qdG4XJ{f4g!RkBx8e^}*r`@OnMu7XBi^1A_Y zjE5BTV1022lzx|da|xCP_5jpRH8ct*$e?wAF_X$0%&FO6 zYMkADK9}wfoR z#W*GIt%6|M^1ceONhk7CgA-tkc)d$q7jAXk$;*AlZ6{XbwWkx}4fjt1il^HjSH}wMv+#TGatox#zgrBt?hXe-d)SbL+#qYSrbo{@z;EW$%aY zI(sM7ww&sPWOM30hbbd+=n*Lhh|Q_1A%OmVh`?_^2*=emSU91sZGt$oan=Fps_Q~7 zyF`VM+lE-@cR_$E3hllCbXkMHi{zvse3{=gsjknd5pay2t4Z3XCn!*~6gz~LJjGIi zjc^W?f9lh*jbAw;FeeC4C-HwTUAK4ef8DyhhmYv?7=KH*-{SA`kCBkR&(G=p zPxv){4gJ^nE$ORU)W>Z|X}VR)1n9b7_obEo*VO~MFRk<+RFk?dt@IyOkLbR%(tlh% zr~A@M|A*>Fx-YHtf2w|=`_fAPb@hhsODp|1)k)o#7JZw?RxN$qqHn=Lm7!(t&{hq> zfBN30nv>)eFv#*I<<7@l9eM?-Sx*y)zw@1jJUmohV9;8YcdQ!yjhxu^01LoMVy&~sO0gUjAu98*ARGn@m1 z5mMXFK4=3p^Fe_~iI%QXE>ZqxL&qF@4ba6L5S$0Men^V5#3@% z?`=tRL99gYEeKW(LX_yeCQ1lF2+rDk=lhPc*Y*C`nfrO>nP+C->)m%|?wbL;Q-kg` z`E^as{3>Idn(xuPYKM=QVx#q>MmB^uZo{8eD8dUzZYGl9S7P!X;SsJ1W`40To<+sl zZmAuptOEGiqSj`)Ak@}Q5 z?>oTgT>`VsnwdgG=@rh+S~b(y71}W^0D$oTwH!~9DGGhr^@ua|QLKfWyPnG(TP;~` zQ8suqp7LdsDCoD?0^wj+P&GZsn&t3*E54`^DbyFuEcqPa3+H`QmzJ|@can3|x~2Ac zsL|ZK9o+zdkH{)4WG~Wp%GtT-a(qWq(q72-zQq{=?~xz(ifNc5etXe%SBkiKnwsyQ>Iodb3ly$!bBGwBc{OTwHh&aWsb`8HZVv$=&hE*qBSl>0A-YSX{_iZ3 z7y}CynY$bhT3E?|f#iq5Uu;ph>W$=1s>@S#ym2%EGje)E2RR>7$s7Z6e@~ebe$#7g<2KNa552am6cn}j_JUmoE z*-*k{Pkde>4qITx8!H4W_47<)BKiq*ka_7tj0>sib$JLMadZF1)zZ!EGBL8j zf+c6`Lx)umvSi^SAh1*UJxs0c@HZUze8mG`>KxB5H6s$OgJhGa z0}5<49xg~@kd;6zTS_FW0!q_MTXeG z%z&=wNnTr>A_)wZ-r+)3^wCkn;!jjw92Y+y^a!M>cBo4y5P)C7 zncB>M(100vC*AO4+hxa4uVhKR&(KFS`3WjTaTIAATJgFn>|w4YlvE$7+QR_T2w|cm zF?Is-5->7?lc}|w$*Z3t6)uXZDJ7A+_c34@--f98+M13#=`HDJpO^>Yb2(2&w*PAT znEx@KU;UJ-gGE+9tDa4`_G6&rQUK)o>NM6IExXSLNc3aL{8Ioux5GJgV@&n?bh8*% z>lRZlpblvSxTwS%DI2n|!8RqOVe$A#+dq8AIoJG`l@9Chv9Hi(s*c{UGL=wY57Wma zPK~6>teHgq-*Dv!uYLd2G+@Tzz+v>jbRwewwy6C@#b-BaHv6bK*9WHF^#udtG!>iT z^Xg09;1;}4$e8`JT3kqW!lyz>2;5|QrxWrXX<7UZgOth z;U1PSH*hc`2F^N}NRHp=_b49=9*e$F%){5vni-6+jUo!I@nI5=mm|jzM+l70uVinW zF|nIe=#jTFLQCnyEr`$IF^t)^+XCS7{^u<#6J+#fx$yx^OpK zk0na|$R%PznqstOkjn>bbs;uxaS7GEjW0}S*I%l2%j2(cr6>t|*305AG7}PXnyOFp zA>O~5ZkCp0P4D|L(b6II7?bh~IaCo_MZ_E@-NKv` z3{M~v^?V_y7sDwK#N2s-A%(vWo@Ut*^^8#30m**|(Za&O2V?vRFZJ>a=B&H;rde5N zx@u`~#eoKY>>KIT`NVWM7?^vM z?UC!@>IS|1eMs8D>;N>^)XbV3aeBQYZ%OkVj+BfnD3L6zc61-{+#Tx|#Qdgh5b_}; z1!Mo!@ypT33Yiy^!}l6Zv~>F}$D$Hd?Y0l06U3S&z1WkVz8gYE8hP#}gnJI8g*hR) zQ{KHZPq6^Lf3>D++A6}^`SiJs`Hrfq!6V7!04WM>2>nWe_32PMKYJ-Fv3)=7p>>7f z{JwMie)4D=<|c>?;VJFteOZ;jXqu-x(=|Rj+-5%^nzaOn2(Ev>&NfP|y||ugTUiW- zGRkn*V+XYTY}!Z!-OU?vUni@1cVC@UllDP?b3)h}1n81z`8(Yh-YH!zX+80Z!dB*K zEg1{`>rD9*_bfB`mz_PB!0CLQx?3z`L(REHCo4~JQitvAPraia*Ea8I*U8MtN}7v$ zmI^MVl`g5zYZ8dA8GWF>53K3EY;;N^l>99-oXa3?S{jSWM*c)rj*wEmp}>N3>?_$i z^D~>MAERqbs&XMg@5n$Y>Fm8-E2O&cCV`$~GRk5l7@Lv@8aEF>+APza&n9|B6N0YtUCKEY#R7OND6N3>Q@Jk(~* zub>ZnFpVNZDA+}3iYkb(!Md*v%jzM4t!PA2H~pbQ=3R7NM~*T>z9=tTS3~Rnz#BX5jEAq z>^{dE{!#Vk6koTTdzi?Ji}Z^0xXxQ3A0YmPnm7!YA&#|nwi`i(99RW#JtcA;Y5mmq zoT`<&(6?#+Lv*J($upT?qaa_bXSoxaJMRn3_}&sG5FTj%QO+Lrzcyk=-s=^5YB2&&51@6JHSWLAxXd<8h6odpMz>jyJO0bZ-qqS*gtz zW>Sacr;9r-9QU!sO_L3o-E0PizaCV@g6=}6$3zU>yso1P0)mSzv({&;DkUH-be(co z65WBK_eSH-jTG)Fwxs=rgxOIihq?%%Rq;I<_Vxni>BI_&@k+d%BSp&|ecqLGE#tLiS-y08>p`ho4=DJ~ddcB$U zE%E%EkIc;;IgE<<$yf18_eDJj`<^+M1T848T-lKeAYZZ|=25OM!#)U!pq!6t>rdzU zH6v%HfphfR@^gFHimlIy@tuV(!ImFt&z{X!oPl2w#F9oj+h*a630%{?#Cb9lMf$5h z)lyS|ZDU6138bD`+iORDlS%P`_K&vYo>-<7dw22veSn!g{czSZ<;E+fP7Fa)K^{48M%(Vuv?A{i~u;OZW>@5Bm>1aQf zfnx}%r5oGBSg^3tkOkzOJ+@@U%9=0F1aqtKHhpd<#^s_P&_?L8y_@Ic^Rh6#)q>Q0 z+pc!1fcv|2Sy*z3Cl^*3L)+U9Lc3YLUq-D5PTftYloHg}d>v9(*Y2#YRzd3SEIQepfdB`VfT3c5B3KG zpg#xSN2oSA8ls1rI%2&(2)p({+pIeL4)?s*rG2NVBBzHY#Y3Lt9C9e;xC4u`tGVu% zYSqP}+0znV&|M0d!0Y85nm-SL4-s4TgQv9|m#v!5>129pTC~sHJ3Z)VQr9_fXA0_m znAo5Dny?CX$W#-`z$cdq^%<)|%}xZHBbHfZ=yR=pN+BkcwX{X{wiz_gIK@wGr-QFbJ!uHa$s2e8Nq&+oARzt z0L~?v&st*CvgUN0jBpH;5tdj@4t*?n6#+hzlsF)INOm>c9~1&wDtG@966UZ$`dGfv z;AN^oPv3x1Zy@s$^WL@%Nod?<(EfY%HllfL*>K3Ad#lXJ!!CEYDD0BjsB4is+WCOB z!leZ>uc<&IJvctG$xvX(iKQ`L`o;W$2(276`%$04%o~cHeu#-+a#gf2 zi*f;4)~{$4FOZ6=*%$x-+bvKmF$&aQ&Wagd#(51pET=#>B1M)l!zxH=V8P``QOM`y z>b6M3Q5*n3>XtD91q%48LImrJJcIKnY@~u5eS->Bh*v2}>oQlJDu!$}1o`lG+me$J z1#)F%K{fHOC_7SAMfz3~NDBI5=k zbALOxq5?=D6H>R9=mv;|(kX+<)R1H3hyq|hNk*7&4L#kB#FjjA>|n@?=TV zd68RQ#Gn2P4tUCR1ID}|9)cy;Q=pgX{8PtH^Dl7?G7R2Z4%Sl|lxhDjnals1*|ta@ z$jR9tNc-Pm9RPsqmJbWNe?WI&6?Ui*phX>fLH;~;kr&e~u$CVxs(3#TD%zWK8!)#< z3UnX;oAQ(<9M}hcT2!z9dmRY?Fx>JX8t@m(MsxF0N$qAtkn^7Y7A6=b*CdIFdM^^( zM1jtfjcT^MiHG*TKREyZ-z}Sw925w@S%miHY4 o0DtZP035eGEx-K-gab<`qNI4LiiL#Tq=E_HOaK57m)(5(A7B0OasU7T diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2d2b8db9..c1d138b3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip -distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/scripts/sync-gateway-contract.sh b/scripts/sync-gateway-contract.sh new file mode 100755 index 00000000..f7e0b3fc --- /dev/null +++ b/scripts/sync-gateway-contract.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Refresh the vendored gateway contract snapshot from hermes-agent (#1374). +# Usage: scripts/sync-gateway-contract.sh [ref] (default: main) +set -euo pipefail + +REF="${1:-main}" +REPO="NousResearch/hermes-agent" +CONTRACT_PATH="apps/shared/src/gateway-contract.openrpc.json" +DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src/test/resources/gateway-contract" + +SHA="$(gh api "repos/$REPO/commits/$REF" --jq .sha)" +DATE="$(gh api "repos/$REPO/commits/$SHA" --jq .commit.committer.date)" + +mkdir -p "$DEST" +gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$CONTRACT_PATH?ref=$SHA" > "$DEST/openrpc.json" +jq -e '.methods | length > 0' "$DEST/openrpc.json" > /dev/null + +printf 'repo: %s\nref: %s\nsha: %s\ndate: %s\npath: %s\n' "$REPO" "$REF" "$SHA" "${DATE%%T*}" "$CONTRACT_PATH" > "$DEST/SOURCE" +echo "Vendored $REPO@$SHA. Run ./gradlew testDebugUnitTest --tests '*GatewayContract*' and review the diff."