Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60c5d9eeb | ||
|
|
9e201e54d7 | ||
|
|
4834fcbdf5 |
+1
-1
@@ -22,9 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
|
||||
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
|
||||
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
@@ -797,6 +797,11 @@ class GatewayChatClient(
|
||||
*/
|
||||
fun hasActiveTurn(): Boolean = activeTurn?.ended == false || backgroundTurns.isNotEmpty()
|
||||
|
||||
/** True only when [storedId] still owns a foreground or deliberately detached turn. */
|
||||
fun hasActiveTurnForSession(storedId: String): Boolean =
|
||||
(activeTurn?.ended == false && storedSessionId == storedId) ||
|
||||
backgroundTurns.values.any { it.storedSessionId == storedId }
|
||||
|
||||
/** Live id to persist beside a durable stored id while a turn is active. */
|
||||
fun currentLiveSessionId(storedId: String): String? =
|
||||
liveSessionId?.takeIf { storedSessionId == storedId }
|
||||
|
||||
@@ -396,6 +396,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private var firstTokenNotified = false
|
||||
private var toolHistoryJob: Job? = null
|
||||
private var gatewayComposerSettlementJob: Job? = null
|
||||
private var backgroundProcessSessionJob: Job? = null
|
||||
private var connectionSwitchJob: Job? = null
|
||||
private var sessionRefreshJob: Job? = null
|
||||
@@ -3403,6 +3404,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (this.chatHandler !== chatHandler) {
|
||||
checkpointStatusJob?.cancel()
|
||||
checkpointStatusJob = null
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = null
|
||||
}
|
||||
this.chatHandler = chatHandler
|
||||
ensureCheckpointObservers()
|
||||
@@ -3454,6 +3457,26 @@ class ChatViewModel : ViewModel() {
|
||||
scheduleCheckpointWrite()
|
||||
}
|
||||
}
|
||||
gatewayComposerSettlementJob?.cancel()
|
||||
gatewayComposerSettlementJob = viewModelScope.launch {
|
||||
chatHandler.messages.collect { messages ->
|
||||
val storedSessionId = chatHandler.currentSessionId.value ?: return@collect
|
||||
val client = gatewayClient ?: return@collect
|
||||
if (
|
||||
streamingEndpoint == "gateway" &&
|
||||
chatHandler.isStreaming.value &&
|
||||
messages.none { it.isStreaming || it.isThinkingStreaming } &&
|
||||
!client.hasActiveTurnForSession(storedSessionId)
|
||||
) {
|
||||
// A terminal bubble with no matching live or detached
|
||||
// Gateway owner is an orphaned handler-wide busy bit. Clear
|
||||
// it without disturbing a different session's active turn.
|
||||
chatHandler.clearStreamingStatus()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+41
-2
@@ -1005,10 +1005,13 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
|
||||
@Test
|
||||
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
|
||||
// Exercise Stop's route-independent fallback without the Gateway
|
||||
// orphan-state observer settling this synthetic state first.
|
||||
viewModel.streamingEndpoint = "sessions"
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
|
||||
viewModel.cancelStream()
|
||||
|
||||
@@ -1016,10 +1019,46 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertNull(handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedGatewayBubbleAutomaticallySettlesComposerWithoutInput() {
|
||||
handler.onTextDelta("completed-answer", "Finished answer")
|
||||
handler.onTurnComplete("completed-answer")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertFalse(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "prompt.submit" })
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedBubbleKeepsComposerBusyWhileGatewaySessionStillOwnsTheRun() {
|
||||
viewModel.sendMessage("Continue through another assistant turn")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID) }
|
||||
val assistantId = handler.messages.value.single { it.role == MessageRole.ASSISTANT }.id
|
||||
|
||||
handler.onTextDelta(assistantId, "First assistant message")
|
||||
handler.onTurnComplete(assistantId)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertTrue(gatewayClient.hasActiveTurnForSession(STORED_SESSION_ID))
|
||||
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Final assistant message") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(gatewayClient.hasActiveTurn())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user