Compare commits

...
Author SHA1 Message Date
Bailey Dixon b60c5d9eeb Merge remote-tracking branch 'origin/dev' into codex/pr-425-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 21:27:03 -04:00
Bailey Dixon 9e201e54d7 Merge pull request #393 from Codename-11/feature/provider-usage
feat: add provider-aware usage and limits (salvages #384)
2026-08-24 21:13:20 -04:00
Bailey Dixon 4834fcbdf5 fix(android): settle orphaned gateway composer state 2026-08-24 20:43:53 -04:00
4 changed files with 70 additions and 3 deletions
+1 -1
View File
@@ -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
}
}
}
}
/**
@@ -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())