Compare commits

...
Author SHA1 Message Date
Bailey Dixonandlayerflex c8cd13e5bc fix(android): render bot chat history after binding
Preserve the route-owned ChatHandler as the rendered state source from first composition and cover fast/delayed history plus handler replacement and on-device lifecycle recovery.

Supersedes PR #453 while preserving contributor credit.

Co-authored-by: layerflex <254160994+layerflex@users.noreply.github.com>
2026-08-31 10:08:20 -04:00
Bailey Dixon ad4175bb6d test(android): remove obsolete clean chat instrumentation 2026-08-31 10:08:20 -04:00
dependabot[bot] 6b7cb706e0 chore(deps): bump coil from 3.5.0 to 3.6.0 (#502)
Bumps `coil` from 3.5.0 to 3.6.0.

Updates `io.coil-kt.coil3:coil-compose` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

Updates `io.coil-kt.coil3:coil-gif` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

---
updated-dependencies:
- dependency-name: io.coil-kt.coil3:coil-compose
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.coil-kt.coil3:coil-gif
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.coil-kt.coil3:coil-network-okhttp
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 12:00:18 +00:00
dependabot[bot] 6031427ffb chore(deps): bump haze from 1.7.2 to 1.7.3 (#504)
Bumps `haze` from 1.7.2 to 1.7.3.

Updates `dev.chrisbanes.haze:haze` from 1.7.2 to 1.7.3
- [Release notes](https://github.com/chrisbanes/haze/releases)
- [Changelog](https://github.com/chrisbanes/haze/blob/1.7.3/CHANGELOG.md)
- [Commits](https://github.com/chrisbanes/haze/compare/1.7.2...1.7.3)

Updates `dev.chrisbanes.haze:haze-materials` from 1.7.2 to 1.7.3
- [Release notes](https://github.com/chrisbanes/haze/releases)
- [Changelog](https://github.com/chrisbanes/haze/blob/1.7.3/CHANGELOG.md)
- [Commits](https://github.com/chrisbanes/haze/compare/1.7.2...1.7.3)

---
updated-dependencies:
- dependency-name: dev.chrisbanes.haze:haze
  dependency-version: 1.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: dev.chrisbanes.haze:haze-materials
  dependency-version: 1.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:59:10 +00:00
dependabot[bot] 53dd21400a chore(deps): bump org.jetbrains.compose from 1.11.1 to 1.12.0 (#503)
Bumps [org.jetbrains.compose](https://github.com/JetBrains/compose-multiplatform) from 1.11.1 to 1.12.0.
- [Release notes](https://github.com/JetBrains/compose-multiplatform/releases)
- [Changelog](https://github.com/JetBrains/compose-multiplatform/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/compose-multiplatform/compare/v1.11.1...v1.12.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.compose
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:58:09 +00:00
dependabot[bot] 802a0b0844 chore(deps): bump androidx.navigation:navigation-compose (#501)
Bumps androidx.navigation:navigation-compose from 2.9.8 to 2.10.0.

---
updated-dependencies:
- dependency-name: androidx.navigation:navigation-compose
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:57:10 +00:00
dependabot[bot] 7658329ca7 chore(deps): bump markdown-renderer from 0.44.0 to 0.45.0 (#500)
Bumps `markdown-renderer` from 0.44.0 to 0.45.0.

Updates `com.mikepenz:multiplatform-markdown-renderer-m3` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.44.0...v0.45.0)

Updates `com.mikepenz:multiplatform-markdown-renderer-code` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-m3
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-code
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:56:41 +00:00
dependabot[bot] 1622db0b23 chore(deps): bump the testing group with 2 updates (#498)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.72.0 to 1.73.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.72.0...1.73.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.72.0 to 1.73.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.72.0...1.73.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:56:30 +00:00
Bailey Dixon bca3cd0e48 fix(plugin): harden Git workspace path containment (#492) 2026-08-30 23:30:51 -04:00
14 changed files with 517 additions and 64 deletions
+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
## [Android 1.14.0] - 2026-08-30
### Added
+2 -2
View File
@@ -399,8 +399,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -7,7 +7,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.platform.app.InstrumentationRegistry
@@ -28,50 +27,6 @@ class AmbientVisualizationVisibilityTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
composeTestRule.setContent {
AmbientTestProviders(enabled = false) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = true,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
.assertExists()
}
@Test
fun cleanMode_backgroundOn_rendersSphere() {
composeTestRule.setContent {
AmbientTestProviders(enabled = true) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = false,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
}
@Test
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
composeTestRule.setContent {
@@ -0,0 +1,160 @@
package com.hermesandroid.relay.ui.screens
import android.os.Handler
import android.os.Looper
import android.view.accessibility.AccessibilityNodeInfo
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.viewmodel.AndroidGatewayContractFixture
import com.hermesandroid.relay.viewmodel.ChatViewModel
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** On-device proof for the route-owned first-composition collection boundary. */
class BotChatScreenBindingInstrumentedTest {
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var dashboardClient: DashboardApiClient
private lateinit var gatewayClient: GatewayChatClient
private lateinit var viewModel: ChatViewModel
private lateinit var handler: ChatHandler
private var activityScenario: ActivityScenario<BotChatBindingTestActivity>? = null
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture()
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
dashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
)
gatewayClient = GatewayChatClient(
initialDashboardClient = dashboardClient,
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel = ChatViewModel()
handler = ChatHandler()
}
@After
fun tearDown() {
activityScenario?.close()
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
dashboardClient.shutdown()
fixture.shutdown()
}
@Test
fun fastInitialHistoryRendersBeforeNavigationAndSurvivesLifecycleResume() {
val route = BotGatewayRoute(
key = BotGatewayRouteKey("fixture-gateway", PROFILE_NAME),
connectionLabel = "Fixture gateway",
)
val bot = BotRosterEntry(
profile = Profile(
name = PROFILE_NAME,
model = "fixture-model",
description = "Fixture profile",
),
displayName = "Research",
route = route,
)
val scenario = ActivityScenario.launch(BotChatBindingTestActivity::class.java)
.also { activityScenario = it }
scenario.onActivity { activity ->
activity.setContent {
MaterialTheme {
BotChatScreen(
route = route,
bot = bot,
sessionId = STORED_SESSION_ID,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = viewModel,
onBack = {},
handlerFactory = { handler },
historyLoader = { _, _, _ ->
Result.success(
listOf(
MessageItem(
id = HISTORY_ID,
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive(HISTORY_TEXT),
timestamp = 1.0,
finishReason = "stop",
),
),
)
},
profileIconFlow = { _, _ -> MutableStateFlow(null) },
)
}
}
}
waitUntil { handler.messages.value.singleOrNull()?.content == HISTORY_TEXT }
waitUntil { renderedTextExists(HISTORY_TEXT) }
scenario.moveToState(Lifecycle.State.STARTED)
scenario.moveToState(Lifecycle.State.RESUMED)
waitUntil { renderedTextExists(HISTORY_TEXT) }
assertEquals(0, fixture.rpcCount("prompt.submit"))
}
private fun renderedTextExists(expected: String): Boolean {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.waitForIdleSync()
val root = instrumentation.uiAutomation.rootInActiveWindow ?: return false
return root.containsText(expected)
}
private fun AccessibilityNodeInfo.containsText(expected: String): Boolean {
if (text?.toString() == expected || contentDescription?.toString() == expected) return true
return (0 until childCount).any { index -> getChild(index)?.containsText(expected) == true }
}
private fun waitUntil(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(25)
}
assertTrue("Condition was not satisfied within 5 seconds", condition())
}
private companion object {
const val PROFILE_NAME = "research"
const val STORED_SESSION_ID = "20260829_120000_bot_chat"
const val HISTORY_ID = "persisted-bot-history"
const val HISTORY_TEXT = "Durable Bot Chat history is ready."
}
}
+4
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.hermesandroid.relay.ui.screens.BotChatBindingTestActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
@@ -0,0 +1,6 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
/** Empty debug-only host populated by the Bot Chat lifecycle instrumentation. */
class BotChatBindingTestActivity : ComponentActivity()
@@ -56,11 +56,21 @@ import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.SessionMessageLoadMode
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
import kotlinx.coroutines.flow.Flow
internal typealias BotChatHistoryLoader = suspend (
profileName: String,
sessionId: String,
mode: SessionMessageLoadMode,
) -> Result<List<MessageItem>>
internal typealias BotChatProfileIconFlow = (connectionId: String, profileName: String) -> Flow<String?>
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,14 +84,52 @@ fun BotChatScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val handler = remember(route.key) { ChatHandler() }
BotChatScreen(
route = route,
bot = bot,
sessionId = sessionId,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = chatViewModel,
onBack = onBack,
handlerFactory = ::ChatHandler,
historyLoader = { profileName, storedSessionId, mode ->
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = profileName,
mode = mode,
)
},
profileIconFlow = connectionViewModel::profileIconFlow,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BotChatScreen(
route: BotGatewayRoute,
bot: BotRosterEntry,
sessionId: String,
gatewayClient: GatewayChatClient,
dashboardClient: DashboardApiClient,
chatViewModel: ChatViewModel,
onBack: () -> Unit,
handlerFactory: () -> ChatHandler,
historyLoader: BotChatHistoryLoader,
profileIconFlow: BotChatProfileIconFlow,
) {
val handler = remember(route.key) { handlerFactory() }
val context = LocalContext.current
val messages by chatViewModel.messages.collectAsState()
val isStreaming by chatViewModel.isStreaming.collectAsState()
// This route owns the handler but binds it to the ViewModel only after the
// first composition. Collecting delegated ViewModel getters here can pin
// Compose to their empty pre-bind fallback when history settles before the
// next frame. Observe the route-owned source directly so StateFlow replay
// covers fast history, live streaming, completion, and errors.
val messages by handler.messages.collectAsState()
val isStreaming by handler.isStreaming.collectAsState()
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
val error by chatViewModel.error.collectAsState()
val iconPath by connectionViewModel
.profileIconFlow(route.connectionId, route.profileName)
val error by handler.error.collectAsState()
val iconPath by profileIconFlow(route.connectionId, route.profileName)
.collectAsState(initial = null)
val listState = rememberLazyListState()
var composer by remember(route.key, sessionId) { mutableStateOf("") }
@@ -101,11 +149,7 @@ fun BotChatScreen(
selected?.name == route.profileName
}
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = route.profileName,
mode = mode,
)
historyLoader(route.profileName, storedSessionId, mode)
}
chatViewModel.updateApiClient(null)
chatViewModel.updateGatewayClient(gatewayClient)
@@ -0,0 +1,233 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.viewmodel.ChatViewModel
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "w390dp-h844dp-432dpi")
class BotChatScreenBindingTest {
@get:Rule
val compose = createComposeRule()
private val resources = CopyOnWriteArrayList<ScreenResources>()
@After
fun tearDown() {
resources.forEach(ScreenResources::close)
}
@Test
fun fastHistoryPublishedDuringInitialBindRendersWithoutNavigation() {
val screen = resources("research")
val handler = ChatHandler()
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
screen.content(
handler = handler,
historyLoader = { _, _, _ ->
Result.success(history(screen.sessionId, FAST_HISTORY))
},
)
}
}
compose.mainClock.advanceTimeByFrame()
compose.waitUntil(5_000) { handler.messages.value.singleOrNull()?.content == FAST_HISTORY }
compose.mainClock.autoAdvance = true
compose.waitForIdle()
compose.onNodeWithText(FAST_HISTORY).assertIsDisplayed()
compose.runOnIdle { handler.onTextDelta("live-tail", LIVE_TAIL) }
compose.onNodeWithText(LIVE_TAIL).assertIsDisplayed()
compose.runOnIdle { handler.onStreamError(HANDLER_ERROR) }
compose.onNodeWithText(HANDLER_ERROR).assertIsDisplayed()
}
@Test
fun delayedHistoryAfterCompositionRendersFromTheSameHandler() {
val screen = resources("builder")
val handler = ChatHandler()
val releaseHistory = CompletableDeferred<Unit>()
compose.setContent {
MaterialTheme {
screen.content(
handler = handler,
historyLoader = { _, _, _ ->
releaseHistory.await()
Result.success(history(screen.sessionId, DELAYED_HISTORY))
},
)
}
}
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
compose.onNodeWithText(DELAYED_HISTORY).assertDoesNotExist()
releaseHistory.complete(Unit)
compose.waitUntil(5_000) { handler.messages.value.isNotEmpty() }
compose.onNodeWithText(DELAYED_HISTORY).assertIsDisplayed()
}
@Test
fun replacementHandlerRejectsLateHistoryAndOldHandlerPublications() {
val screen = resources("operator")
val firstHandler = ChatHandler()
val secondHandler = ChatHandler()
val releaseFirstHistory = CompletableDeferred<Unit>()
val target = mutableStateOf(
Target(
revision = 0,
handler = firstHandler,
loader = { _, _, _ ->
releaseFirstHistory.await()
Result.success(history(screen.sessionId, OLD_HISTORY))
},
),
)
compose.setContent {
val current = target.value
key(current.revision) {
MaterialTheme {
screen.content(current.handler, current.loader)
}
}
}
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
compose.runOnIdle {
target.value = Target(
revision = 1,
handler = secondHandler,
loader = { _, _, _ -> Result.success(history(screen.sessionId, NEW_HISTORY)) },
)
}
compose.waitForIdle()
compose.runOnIdle { assertSame(secondHandler, screen.viewModel.boundHandler) }
compose.waitUntil(5_000) { secondHandler.messages.value.singleOrNull()?.content == NEW_HISTORY }
compose.onNodeWithText(NEW_HISTORY).assertIsDisplayed()
releaseFirstHistory.complete(Unit)
compose.waitForIdle()
assertEquals(emptyList<String>(), firstHandler.messages.value.map { it.content })
compose.runOnIdle { firstHandler.onTextDelta("old-tail", OLD_TAIL) }
compose.waitForIdle()
compose.onNodeWithText(OLD_HISTORY).assertDoesNotExist()
compose.onNodeWithText(OLD_TAIL).assertDoesNotExist()
assertEquals(listOf(OLD_TAIL), firstHandler.messages.value.map { it.content })
assertEquals(listOf(NEW_HISTORY), secondHandler.messages.value.map { it.content })
}
private fun resources(profileName: String): ScreenResources = ScreenResources(profileName).also {
resources += it
}
private fun history(sessionId: String, text: String) = listOf(
MessageItem(
id = "history-$sessionId",
sessionId = sessionId,
role = "assistant",
content = JsonPrimitive(text),
timestamp = 1.0,
finishReason = "stop",
),
)
private inner class ScreenResources(profileName: String) {
val route = BotGatewayRoute(
key = BotGatewayRouteKey("gateway-$profileName", profileName),
connectionLabel = "Fixture gateway",
)
val bot = BotRosterEntry(
profile = Profile(
name = profileName,
model = "fixture-model",
description = "Fixture profile",
),
displayName = profileName.replaceFirstChar(Char::uppercase),
route = route,
)
val sessionId = "fixture-$profileName-session"
val viewModel = ChatViewModel()
private val gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dashboardClient = DashboardApiClient("http://127.0.0.1:1")
private val gatewayClient = GatewayChatClient(
initialDashboardClient = dashboardClient,
fixedSessionProfile = profileName,
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
@androidx.compose.runtime.Composable
fun content(handler: ChatHandler, historyLoader: BotChatHistoryLoader) {
BotChatScreen(
route = route,
bot = bot,
sessionId = sessionId,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = viewModel,
onBack = {},
handlerFactory = { handler },
historyLoader = historyLoader,
profileIconFlow = { _, _ -> MutableStateFlow(null) },
)
}
fun close() {
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
dashboardClient.shutdown()
}
}
private data class Target(
val revision: Int,
val handler: ChatHandler,
val loader: BotChatHistoryLoader,
)
private companion object {
const val FAST_HISTORY = "History loaded before the next frame."
const val DELAYED_HISTORY = "History loaded after composition."
const val LIVE_TAIL = "Live tail from the owned handler."
const val HANDLER_ERROR = "Owned handler error"
const val OLD_HISTORY = "Late history from the old route."
const val OLD_TAIL = "Old handler live tail."
const val NEW_HISTORY = "History from the replacement route."
}
}
+1
View File
@@ -66,6 +66,7 @@ the upstream contract identifiers it depends on.
| Scenario | Contract exercised |
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
| `queued_follow_up` | Two explicitly owned turns and ordered queue drainage |
+4 -4
View File
@@ -4,7 +4,7 @@ appVersionCode = "52"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
navigation-compose = "2.9.8"
navigation-compose = "2.10.0"
okhttp = "5.5.0"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
@@ -21,9 +21,9 @@ core-ktx = "1.19.0"
exifinterface = "1.4.2"
datastore = "1.2.1"
splashscreen = "1.2.0"
markdown-renderer = "0.44.0"
coil = "3.5.0"
haze = "1.7.2"
markdown-renderer = "0.45.0"
coil = "3.6.0"
haze = "1.7.3"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
+4
View File
@@ -88,6 +88,10 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
and both issue #365 terminal-gap forms:
- `initial_history_bind`: a durable, profile-scoped transcript exists before
the client resumes, so rendered clients can exercise first-composition
binding without relying on a new turn to trigger recomposition.
- `subagent_child_preview`: interleaved concurrent child lifecycle events carry
stable child/session identity, thinking/progress/tool previews, and distinct
completed/interrupted terminal states. Its upstream requirement also proves
@@ -77,6 +77,29 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
await rejected.release()
await ws.close()
async def test_initial_history_is_available_before_session_resume(self) -> None:
fixture, base_url = await self.start("initial_history_bind")
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "research", "limit": 500, "offset": 0, "order": "asc"},
) as response:
history = await response.json()
self.assertEqual(
["Open the durable Bot Chat.", "Durable Bot Chat history is ready."],
[row["content"] for row in history["messages"]],
)
ws, _ = await self.connect(base_url)
await self.rpc(
ws,
1,
"session.resume",
{"session_id": fixture.scenario.stored_session_id, "profile": "research"},
)
resumed = (await ws.receive_json())["result"]
self.assertEqual(fixture.scenario.live_session_id, resumed["session_id"])
self.assertEqual(fixture.scenario.stored_session_id, resumed["stored_session_id"])
async def test_ordinary_turn_persists_authoritative_history(self) -> None:
fixture, base_url = await self.start("ordinary_turn")
ws, _ = await self.connect(base_url)
@@ -307,6 +330,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_profile_scope",
"active_status_unsupported",
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
"rapid_tools_interims",
"subagent_child_preview",
@@ -334,7 +358,11 @@ class ScenarioTestCase(unittest.TestCase):
from vanilla_gateway.scenario import Scenario
Scenario.from_dict(scenario)
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
def test_contract_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
("gateway.session_resume_durable",),
load_scenario("initial_history_bind").contract_requirements,
)
self.assertEqual(
(
"gateway.message_complete",
@@ -0,0 +1,14 @@
{
"name": "initial_history_bind",
"live_session_id": "fixture-live-history",
"stored_session_id": "20260829_120000_bot_chat",
"profile": "research",
"contract_requirements": [
"gateway.session_resume_durable"
],
"initial_history": [
{"id": 1, "role": "user", "content": "Open the durable Bot Chat.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Durable Bot Chat history is ready.", "timestamp": 2.0, "finish_reason": "stop"}
],
"turns": []
}
+1 -1
View File
@@ -17,7 +17,7 @@ plugins {
kotlin("jvm")
// Compose compiler — version inherited from the root plugins {} block.
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose") version "1.11.1"
id("org.jetbrains.compose") version "1.12.0"
}
kotlin {