feat: add gated tool output experience
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
+29
-9
@@ -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.",
|
||||
|
||||
+55
-9
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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())
|
||||
}
|
||||
|
||||
+24
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
|
||||
+23
-11
@@ -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 {
|
||||
|
||||
+29
-1
@@ -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"
|
||||
|
||||
+8
@@ -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 ->
|
||||
|
||||
+60
-2
@@ -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")
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class GatewaySessionStreamRepository(
|
||||
publishState(state)
|
||||
}
|
||||
|
||||
is GatewayDecodedStreamEvent.Structured, null -> Unit
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)))
|
||||
|
||||
+16
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -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"
|
||||
|
||||
+11
-2
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user