From 1c33d497fd2d3b0d9c40e3e6a7128d272ab38e1a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 24 Jul 2026 13:31:37 +0000 Subject: [PATCH] feat: add gated tool output experience --- CHANGELOG.md | 7 ++ PROJECT_PLAN.md | 26 ++++---- .../molberg/hermesmobile/chat/ChatModels.kt | 2 + .../hermesmobile/chat/FakeChatReducer.kt | 38 ++++++++--- .../hermesmobile/chat/FakeChatScreen.kt | 64 ++++++++++++++++--- .../chat/StoredChatStateRepository.kt | 17 ++++- .../hermesmobile/chat/ToolOutputPolicy.kt | 24 +++++++ .../domain/ChatStateRepository.kt | 4 +- .../hermesmobile/domain/HermesMappers.kt | 34 ++++++---- .../domain/HermesStreamReducer.kt | 30 ++++++++- .../fake/FakeChatStateRepository.kt | 8 +++ .../streaming/GatewaySessionSseDecoder.kt | 62 +++++++++++++++++- .../GatewaySessionStreamRepository.kt | 2 +- .../hermesmobile/chat/ChatStateJsonTest.kt | 3 +- .../hermesmobile/chat/FakeChatReducerTest.kt | 17 ++++- .../hermesmobile/chat/ToolOutputPolicyTest.kt | 38 +++++++++++ .../GatewaySessionStreamRepositoryTest.kt | 33 +++++++++- .../streaming/GatewayStreamingContractTest.kt | 13 +++- docs/DIRECT_GATEWAY_ARCHITECTURE.md | 3 +- 19 files changed, 370 insertions(+), 55 deletions(-) create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicy.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f096c6e..352cd60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du ## Unreleased ### Added +- Added capability-gated direct gateway tool lifecycle decoding and compact status cards for `tool.started`, `tool.progress`, `tool.completed`, and `tool.failed` SSE events. +- Added bounded collapsed/expanded large-output windows with full-output copy, plus durable expanded-tool restoration across rotation/process resume. - Added a durable direct-gateway SSE send coordinator that captures the selected session, draft, request ID, and immutable route lease before asynchronous execution. - Added deterministic coordinator tests for successful SSE completion, upstream gateway failure, transport failure, and stale request IDs. - Added an OkHttp-backed Android session stream repository that executes the capability-advertised SSE request and reduces decoded frames through the existing lease-bound foundation. @@ -28,6 +30,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Added `PROJECT_PLAN.md` as the project-controlled milestone plan. ### Changed +- Kept direct artifact handoff actions gated off because the advertised gateway contract has no verified artifact URI/download capability; no upstream route or backend behavior was inferred. - Changed direct session streaming to publish reducer updates to the owning durable send coordinator while retaining the original immutable route/session lease for the entire OkHttp call. - Changed direct session streaming so one immutable route/session lease owns the request and every decoded event for the full OkHttp call. - Changed streamed assistant reduction so deltas can recover a missing start event and the final upstream response replaces accumulated partial text. @@ -52,6 +55,10 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. ### Verification +- Full Android Kotlin/Compose main-source and unit-test compilation through cached Kotlin 2.0.21 plus direct JUnit execution — passed, 57 tests including B5 large-output bounds, advertised tool lifecycle reduction, and rotation/resume expansion restoration. +- Recovery preflight for B5 — passed before this run. +- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug` — attempted for B5 but blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`). +- `git diff --check` — passed for B5. - `/root/.openclaw/workspace/scripts/hermes-mobile-preflight.sh` — passed; Android `:app:assembleDebug` BUILD SUCCESSFUL (35 tasks, 4 executed). - Full Android Kotlin/Compose main-source and unit-test compilation through the cached Kotlin compiler plus direct JUnit execution — passed, 52 tests including B4c coordinator success, gateway-failure, transport-failure, and stale-request-ID coverage. - `git diff --check` — passed for B4c. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index abafd95..389cb1e 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -24,7 +24,10 @@ Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only. -## Beta path — upstream-connected MVP +- [ ] **R1 — Adaptive/accessibility quality pass** + - Small phone, normal phone, tablet/two-pane behavior; portrait/landscape. + - Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior. + - Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`. ## Externally blocked @@ -33,20 +36,9 @@ Complete the Android/client contract pivot before implementing backend behavior. - Not complete: official upstream documentation still provides no session-search HTTP contract, and the configured local API server was not listening for a live compatibility exercise on 2026-07-24. - Unblock evidence is maintained in `docs/BLOCKERS.md`; do not mark B3 complete until documented search and real gateway behavior are verified. -- [ ] **B5 — Tool, artifact, and output experience** - - Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances. - - Safe rendering for large logs and code; no unbounded nested scroll failures. - - Artifact/file handoff only where upstream capability is actually supported. - - Verification: large-output and rotation/resume checks. +## Later beta work -## Beta hardening and release - -- **Queued — R1: Adaptive/accessibility quality pass** - - Small phone, normal phone, tablet/two-pane behavior; portrait/landscape. - - Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior. - - Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`. - -- **Queued — R2: Beta packaging and install guide** +- **R2 — Beta packaging and install guide** - Version `v0.1.0-beta.1`, reproducible debug/beta APK build, install/run guide, known limitations. - Publish source commit, APK, release notes, and verification result. - Verification: clean install on a real device or emulator plus full required checks. @@ -66,6 +58,12 @@ Complete the Android/client contract pivot before implementing backend behavior. _Move finished items here with date, commit, and verification. Keep this section factual; do not claim unverified work._ +- [x] **B5 — Tool, artifact, and output experience** — 2026-07-24, commit `feat: add gated tool output experience`. + - Added capability-gated decoding and reduction for the existing `tool.started`, `tool.progress`, `tool.completed`, and `tool.failed` direct gateway SSE events, with compact lifecycle/status cards for referenced and stream-only tools. + - Added full-output copy plus deterministic collapsed/expanded display windows capped at 5/80 lines and 24,000 expanded characters, avoiding same-direction nested scrolling while retaining the complete output in state. + - Persisted expanded tool IDs in durable chat state so rotation/process resume restores presentation state; artifact share/save remains unavailable because the direct gateway advertises no verified artifact handoff URI/download capability. + - Verification: the recovery preflight passed before this run; cached Kotlin 2.0.21 compilation passed for all Android Kotlin/Compose main and unit-test sources; direct JUnit passed 57 tests including large-output bounds, capability gating, lifecycle merging, and rotation/resume restoration; `git diff --check` passed. A direct Gradle `:app:testDebugUnitTest :app:assembleDebug` retry was blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`). + - [x] **B4c — Wire gateway SSE into durable chat sends** — 2026-07-24, commit `feat: wire durable gateway SSE sends`. - Added a direct-gateway send coordinator that captures the selected session, draft, route lease, and capability-built SSE request before asynchronous execution; it never reselects a route or switches an active stream. - Publishes only the currently reserved request's intermediate and terminal reducer states; terminal completion, gateway failure, and transport failure clear only the matching durable reservation while retaining the selected session and draft. diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt index f1c6b8b..814eb87 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt @@ -12,6 +12,7 @@ data class ChatContextControls( ) data class ToolResultUi( + val id: String, val name: String, val status: String, val summary: String, @@ -39,6 +40,7 @@ data class ChatSessionUi( val title: String, val subtitle: String, val messages: List, + val toolResults: List = emptyList(), val draft: String = "", val busy: Boolean = false, val pendingSendId: String? = null diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt index 95e35da..ec74894 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt @@ -67,7 +67,19 @@ object FakeChatReducer { session.copy( draft = durable.drafts[session.id].orEmpty(), busy = session.busy || durable.pendingSends.containsKey(session.id), - pendingSendId = durable.pendingSends[session.id]?.requestId + pendingSendId = durable.pendingSends[session.id]?.requestId, + messages = session.messages.map { message -> + message.copy(parts = message.parts.map { part -> + if (part is MessagePartUi.Tool) { + part.copy(result = part.result.copy(expanded = part.result.id in durable.expandedToolIds)) + } else { + part + } + }) + }, + toolResults = session.toolResults.map { result -> + result.copy(expanded = result.id in durable.expandedToolIds) + } ) } val selectedSessionId = durable.selectedSessionId?.takeIf { selected -> @@ -188,15 +200,22 @@ object FakeChatReducer { return send(cleared, sessionId, prompt, nextMessageId) } - fun toggleTool(state: FakeChatUiState, sessionId: String, messageId: String, toolName: String): FakeChatUiState = - updateMessage(state, sessionId, messageId) { message -> - message.copy(parts = message.parts.map { part -> - if (part is MessagePartUi.Tool && part.result.name == toolName) { - part.copy(result = part.result.copy(expanded = !part.result.expanded)) - } else { - part + fun toggleTool(state: FakeChatUiState, sessionId: String, toolId: String): FakeChatUiState = + updateSession(state, sessionId) { session -> + session.copy( + messages = session.messages.map { message -> + message.copy(parts = message.parts.map { part -> + if (part is MessagePartUi.Tool && part.result.id == toolId) { + part.copy(result = part.result.copy(expanded = !part.result.expanded)) + } else { + part + } + }) + }, + toolResults = session.toolResults.map { result -> + if (result.id == toolId) result.copy(expanded = !result.expanded) else result } - }) + ) } private fun updateSession( @@ -233,6 +252,7 @@ object FakeChatReducer { ), MessagePartUi.Tool( ToolResultUi( + id = "fake.context", name = "fake.context", status = "done", summary = "Read model, effort, tools, and workspace controls.", diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt index 557aace..b53862a 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt @@ -180,7 +180,16 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) { onSend = ::send, onCancel = ::cancel, onRetry = ::retry, - onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) }, + onToggleTool = { sessionId, toolId -> + state = FakeChatReducer.toggleTool(state, sessionId, toolId) + state.selectedSession?.let { selected -> + val expanded = selected.messages.flatMap { it.parts }.filterIsInstance() + .firstOrNull { it.result.id == toolId }?.result?.expanded + ?: selected.toolResults.firstOrNull { it.id == toolId }?.expanded + ?: false + chatStateRepository.setToolExpanded(FAKE_PROFILE_ID, toolId, expanded) + } + }, onContext = { state = FakeChatReducer.updateContext(state, it) }, notify = notify ) @@ -203,7 +212,15 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) { onSend = ::send, onCancel = ::cancel, onRetry = ::retry, - onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) }, + onToggleTool = { sessionId, toolId -> + state = FakeChatReducer.toggleTool(state, sessionId, toolId) + val expanded = state.selectedSession?.messages?.flatMap { it.parts } + ?.filterIsInstance() + ?.firstOrNull { it.result.id == toolId }?.result?.expanded + ?: state.selectedSession?.toolResults?.firstOrNull { it.id == toolId }?.expanded + ?: false + chatStateRepository.setToolExpanded(FAKE_PROFILE_ID, toolId, expanded) + }, onContext = { state = FakeChatReducer.updateContext(state, it) }, notify = notify ) @@ -309,7 +326,7 @@ private fun ChatConversationPane( onSend: (ChatSessionUi) -> Unit, onCancel: (ChatSessionUi) -> Unit, onRetry: (String, String) -> Unit, - onToggleTool: (String, String, String) -> Unit, + onToggleTool: (String, String) -> Unit, onContext: (ChatContextControls) -> Unit, notify: (String) -> Unit ) { @@ -337,10 +354,16 @@ private fun ChatConversationPane( ChatMessageCard( message = message, onRetry = { onRetry(session.id, message.id) }, - onToggleTool = { onToggleTool(session.id, message.id, it) }, + onToggleTool = { onToggleTool(session.id, it) }, notify = notify ) } + if (session.toolResults.isNotEmpty()) { + item { SectionHeader("Tool activity", "Live lifecycle events from the advertised gateway stream.") } + items(session.toolResults, key = { "tool:${it.id}" }) { result -> + ToolResultCard(result, onToggle = { onToggleTool(session.id, result.id) }, notify = notify) + } + } } ChatComposer( session = session, @@ -434,7 +457,7 @@ private fun ChatMessageCard( when (part) { is MessagePartUi.Text -> MarkdownText(part.text) is MessagePartUi.Code -> CodeBlockCard(part.language, part.code, notify) - is MessagePartUi.Tool -> ToolResultCard(part.result, onToggle = { onToggleTool(part.result.name) }) + is MessagePartUi.Tool -> ToolResultCard(part.result, onToggle = { onToggleTool(part.result.id) }, notify = notify) } } } @@ -486,7 +509,9 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un } @Composable -private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit) { +private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (String) -> Unit) { + val clipboard = LocalClipboardManager.current + val outputWindow = ToolOutputPolicy.window(result.output, result.expanded) FlatCard(color = HermesTheme.status.neutralPanel) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( @@ -498,16 +523,37 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit) { ) { TinyLabel(result.status) Text(result.name, color = HermesTheme.colors.text, style = HermesTheme.typography.bodyStrong, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (result.output.isNotEmpty()) { + IconButton( + onClick = { + clipboard.setText(AnnotatedString(result.output)) + notify("Tool output copied.") + }, + modifier = Modifier.size(36.dp) + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted) + } + } Icon(if (result.expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = if (result.expanded) "Collapse tool result" else "Expand tool result", tint = HermesTheme.colors.textMuted) } Text(result.summary, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption) - if (result.expanded) { + if (result.output.isNotEmpty()) { SelectionContainer { Text( - result.output, + outputWindow.text, modifier = Modifier.horizontalScroll(rememberScrollState()), color = HermesTheme.colors.codeText, - style = HermesTheme.typography.code + style = HermesTheme.typography.code, + maxLines = if (result.expanded) ToolOutputPolicy.ExpandedLines else ToolOutputPolicy.CollapsedLines, + overflow = TextOverflow.Clip + ) + } + if (outputWindow.truncated) { + Text( + if (result.expanded) "Large output capped for safe display. Copy includes the full output." + else "Expand to show more. Copy includes the full output.", + color = HermesTheme.colors.textMuted, + style = HermesTheme.typography.caption ) } } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt index c6b6545..5923e52 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import cloud.molberg.hermesmobile.domain.ChatStateRepository import cloud.molberg.hermesmobile.domain.DurableChatState import cloud.molberg.hermesmobile.domain.PendingChatSend +import org.json.JSONArray import org.json.JSONObject class StoredChatStateRepository(context: Context) : ChatStateRepository { @@ -46,6 +47,15 @@ class StoredChatStateRepository(context: Context) : ChatStateRepository { } } + @Synchronized + override fun setToolExpanded(profileId: String, toolId: String, expanded: Boolean) { + update(profileId) { state -> + state.copy( + expandedToolIds = if (expanded) state.expandedToolIds + toolId else state.expandedToolIds - toolId + ) + } + } + @Synchronized override fun clearSession(profileId: String, sessionId: String) { update(profileId) { state -> @@ -81,6 +91,7 @@ object ChatStateJson { put(sessionId, JSONObject().put("requestId", pending.requestId).put("prompt", pending.prompt)) } }) + put("expandedToolIds", JSONArray(state.expandedToolIds.sorted())) }.toString() fun decode(value: String?): DurableChatState { @@ -89,6 +100,7 @@ object ChatStateJson { val json = JSONObject(value) val draftsJson = json.optJSONObject("drafts") ?: JSONObject() val pendingJson = json.optJSONObject("pendingSends") ?: JSONObject() + val expandedJson = json.optJSONArray("expandedToolIds") ?: JSONArray() DurableChatState( selectedSessionId = json.optString("selectedSessionId").takeIf { it.isNotBlank() }, drafts = draftsJson.keys().asSequence().associateWith { draftsJson.getString(it) }, @@ -96,7 +108,10 @@ object ChatStateJson { pendingJson.getJSONObject(sessionId).let { pending -> PendingChatSend(pending.getString("requestId"), pending.getString("prompt")) } - } + }, + expandedToolIds = (0 until expandedJson.length()).mapNotNull { index -> + expandedJson.optString(index).takeIf { it.isNotBlank() } + }.toSet() ) }.getOrDefault(DurableChatState()) } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicy.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicy.kt new file mode 100644 index 0000000..33924bb --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicy.kt @@ -0,0 +1,24 @@ +package cloud.molberg.hermesmobile.chat + +data class ToolOutputWindow( + val text: String, + val truncated: Boolean +) + +object ToolOutputPolicy { + const val CollapsedLines = 5 + const val ExpandedLines = 80 + const val ExpandedCharacters = 24_000 + + fun window(output: String, expanded: Boolean): ToolOutputWindow { + val lineLimit = if (expanded) ExpandedLines else CollapsedLines + val characterLimit = if (expanded) ExpandedCharacters else ExpandedCharacters.coerceAtMost(2_000) + val lines = output.lineSequence().take(lineLimit + 1).toList() + val lineWindow = lines.take(lineLimit).joinToString("\n") + val text = lineWindow.take(characterLimit) + return ToolOutputWindow( + text = text, + truncated = lines.size > lineLimit || lineWindow.length > characterLimit || text.length < output.length + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt index 38ec5ca..0675e80 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt @@ -8,7 +8,8 @@ data class PendingChatSend( data class DurableChatState( val selectedSessionId: String? = null, val drafts: Map = emptyMap(), - val pendingSends: Map = emptyMap() + val pendingSends: Map = emptyMap(), + val expandedToolIds: Set = emptySet() ) interface ChatStateRepository { @@ -17,5 +18,6 @@ interface ChatStateRepository { fun saveDraft(profileId: String, sessionId: String, draft: String) fun reserveSend(profileId: String, sessionId: String, requestId: String, prompt: String): Boolean fun finishSend(profileId: String, sessionId: String, requestId: String) + fun setToolExpanded(profileId: String, toolId: String, expanded: Boolean) fun clearSession(profileId: String, sessionId: String) } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt index d32b878..888fd8d 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt @@ -118,50 +118,62 @@ fun StreamEventDto.toDomain(): HermesStreamEvent = is StreamEventDto.Done -> HermesStreamEvent.Done(id, sessionId, timestamp) } -fun HermesConversationState.toChatUiState(selectedSessionId: String = session.id): FakeChatUiState = +fun HermesConversationState.toChatUiState( + selectedSessionId: String = session.id, + expandedToolIds: Set = emptySet() +): FakeChatUiState = FakeChatUiState( - sessions = listOf(toChatSessionUi()), + sessions = listOf(toChatSessionUi(expandedToolIds)), selectedSessionId = selectedSessionId, connectionState = session.status.toConnectionState(error), statusText = error ?: session.subtitle ) -fun HermesConversationState.toChatSessionUi(): ChatSessionUi = - ChatSessionUi( +fun HermesConversationState.toChatSessionUi(expandedToolIds: Set = emptySet()): ChatSessionUi { + val referencedToolIds = messages.flatMap { message -> + message.parts.filterIsInstance().map { it.resultId } + }.toSet() + return ChatSessionUi( id = session.id, title = session.title, subtitle = error ?: session.subtitle, - messages = messages.map { it.toChatMessageUi(tools, artifacts) }, + messages = messages.map { it.toChatMessageUi(tools, artifacts, expandedToolIds) }, + toolResults = tools.values + .filterNot { it.id in referencedToolIds } + .map { it.toToolResultUi(it.id in expandedToolIds) }, busy = session.status == SessionStatus.Running || session.status == SessionStatus.Reconnecting ) +} fun HermesMessage.toChatMessageUi( tools: Map = emptyMap(), - artifacts: Map = emptyMap() + artifacts: Map = emptyMap(), + expandedToolIds: Set = emptySet() ): ChatMessageUi = ChatMessageUi( id = id, role = role.toChatRole(), title = role.toTitle(), - parts = parts.mapNotNull { it.toChatPartUi(tools, artifacts) }, + parts = parts.mapNotNull { it.toChatPartUi(tools, artifacts, expandedToolIds) }, streaming = status == MessageStatus.Streaming || status == MessageStatus.Pending ) private fun MessagePart.toChatPartUi( tools: Map, - artifacts: Map + artifacts: Map, + expandedToolIds: Set ): MessagePartUi? = when (this) { is MessagePart.Text -> MessagePartUi.Text(text) is MessagePart.Code -> MessagePartUi.Code(language, code) - is MessagePart.ToolResultRef -> tools[resultId]?.toToolResultUi()?.let { MessagePartUi.Tool(it) } + is MessagePart.ToolResultRef -> tools[resultId]?.toToolResultUi(resultId in expandedToolIds)?.let { MessagePartUi.Tool(it) } is MessagePart.ArtifactRef -> artifacts[artifactId]?.let { artifact -> MessagePartUi.Text("Artifact: ${artifact.name}") } } -private fun ToolResult.toToolResultUi(): ToolResultUi = - ToolResultUi(name = name, status = status.toReadableLabel(), summary = summary, output = output) +private fun ToolResult.toToolResultUi(expanded: Boolean): ToolResultUi = + ToolResultUi(id = id, name = name, status = status.toReadableLabel(), summary = summary, output = output, expanded = expanded) private fun SessionStatus.toConnectionState(error: String?): ChatConnectionState = when { diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt index 61bc938..2aa0b4f 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt @@ -27,7 +27,7 @@ object HermesStreamReducer { ) is HermesStreamEvent.ToolUpdated -> state.copy( - tools = state.tools + (event.result.id to event.result), + tools = state.tools + (event.result.id to state.tools[event.result.id].merge(event.result)), error = null ) @@ -73,6 +73,34 @@ object HermesStreamReducer { return copy(parts = updated) } + private fun ToolResult?.merge(update: ToolResult): ToolResult { + val current = this ?: return update + val summary = if (update.summary == update.status.readableLabel() && current.summary.isNotBlank()) { + current.summary + } else { + update.summary.ifBlank { current.summary } + } + return update.copy( + callId = update.callId.ifBlank { current.callId }, + name = update.name.ifBlank { current.name }, + summary = summary, + inputPreview = update.inputPreview ?: current.inputPreview, + output = update.output.ifBlank { current.output }, + exitCode = update.exitCode ?: current.exitCode, + startedAt = current.startedAt ?: update.startedAt, + completedAt = update.completedAt ?: current.completedAt + ) + } + + private fun ToolStatus.readableLabel(): String = + when (this) { + ToolStatus.Queued -> "queued" + ToolStatus.Running -> "running" + ToolStatus.Succeeded -> "done" + ToolStatus.Failed -> "failed" + ToolStatus.Cancelled -> "cancelled" + } + private fun SessionStatus.toSubtitle(): String = when (this) { SessionStatus.Idle -> "Ready" diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt index 85896f0..b9ae85b 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt @@ -40,6 +40,14 @@ class FakeChatStateRepository(initial: Map = emptyMap( } } + override fun setToolExpanded(profileId: String, toolId: String, expanded: Boolean) { + update(profileId) { state -> + state.copy( + expandedToolIds = if (expanded) state.expandedToolIds + toolId else state.expandedToolIds - toolId + ) + } + } + @Synchronized override fun clearSession(profileId: String, sessionId: String) { update(profileId) { state -> diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionSseDecoder.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionSseDecoder.kt index 8d236ba..1a47ae3 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionSseDecoder.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionSseDecoder.kt @@ -5,11 +5,13 @@ import cloud.molberg.hermesmobile.domain.HermesStreamEvent import cloud.molberg.hermesmobile.domain.MessagePart import cloud.molberg.hermesmobile.domain.MessageRole import cloud.molberg.hermesmobile.domain.MessageStatus +import cloud.molberg.hermesmobile.domain.ToolResult +import cloud.molberg.hermesmobile.domain.ToolStatus +import org.json.JSONArray import org.json.JSONObject sealed interface GatewayDecodedStreamEvent { data class Domain(val event: HermesStreamEvent) : GatewayDecodedStreamEvent - data class Structured(val name: String, val payload: JSONObject) : GatewayDecodedStreamEvent } object GatewaySessionSseDecoder { @@ -83,11 +85,67 @@ object GatewaySessionSseDecoder { ) "done" -> GatewayDecodedStreamEvent.Domain(HermesStreamEvent.Done(eventId, sessionId, timestamp)) - in STRUCTURED_EVENTS -> GatewayDecodedStreamEvent.Structured(eventName, payload) + in STRUCTURED_EVENTS -> payload.toToolEvent(eventName, eventId, sessionId, timestamp) else -> null } } + private fun JSONObject.toToolEvent( + eventName: String, + eventId: String, + sessionId: String, + timestamp: String? + ): GatewayDecodedStreamEvent.Domain? { + val name = optString("tool_name").takeIf { it.isNotBlank() } ?: return null + val callId = firstString("tool_call_id", "call_id").ifBlank { name } + val toolId = firstString("tool_result_id", "result_id", "tool_call_id", "call_id").ifBlank { callId } + val status = when (eventName) { + "tool.started", "tool.progress" -> ToolStatus.Running + "tool.completed" -> ToolStatus.Succeeded + "tool.failed" -> ToolStatus.Failed + else -> return null + } + val summary = firstString("message", "progress", "summary").ifBlank { status.readableLabel() } + return GatewayDecodedStreamEvent.Domain( + HermesStreamEvent.ToolUpdated( + id = eventId, + sessionId = sessionId, + timestamp = timestamp, + result = ToolResult( + id = toolId, + callId = callId, + name = name, + status = status, + summary = summary, + inputPreview = jsonValue("input"), + output = jsonValue("output") ?: jsonValue("result") ?: "", + exitCode = optInt("exit_code").takeIf { has("exit_code") }, + startedAt = timestamp.takeIf { status == ToolStatus.Running }, + completedAt = timestamp.takeIf { status == ToolStatus.Succeeded || status == ToolStatus.Failed } + ) + ) + ) + } + + private fun JSONObject.firstString(vararg names: String): String = + names.firstNotNullOfOrNull { name -> optString(name).takeIf { it.isNotBlank() } }.orEmpty() + + private fun JSONObject.jsonValue(name: String): String? = + when (val value = opt(name)) { + null, JSONObject.NULL -> null + is JSONObject, is JSONArray -> value.toString() + else -> value.toString() + } + + private fun ToolStatus.readableLabel(): String = + when (this) { + ToolStatus.Queued -> "queued" + ToolStatus.Running -> "running" + ToolStatus.Succeeded -> "done" + ToolStatus.Failed -> "failed" + ToolStatus.Cancelled -> "cancelled" + } + private fun JSONObject.requiredString(name: String): String? = optString(name).takeIf { it.isNotBlank() } private val STRUCTURED_EVENTS = setOf("tool.progress", "tool.started", "tool.completed", "tool.failed") diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepository.kt index ef6297f..634a270 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepository.kt @@ -35,7 +35,7 @@ class GatewaySessionStreamRepository( publishState(state) } - is GatewayDecodedStreamEvent.Structured, null -> Unit + null -> Unit } } } diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt index bf41dd6..8d0d302 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt @@ -11,7 +11,8 @@ class ChatStateJsonTest { val state = DurableChatState( selectedSessionId = "session-1", drafts = mapOf("session-1" to "keep this draft"), - pendingSends = mapOf("session-1" to PendingChatSend("request-1", "send once")) + pendingSends = mapOf("session-1" to PendingChatSend("request-1", "send once")), + expandedToolIds = setOf("tool-2", "tool-1") ) assertEquals(state, ChatStateJson.decode(ChatStateJson.encode(state))) diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt index d2a213a..c3cc1de 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt @@ -101,9 +101,24 @@ class FakeChatReducerTest { val session = state.selectedSession!! val message = session.messages.last() - val toggled = FakeChatReducer.toggleTool(state, session.id, message.id, "workspace.inspect") + val toolId = message.parts.filterIsInstance().single().result.id + val toggled = FakeChatReducer.toggleTool(state, session.id, toolId) val part = toggled.selectedSession!!.messages.last().parts.filterIsInstance().single() assertTrue(part.result.expanded) } + + @Test + fun restoreReappliesExpandedToolAfterRotationOrResume() { + val seeded = FakeChatReducer.seed() + val tool = seeded.selectedSession!!.messages.last().parts.filterIsInstance().single().result + val restored = FakeChatReducer.restore( + FakeChatReducer.seed(), + DurableChatState(selectedSessionId = seeded.selectedSessionId, expandedToolIds = setOf(tool.id)) + ) + + val restoredTool = restored.selectedSession!!.messages.last().parts.filterIsInstance().single().result + + assertTrue(restoredTool.expanded) + } } diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicyTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicyTest.kt new file mode 100644 index 0000000..a590bfa --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ToolOutputPolicyTest.kt @@ -0,0 +1,38 @@ +package cloud.molberg.hermesmobile.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ToolOutputPolicyTest { + @Test + fun collapsedLargeOutputUsesSmallDeterministicWindow() { + val output = (1..200).joinToString("\n") { "line-$it ${"x".repeat(40)}" } + + val window = ToolOutputPolicy.window(output, expanded = false) + + assertEquals(ToolOutputPolicy.CollapsedLines, window.text.lines().size) + assertTrue(window.truncated) + assertFalse(window.text.contains("line-6")) + } + + @Test + fun expandedLargeOutputRemainsBoundedWithoutNestedVerticalScrolling() { + val output = (1..500).joinToString("\n") { "line-$it ${"x".repeat(400)}" } + + val window = ToolOutputPolicy.window(output, expanded = true) + + assertTrue(window.text.length <= ToolOutputPolicy.ExpandedCharacters) + assertTrue(window.text.lines().size <= ToolOutputPolicy.ExpandedLines) + assertTrue(window.truncated) + } + + @Test + fun smallOutputIsUnchanged() { + val window = ToolOutputPolicy.window("one\ntwo", expanded = true) + + assertEquals("one\ntwo", window.text) + assertFalse(window.truncated) + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepositoryTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepositoryTest.kt index ea698a0..257f957 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepositoryTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewaySessionStreamRepositoryTest.kt @@ -6,6 +6,7 @@ import cloud.molberg.hermesmobile.domain.HermesConversationState import cloud.molberg.hermesmobile.domain.HermesSession import cloud.molberg.hermesmobile.domain.MessagePart import cloud.molberg.hermesmobile.domain.SessionStatus +import cloud.molberg.hermesmobile.domain.ToolStatus import java.io.IOException import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -97,10 +98,40 @@ class GatewaySessionStreamRepositoryTest { assertEquals("Hermes rejected the turn.", result.error) } - private fun request(routeLease: GatewayRouteLease): GatewayStreamRequest = + @Test + fun advertisedToolLifecycleIsReducedAndLargeOutputIsRetainedForCopy() { + val output = (1..300).joinToString("\\n") { "line-$it" } + val repository = GatewaySessionStreamRepository( + client = client { chain -> + response( + chain, + """ + event: tool.started + data: {"session_id":"session-1","tool_call_id":"call-1","tool_name":"terminal","message":"Running"} + + event: tool.completed + data: {"session_id":"session-1","tool_call_id":"call-1","tool_name":"terminal","output":"$output","exit_code":0} + + """.trimIndent() + ) + }, + reportTransportFailure = { error("Successful stream must not report route failure.") } + ) + + val result = repository.execute(request(LOCAL_LEASE, toolEvents = true), state("session-1")) + val tool = result.tools.getValue("call-1") + + assertEquals(ToolStatus.Succeeded, tool.status) + assertEquals(0, tool.exitCode) + assertEquals(output.replace("\\n", "\n"), tool.output) + assertEquals("Running", tool.summary) + } + + private fun request(routeLease: GatewayRouteLease, toolEvents: Boolean = false): GatewayStreamRequest = GatewayStreamingContract( GatewayStreamingCapabilities( sessionChatStreaming = true, + toolProgressEvents = toolEvents, sessionChatStream = GatewayCapabilityEndpoint( method = "POST", path = "/api/sessions/{session_id}/chat/stream" diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewayStreamingContractTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewayStreamingContractTest.kt index 2ab1d44..b1e99b4 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewayStreamingContractTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/streaming/GatewayStreamingContractTest.kt @@ -6,6 +6,7 @@ import cloud.molberg.hermesmobile.domain.HermesConversationState import cloud.molberg.hermesmobile.domain.HermesSession import cloud.molberg.hermesmobile.domain.MessagePart import cloud.molberg.hermesmobile.domain.SessionStatus +import cloud.molberg.hermesmobile.domain.ToolStatus import okio.Buffer import org.json.JSONObject import org.junit.Assert.assertEquals @@ -110,9 +111,17 @@ class GatewayStreamingContractTest { "hello" ).lease - val decoded = GatewaySessionSseDecoder.decode(lease, "tool.started", """{"tool_name":"terminal"}""") + val decoded = GatewaySessionSseDecoder.decode( + lease, + "tool.started", + """{"session_id":"session-1","tool_call_id":"call-1","tool_name":"terminal","input":{"command":"pwd"}}""" + ) as GatewayDecodedStreamEvent.Domain + val event = decoded.event as cloud.molberg.hermesmobile.domain.HermesStreamEvent.ToolUpdated - assertTrue(decoded is GatewayDecodedStreamEvent.Structured) + assertEquals("call-1", event.result.id) + assertEquals("terminal", event.result.name) + assertEquals(ToolStatus.Running, event.result.status) + assertEquals("{\"command\":\"pwd\"}", event.result.inputPreview) } private fun capabilities(toolProgressEvents: Boolean = false): GatewayStreamingCapabilities = diff --git a/docs/DIRECT_GATEWAY_ARCHITECTURE.md b/docs/DIRECT_GATEWAY_ARCHITECTURE.md index 085b524..3bc2316 100644 --- a/docs/DIRECT_GATEWAY_ARCHITECTURE.md +++ b/docs/DIRECT_GATEWAY_ARCHITECTURE.md @@ -47,7 +47,8 @@ Any route not listed here must be labeled provisional until checked against offi - Never apply an SSE event whose `session_id` differs from the bound lease. A later Local/Remote re-evaluation applies only to a later request. - Append `assistant.delta` text deterministically, then replace it with the authoritative `assistant.completed.content` response when present. - Report HTTP, I/O, or malformed-SSE failure only against the route/session lease that owned the failed call; an upstream `error` event fails the session without invalidating the route. -- Decode tool lifecycle/progress only when `tool_progress_events` is advertised; B4 does not render or persist those structured payloads. +- Decode and render tool lifecycle/progress only when `tool_progress_events` is advertised. Treat event payload fields defensively, retain large output outside bounded display windows, and persist only client presentation state such as expansion. +- Do not expose artifact share/save actions until an upstream artifact handoff capability and URI/download contract are advertised; the current direct gateway contract has neither. ## Unverified Assumptions