Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cd13e5bc | ||
|
|
ad4175bb6d | ||
|
|
6b7cb706e0 | ||
|
|
6031427ffb | ||
|
|
53dd21400a | ||
|
|
802a0b0844 | ||
|
|
7658329ca7 | ||
|
|
1622db0b23 | ||
|
|
bca3cd0e48 | ||
|
|
bb2f1e6c0c | ||
|
|
ff23d54332 | ||
|
|
a4a0575688 | ||
|
|
0509ac8377 |
@@ -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 |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"sourceFingerprint": {
|
||||
"algorithm": "sha256",
|
||||
"normalization": "text-lf-v1",
|
||||
"digest": "8cf00c04da9e80621b439563145be49ede9539ac95ab903b1903763004e370bc",
|
||||
"digest": "38253cb9fb1124a629625ea0bc5be09a4f61af91e3fca0103cbb12a5279aae54",
|
||||
"files": [
|
||||
"desktop/tray/ui/App.tsx",
|
||||
"desktop/tray/ui/main.tsx",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+181
-31
@@ -20,9 +20,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from threading import Lock, RLock
|
||||
from typing import Any
|
||||
|
||||
from .config import raw_config_value
|
||||
@@ -48,6 +51,12 @@ _SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
|
||||
_ERROR_USERINFO_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^\s/@]+)@")
|
||||
_ERROR_SSH_USERINFO_RE = re.compile(r"(?<![\w@])([^\s/@:]+)@([^\s:]+):")
|
||||
|
||||
# FastAPI executes synchronous routes concurrently. These locks serialize Git
|
||||
# operations per repository without creating worker threads, so one API call
|
||||
# cannot replace a validated working-tree path underneath another.
|
||||
_REPO_LOCKS_GUARD = Lock()
|
||||
_REPO_LOCKS: dict[str, RLock] = {}
|
||||
|
||||
|
||||
class GitStateError(ValueError):
|
||||
"""A caller supplied an invalid repo id, path, or diff kind."""
|
||||
@@ -91,13 +100,18 @@ def _run_git_bounded(
|
||||
args: list[str],
|
||||
*,
|
||||
mutation: bool,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run Git without materializing unbounded stdout or stderr in memory."""
|
||||
error_type = GitError if mutation else GitStateError
|
||||
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
||||
try:
|
||||
command = ["git", "-C", str(repo)]
|
||||
if literal_pathspecs:
|
||||
command.append("--literal-pathspecs")
|
||||
command.extend(args)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
command,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
@@ -139,9 +153,14 @@ def _safe_git_error(text: str) -> str:
|
||||
return scrubbed[:MAX_GIT_ERROR_BYTES].strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
def _git(repo: Path, *args: str, literal_pathspecs: bool = False) -> str:
|
||||
"""Run ``git -C <repo> <args>`` and return bounded stdout."""
|
||||
returncode, stdout, stderr = _run_git_bounded(repo, list(args), mutation=False)
|
||||
returncode, stdout, stderr = _run_git_bounded(
|
||||
repo,
|
||||
list(args),
|
||||
mutation=False,
|
||||
literal_pathspecs=literal_pathspecs,
|
||||
)
|
||||
if returncode != 0:
|
||||
raise GitStateError(
|
||||
f"git {args[0] if args else 'command'} failed for {repo.name}: "
|
||||
@@ -157,13 +176,79 @@ def _is_git_repo(path: Path) -> bool:
|
||||
|
||||
def _is_link_or_junction(path: Path) -> bool:
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
return path.is_symlink() or bool(is_junction and is_junction())
|
||||
if path.is_symlink() or bool(is_junction and is_junction()):
|
||||
return True
|
||||
if os.name != "nt":
|
||||
return False
|
||||
try:
|
||||
attributes = os.lstat(path).st_file_attributes
|
||||
except (AttributeError, FileNotFoundError, OSError):
|
||||
return False
|
||||
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
return bool(attributes & reparse_point)
|
||||
|
||||
|
||||
def _is_within(root: Path, candidate: Path) -> bool:
|
||||
return candidate == root or root in candidate.parents
|
||||
|
||||
|
||||
def _canonical_repo_root(repo: Path) -> Path:
|
||||
"""Return an unlinked absolute repository root or fail closed."""
|
||||
lexical = Path(os.path.abspath(repo))
|
||||
try:
|
||||
if _is_link_or_junction(lexical):
|
||||
raise GitStateError("repository root changed during operation")
|
||||
canonical = lexical.resolve(strict=True)
|
||||
except GitStateError:
|
||||
raise
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise GitStateError("repository root changed during operation") from exc
|
||||
if os.path.normcase(str(canonical)) != os.path.normcase(str(lexical)):
|
||||
raise GitStateError("repository root changed during operation")
|
||||
return canonical
|
||||
|
||||
|
||||
def _serialized_repo_operation(function: Any) -> Any:
|
||||
"""Serialize a Git operation against other operations on the same repo."""
|
||||
|
||||
@wraps(function)
|
||||
def wrapped(repo: Path, *args: Any, **kwargs: Any) -> Any:
|
||||
key = os.path.normcase(os.path.abspath(repo))
|
||||
with _REPO_LOCKS_GUARD:
|
||||
lock = _REPO_LOCKS.setdefault(key, RLock())
|
||||
with lock:
|
||||
return function(repo, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _resolve_repo_disk_path(
|
||||
repo: Path,
|
||||
path: str,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> Path:
|
||||
"""Resolve a validated relative path and prove canonical repo containment.
|
||||
|
||||
``Path.resolve`` follows symlinks and junctions before ``commonpath``
|
||||
compares the canonical filesystem paths. Different-drive paths fail
|
||||
closed on Windows. Callers that open the result must revalidate the opened
|
||||
handle before using it as defense in depth against an unexpected path
|
||||
replacement. Concurrent external same-user filesystem mutation is outside
|
||||
the plugin trust boundary.
|
||||
"""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
root = _canonical_repo_root(repo)
|
||||
candidate = (root / safe_path).resolve(strict=strict)
|
||||
try:
|
||||
common = Path(os.path.commonpath((str(root), str(candidate))))
|
||||
except ValueError as exc:
|
||||
raise GitStateError(f"path escapes repository: {safe_path}") from exc
|
||||
if os.path.normcase(str(common)) != os.path.normcase(str(root)):
|
||||
raise GitStateError(f"path escapes repository: {safe_path}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _has_link_component(base: Path, path: Path) -> bool:
|
||||
current = base
|
||||
try:
|
||||
@@ -177,6 +262,19 @@ def _has_link_component(base: Path, path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_untracked_delete_path(repo: Path, path: str) -> str:
|
||||
"""Validate one exact untracked file before delegating deletion to Git."""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
root = _canonical_repo_root(repo)
|
||||
lexical = root / safe_path
|
||||
if _has_link_component(root, lexical):
|
||||
raise GitStateError(f"path contains a link or junction: {safe_path}")
|
||||
candidate = _resolve_repo_disk_path(repo, safe_path, strict=False)
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
raise GitStateError(f"path is not a file: {safe_path}")
|
||||
return safe_path
|
||||
|
||||
|
||||
def repo_id(repo: Path, base: Path | None = None) -> str:
|
||||
"""Stable collision-free id relative to the configured canonical base."""
|
||||
if base is None:
|
||||
@@ -433,19 +531,27 @@ def repo_diff(repo: Path, path: str, kind: str) -> dict[str, Any]:
|
||||
args = ["diff", "--no-color", "--"]
|
||||
if kind == "staged":
|
||||
args = ["diff", "--cached", "--no-color", "--"]
|
||||
output = _git(repo, *args, safe_path)
|
||||
output = _git(repo, *args, safe_path, literal_pathspecs=True)
|
||||
truncated = len(output) > MAX_DIFF_BYTES
|
||||
if truncated:
|
||||
output = output[:MAX_DIFF_BYTES]
|
||||
return {"path": safe_path, "kind": kind, "diff": output, "truncated": truncated}
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def read_file(repo: Path, path: str) -> dict[str, Any]:
|
||||
"""Read a tracked file's working-tree content. Untracked/binary/missing → error."""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
# Confirm the file is tracked before reading.
|
||||
try:
|
||||
_git(repo, "ls-files", "--error-unmatch", "--", safe_path)
|
||||
_git(
|
||||
repo,
|
||||
"ls-files",
|
||||
"--error-unmatch",
|
||||
"--",
|
||||
safe_path,
|
||||
literal_pathspecs=True,
|
||||
)
|
||||
except GitStateError as exc:
|
||||
raise GitStateError(f"file is not tracked: {safe_path}") from exc
|
||||
|
||||
@@ -453,12 +559,16 @@ def read_file(repo: Path, path: str) -> dict[str, Any]:
|
||||
# modified-but-uncommitted file returns what is on disk. Read bytes first:
|
||||
# binary content dies on the NUL check (before any decode), and non-UTF-8
|
||||
# text raises a clear GitStateError instead of an unhandled 500.
|
||||
root = repo.resolve()
|
||||
try:
|
||||
disk_path = (repo / safe_path).resolve(strict=True)
|
||||
if not _is_within(root, disk_path):
|
||||
raise GitStateError(f"path escapes repository: {safe_path}")
|
||||
disk_path = _resolve_repo_disk_path(repo, safe_path, strict=True)
|
||||
with disk_path.open("rb") as handle:
|
||||
# Re-resolve after opening, then prove the open handle still names
|
||||
# that in-repo file. This fails closed if a parent, junction, repo
|
||||
# root, or leaf is replaced between validation and open; reads use
|
||||
# the already-verified handle after this point.
|
||||
revalidated = _resolve_repo_disk_path(repo, safe_path, strict=True)
|
||||
if not os.path.samestat(os.fstat(handle.fileno()), revalidated.stat()):
|
||||
raise GitStateError(f"path changed during read: {safe_path}")
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise GitStateError(f"could not read file: {safe_path}") from exc
|
||||
@@ -573,12 +683,22 @@ def _is_git_repo(path: Path) -> bool:
|
||||
return (path / ".git").exists()
|
||||
|
||||
|
||||
def _run_mutation(repo: Path, args: list[str]) -> str:
|
||||
def _run_mutation(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> str:
|
||||
"""Run a mutating ``git -C <repo> <args>``; raise a classified GitError.
|
||||
|
||||
Arg lists only (never shell interpolation); bounded by a timeout.
|
||||
"""
|
||||
returncode, stdout, stderr_output = _run_git_bounded(repo, args, mutation=True)
|
||||
returncode, stdout, stderr_output = _run_git_bounded(
|
||||
repo,
|
||||
args,
|
||||
mutation=True,
|
||||
literal_pathspecs=literal_pathspecs,
|
||||
)
|
||||
if returncode != 0:
|
||||
stderr = _safe_git_error(stderr_output or stdout)
|
||||
raise GitError(
|
||||
@@ -588,11 +708,33 @@ def _run_mutation(repo: Path, args: list[str]) -> str:
|
||||
return stdout
|
||||
|
||||
|
||||
def _mutate(repo: Path, args: list[str]) -> str:
|
||||
def _mutate(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> str:
|
||||
"""Validate ``repo`` is a real git work tree, then run the mutation."""
|
||||
if not _is_git_repo(repo):
|
||||
raise GitError(f"not a git repository: {repo.name}", code="non-repo")
|
||||
return _run_mutation(repo, args)
|
||||
return _run_mutation(repo, args, literal_pathspecs=literal_pathspecs)
|
||||
|
||||
|
||||
def _is_tracked_path(repo: Path, path: str) -> bool:
|
||||
"""Classify one literal path without treating Git failures as untracked."""
|
||||
returncode, stdout, stderr = _run_git_bounded(
|
||||
repo,
|
||||
["ls-files", "--error-unmatch", "--", path],
|
||||
mutation=False,
|
||||
literal_pathspecs=True,
|
||||
)
|
||||
if returncode == 0:
|
||||
return True
|
||||
if returncode == 1:
|
||||
return False
|
||||
raise GitStateError(
|
||||
f"git ls-files failed for {repo.name}: {_safe_git_error(stderr or stdout)}"
|
||||
)
|
||||
|
||||
|
||||
def _is_dirty(repo: Path) -> bool:
|
||||
@@ -712,20 +854,23 @@ def _fresh_mutation_result(
|
||||
return result
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def stage(repo: Path, paths: list[str]) -> dict[str, Any]:
|
||||
"""Stage ``paths`` (repo-relative) and return fresh status."""
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["add", "--"] + safe)
|
||||
_mutate(repo, ["add", "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def unstage(repo: Path, paths: list[str]) -> dict[str, Any]:
|
||||
"""Unstage ``paths`` and return fresh status."""
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["restore", "--staged", "--"] + safe)
|
||||
_mutate(repo, ["restore", "--staged", "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def discard(
|
||||
repo: Path,
|
||||
paths: list[str],
|
||||
@@ -740,26 +885,25 @@ def discard(
|
||||
_require_confirmation(confirmation, CONFIRM_DISCARD)
|
||||
safe = _validate_paths(paths)
|
||||
tracked: list[str] = []
|
||||
untracked: list[str] = []
|
||||
for path in safe:
|
||||
try:
|
||||
_git(repo, "ls-files", "--error-unmatch", "--", path)
|
||||
if _is_tracked_path(repo, path):
|
||||
tracked.append(path)
|
||||
except GitStateError:
|
||||
# Untracked path — only touched when delete_untracked is set.
|
||||
if not delete_untracked:
|
||||
continue
|
||||
root = repo.resolve()
|
||||
candidate = (repo / path).resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
raise GitStateError(f"path escapes repository: {path}")
|
||||
if candidate.is_file():
|
||||
candidate.unlink()
|
||||
elif delete_untracked:
|
||||
untracked.append(path)
|
||||
# Reject links and directories before asking Git to remove only the exact
|
||||
# literal file names. Concurrent same-user filesystem mutation is outside
|
||||
# the plugin trust boundary; stationary redirections fail closed here.
|
||||
deletable = [_validate_untracked_delete_path(repo, path) for path in untracked]
|
||||
if deletable:
|
||||
_mutate(repo, ["clean", "-f", "--"] + deletable, literal_pathspecs=True)
|
||||
# Revert tracked modifications for the given paths.
|
||||
if tracked:
|
||||
_mutate(repo, ["checkout", "--"] + tracked)
|
||||
_mutate(repo, ["checkout", "--"] + tracked, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def commit(repo: Path, message: str) -> dict[str, Any]:
|
||||
"""Create a commit from the staged index. Empty message is rejected."""
|
||||
message = _validate_commit_message(message)
|
||||
@@ -767,14 +911,16 @@ def commit(repo: Path, message: str) -> dict[str, Any]:
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""Commit only the given ``paths`` (staged + modified) under ``message``."""
|
||||
message = _validate_commit_message(message)
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["commit", "-m", message, "--"] + safe)
|
||||
_mutate(repo, ["commit", "-m", message, "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
|
||||
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
|
||||
remote = _validate_remote(repo, remote)
|
||||
@@ -782,6 +928,7 @@ def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]:
|
||||
"""Pull from ``remote``/``branch`` (defaults: origin + current branch).
|
||||
|
||||
@@ -805,6 +952,7 @@ def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def push(
|
||||
repo: Path,
|
||||
remote: str = "origin",
|
||||
@@ -826,6 +974,7 @@ def push(
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def checkout(
|
||||
repo: Path,
|
||||
ref: str,
|
||||
@@ -857,7 +1006,7 @@ def _staged_diff(repo: Path, paths: list[str] | None) -> tuple[str, bool]:
|
||||
if paths:
|
||||
args.append("--")
|
||||
args.extend(paths)
|
||||
output = _git(repo, *args)
|
||||
output = _git(repo, *args, literal_pathspecs=bool(paths))
|
||||
truncated = len(output) > MAX_DIFF_BYTES
|
||||
if truncated:
|
||||
output = output[:MAX_DIFF_BYTES]
|
||||
@@ -945,6 +1094,7 @@ async def commit_message_selected(
|
||||
return await _generate_message(diff)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def stash_checkout(
|
||||
repo: Path,
|
||||
ref: str,
|
||||
|
||||
@@ -313,6 +313,15 @@ class GitStateFileTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "untracked.txt")
|
||||
|
||||
def test_read_tracking_check_treats_pathspec_magic_as_literal(self) -> None:
|
||||
(self.repo / "name1.txt").write_text("tracked", encoding="utf-8")
|
||||
_git(self.repo, "add", "name1.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked pathspec sibling")
|
||||
(self.repo / "name[1].txt").write_text("untracked", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "file is not tracked"):
|
||||
git_state.read_file(self.repo, "name[1].txt")
|
||||
|
||||
def test_read_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "nope.txt")
|
||||
@@ -358,6 +367,57 @@ class GitStateFileTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, tracked_path)
|
||||
|
||||
def test_read_rejects_parent_swapped_after_validation(self) -> None:
|
||||
nested = self.repo / "nested"
|
||||
nested.mkdir()
|
||||
tracked = nested / "tracked.txt"
|
||||
tracked.write_text("safe", encoding="utf-8")
|
||||
_git(self.repo, "add", "nested/tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add nested file")
|
||||
|
||||
parked = self.repo / "nested-original"
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "tracked.txt").write_text("secret", encoding="utf-8")
|
||||
original_open = Path.open
|
||||
swapped = False
|
||||
|
||||
def swap_before_open(path: Path, *args: object, **kwargs: object):
|
||||
nonlocal swapped
|
||||
if not swapped and path == tracked:
|
||||
swapped = True
|
||||
nested.rename(parked)
|
||||
_link_directory(nested, outside)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "open", new=swap_before_open):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, "nested/tracked.txt")
|
||||
|
||||
self.assertTrue((parked / "tracked.txt").exists())
|
||||
self.assertEqual("secret", (outside / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_read_rejects_repo_root_swapped_after_validation(self) -> None:
|
||||
parked = self.base / "file-repo-original"
|
||||
outside = _init_repo(self.base, "outside-repo")
|
||||
(outside / "README.md").write_text("# Secret\n", encoding="utf-8")
|
||||
original_open = Path.open
|
||||
swapped = False
|
||||
|
||||
def swap_before_open(path: Path, *args: object, **kwargs: object):
|
||||
nonlocal swapped
|
||||
if not swapped and path == self.repo / "README.md":
|
||||
swapped = True
|
||||
self.repo.rename(parked)
|
||||
_link_directory(self.repo, outside)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "open", new=swap_before_open):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
|
||||
git_state.read_file(self.repo, "README.md")
|
||||
|
||||
self.assertEqual("# Secret\n", (outside / "README.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_read_tracked_file_is_bounded_during_read(self) -> None:
|
||||
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
|
||||
_git(self.repo, "add", "large.txt")
|
||||
|
||||
@@ -12,6 +12,8 @@ import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
@@ -50,6 +52,15 @@ def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
return remote
|
||||
|
||||
|
||||
def _link_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
|
||||
|
||||
|
||||
class _MutationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
@@ -99,6 +110,46 @@ class StageUnstageTests(_MutationBase):
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(self.repo, ["../outside"])
|
||||
|
||||
def test_stage_treats_wildcard_as_literal_path(self) -> None:
|
||||
(self.repo / "first.txt").write_text("first", encoding="utf-8")
|
||||
(self.repo / "second.txt").write_text("second", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.stage(self.repo, ["*.txt"])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual([], status["staged"])
|
||||
self.assertEqual(
|
||||
{"first.txt", "second.txt"},
|
||||
{entry["path"] for entry in status["untracked"]},
|
||||
)
|
||||
|
||||
def test_stage_treats_pathspec_magic_as_literal_path(self) -> None:
|
||||
literal = "name[1].txt"
|
||||
expanded = "name1.txt"
|
||||
(self.repo / literal).write_text("literal", encoding="utf-8")
|
||||
(self.repo / expanded).write_text("expanded", encoding="utf-8")
|
||||
|
||||
git_state.stage(self.repo, [literal])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual({literal}, {entry["path"] for entry in status["staged"]})
|
||||
self.assertEqual({expanded}, {entry["path"] for entry in status["untracked"]})
|
||||
|
||||
def test_unstage_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.unstage(self.repo, ["*.txt"])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(
|
||||
{"first.txt", "second.txt"},
|
||||
{entry["path"] for entry in status["staged"]},
|
||||
)
|
||||
|
||||
|
||||
class CommitTests(_MutationBase):
|
||||
def test_commit_creates_a_real_commit(self) -> None:
|
||||
@@ -147,6 +198,17 @@ class CommitTests(_MutationBase):
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "", ["a.txt"])
|
||||
|
||||
def test_commit_selected_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "must stay scoped", ["*.txt"])
|
||||
|
||||
self.assertNotIn("first.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
self.assertNotIn("second.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
|
||||
|
||||
class DiscardConfirmationTests(_MutationBase):
|
||||
def test_discard_requires_confirmation_string(self) -> None:
|
||||
@@ -179,6 +241,222 @@ class DiscardConfirmationTests(_MutationBase):
|
||||
)
|
||||
self.assertFalse((self.repo / "untracked.txt").exists())
|
||||
|
||||
def test_discard_delete_untracked_rejects_link_outside_repo(self) -> None:
|
||||
outside = self.base / "outside.txt"
|
||||
outside.write_text("keep", encoding="utf-8")
|
||||
link = self.repo / "untracked-link.txt"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink creation unavailable: {exc}")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["untracked-link.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside.read_text(encoding="utf-8"))
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows junction fallback")
|
||||
def test_discard_rejects_junction_on_python_311_fallback(self) -> None:
|
||||
target = self.repo / "target"
|
||||
target.mkdir()
|
||||
(target / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
junction = self.repo / "junction"
|
||||
_run(["cmd", "/c", "mklink", "/J", str(junction), str(target)], self.repo)
|
||||
|
||||
with patch.object(Path, "is_junction", return_value=False, create=True):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["junction/keep.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", (target / "keep.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_delete_untracked_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["*.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertTrue((self.repo / "first.txt").exists())
|
||||
self.assertTrue((self.repo / "second.txt").exists())
|
||||
|
||||
def test_discard_does_not_delete_when_tracking_check_fails(self) -> None:
|
||||
target = self.repo / "keep.txt"
|
||||
target.write_text("keep", encoding="utf-8")
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_run_git_bounded",
|
||||
return_value=(128, "", "fatal: repository unavailable"),
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "ls-files failed"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["keep.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", target.read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_delete_untracked_does_not_follow_swapped_parent(self) -> None:
|
||||
nested = self.repo / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "delete.txt").write_text("repo", encoding="utf-8")
|
||||
parked = self.repo / "nested-original"
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "delete.txt"
|
||||
outside_file.write_text("keep", encoding="utf-8")
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def swap_before_delete(repo: Path, path: str) -> str:
|
||||
if nested.exists() and not nested.is_symlink():
|
||||
nested.rename(parked)
|
||||
_link_directory(nested, outside)
|
||||
return original_validate(repo, path)
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=swap_before_delete,
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["nested/delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
|
||||
self.assertTrue((parked / "delete.txt").exists())
|
||||
|
||||
def test_discard_delete_untracked_rejects_swapped_repo_root(self) -> None:
|
||||
(self.repo / "delete.txt").write_text("repo", encoding="utf-8")
|
||||
parked = self.base / "write-repo-original"
|
||||
outside = self.base / "outside-repo"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "delete.txt"
|
||||
outside_file.write_text("keep", encoding="utf-8")
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def swap_before_delete(repo: Path, path: str) -> str:
|
||||
self.repo.rename(parked)
|
||||
_link_directory(self.repo, outside)
|
||||
return original_validate(repo, path)
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=swap_before_delete,
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
|
||||
self.assertTrue((parked / "delete.txt").exists())
|
||||
|
||||
def test_discard_tracked_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked files")
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text("v2", encoding="utf-8")
|
||||
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["*.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
|
||||
self.assertEqual("v2", (self.repo / "first.txt").read_text(encoding="utf-8"))
|
||||
self.assertEqual("v2", (self.repo / "second.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_serializes_against_checkout_on_same_repo(self) -> None:
|
||||
target = self.repo / "delete.txt"
|
||||
target.write_text("delete", encoding="utf-8")
|
||||
_git(self.repo, "branch", "other")
|
||||
validation_reached = Event()
|
||||
allow_discard = Event()
|
||||
checkout_started = Event()
|
||||
checkout_completed = Event()
|
||||
errors: list[BaseException] = []
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def blocked_validate(repo: Path, path: str) -> str:
|
||||
result = original_validate(repo, path)
|
||||
validation_reached.set()
|
||||
if not allow_discard.wait(5):
|
||||
raise AssertionError("test timed out waiting to continue discard")
|
||||
return result
|
||||
|
||||
def run_discard() -> None:
|
||||
try:
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
except BaseException as exc: # pragma: no cover - reported below
|
||||
errors.append(exc)
|
||||
|
||||
def run_checkout() -> None:
|
||||
checkout_started.set()
|
||||
try:
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"other",
|
||||
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
)
|
||||
except BaseException as exc: # pragma: no cover - reported below
|
||||
errors.append(exc)
|
||||
finally:
|
||||
checkout_completed.set()
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=blocked_validate,
|
||||
):
|
||||
discard_thread = Thread(target=run_discard)
|
||||
checkout_thread = Thread(target=run_checkout)
|
||||
discard_thread.start()
|
||||
self.assertTrue(validation_reached.wait(5))
|
||||
checkout_thread.start()
|
||||
self.assertTrue(checkout_started.wait(5))
|
||||
self.assertFalse(checkout_completed.wait(0.2))
|
||||
allow_discard.set()
|
||||
discard_thread.join(5)
|
||||
checkout_thread.join(5)
|
||||
|
||||
self.assertFalse(discard_thread.is_alive())
|
||||
self.assertFalse(checkout_thread.is_alive())
|
||||
self.assertEqual([], errors)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual("other", _git(self.repo, "branch", "--show-current"))
|
||||
|
||||
def test_discard_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
|
||||
@@ -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