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
Bailey Dixon bb2f1e6c0c fix(plugin): harden Git workspace path containment 2026-08-30 23:28:51 -04:00
Bailey Dixon ff23d54332 Merge pull request #491 from Codename-11/fix/release-desktop-ui-assets
chore(website): refresh desktop UI source fingerprint
2026-08-30 22:28:31 -04:00
Bailey Dixon a4a0575688 chore(website): refresh desktop UI source fingerprint 2026-08-30 22:26:55 -04:00
Bailey Dixon 0509ac8377 Merge pull request #489 from Codename-11/release/android-plugin-cli-2026-08-30
release: prepare Android 1.14.0, Plugin 1.11.0, and CLI+UI beta.6
2026-08-30 22:01:55 -04:00
18 changed files with 1037 additions and 96 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 |
+1 -1
View File
@@ -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 -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"
+181 -31
View File
@@ -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,
+60
View File
@@ -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")
+278
View File
@@ -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")
+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 {