Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cd13e5bc | ||
|
|
ad4175bb6d | ||
|
|
6b7cb706e0 | ||
|
|
6031427ffb | ||
|
|
53dd21400a | ||
|
|
802a0b0844 | ||
|
|
7658329ca7 | ||
|
|
1622db0b23 | ||
|
|
bca3cd0e48 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
-45
@@ -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 {
|
||||
|
||||
+160
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user