feat: add gated tool output experience

This commit is contained in:
Hermes Agent
2026-07-24 13:31:37 +00:00
parent c75dddae2b
commit 1c33d497fd
19 changed files with 370 additions and 55 deletions
+7
View File
@@ -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.
+12 -14
View File
@@ -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.
@@ -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<ChatMessageUi>,
val toolResults: List<ToolResultUi> = emptyList(),
val draft: String = "",
val busy: Boolean = false,
val pendingSendId: String? = null
@@ -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.",
@@ -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<MessagePartUi.Tool>()
.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<MessagePartUi.Tool>()
?.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
)
}
}
@@ -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())
}
@@ -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
)
}
}
@@ -8,7 +8,8 @@ data class PendingChatSend(
data class DurableChatState(
val selectedSessionId: String? = null,
val drafts: Map<String, String> = emptyMap(),
val pendingSends: Map<String, PendingChatSend> = emptyMap()
val pendingSends: Map<String, PendingChatSend> = emptyMap(),
val expandedToolIds: Set<String> = 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)
}
@@ -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<String> = 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<String> = emptySet()): ChatSessionUi {
val referencedToolIds = messages.flatMap { message ->
message.parts.filterIsInstance<MessagePart.ToolResultRef>().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<String, ToolResult> = emptyMap(),
artifacts: Map<String, Artifact> = emptyMap()
artifacts: Map<String, Artifact> = emptyMap(),
expandedToolIds: Set<String> = 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<String, ToolResult>,
artifacts: Map<String, Artifact>
artifacts: Map<String, Artifact>,
expandedToolIds: Set<String>
): 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 {
@@ -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"
@@ -40,6 +40,14 @@ class FakeChatStateRepository(initial: Map<String, DurableChatState> = 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 ->
@@ -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")
@@ -35,7 +35,7 @@ class GatewaySessionStreamRepository(
publishState(state)
}
is GatewayDecodedStreamEvent.Structured, null -> Unit
null -> Unit
}
}
}
@@ -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)))
@@ -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<MessagePartUi.Tool>().single().result.id
val toggled = FakeChatReducer.toggleTool(state, session.id, toolId)
val part = toggled.selectedSession!!.messages.last().parts.filterIsInstance<MessagePartUi.Tool>().single()
assertTrue(part.result.expanded)
}
@Test
fun restoreReappliesExpandedToolAfterRotationOrResume() {
val seeded = FakeChatReducer.seed()
val tool = seeded.selectedSession!!.messages.last().parts.filterIsInstance<MessagePartUi.Tool>().single().result
val restored = FakeChatReducer.restore(
FakeChatReducer.seed(),
DurableChatState(selectedSessionId = seeded.selectedSessionId, expandedToolIds = setOf(tool.id))
)
val restoredTool = restored.selectedSession!!.messages.last().parts.filterIsInstance<MessagePartUi.Tool>().single().result
assertTrue(restoredTool.expanded)
}
}
@@ -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)
}
}
@@ -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"
@@ -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 =
+2 -1
View File
@@ -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