Compare commits

..
Author SHA1 Message Date
Bailey Dixon 2ec7b7aac9 release: Android 1.14.0 and Plugin 1.11.0 (#490) 2026-08-30 23:50:41 -04:00
46 changed files with 350 additions and 1445 deletions
+4 -4
View File
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -101,7 +101,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -151,7 +151,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -215,7 +215,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
fi
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
@@ -176,7 +176,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- name: Set up JDK 17
uses: actions/setup-java@v6
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
-8
View File
@@ -6,14 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Changed
- **Android What's New now provides a readable, complete release record.** One overall title and summary lead into selected highlights, every remaining user-visible addition, improvement, and fix, and relevant compatibility boundaries. Toast counts and previews are derived from that same inventory, so View all no longer promises details the expanded dialog and history cannot show.
### 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
-7
View File
@@ -61,13 +61,6 @@ configuration between invocations and do not add `--no-daemon` to normal dev
commands; a different heap or Java home starts a separate daemon and discards
the warm-process benefit.
On Windows, all repository dev helpers serialize Android build and device work
through one machine-wide lane shared by every Hermes-Relay worktree. Use
`scripts/android-lane.ps1` for ad hoc Gradle, connected-test, and APK-install
commands, and keep Android Studio idle while another owner holds the lane. See
[Windows Android build lane](docs/android-build-lane.md) for the coordinator
gate, status command, and exceptional recovery modes.
Use the narrowest command that proves the change:
1. `scripts/dev.bat compile` for a Kotlin compile check.
-22
View File
@@ -1,27 +1,5 @@
# Hermes-Relay — Dev Log
## 2026-08-31 — Complete, readable Android release notes
Android release metadata now keeps one overall title and summary plus a complete
typed inventory of user-visible additions, improvements, and fixes. Stable
change ids prevent duplicate records, selected highlights lead the expanded
view, and compatibility boundaries remain visible without turning the compact
notice into technical release documentation. Toast counts and previews are
derived from the same non-highlighted changes the dialog and history render, so
View all reaches every counted item. Older bundled changelog entries retain
their existing rendering path.
The Android 1.14.0 record was migrated to the complete schema and reconciled
against its released Android changelog: four highlights, two additional
improvements, ten fixes, and three compatibility notes. The dialog, full
history, large-text toast, seven Android resource catalogs, legacy text
fallback, Play note, listing copy, release-prep instructions, and validation
tests were updated together.
Structured release-note tests, focused Kotlin tests, Android locale and
collection-API gates, rendered 360×640 dark-theme screenshots including 135%
text, sideload APK assembly, Android lint, and diff checks passed.
## 2026-08-27 — Fixed issue ownership and bounded PR intake
The automated issue first-response lane now assigns only `Codename-11` when the
+27 -30
View File
@@ -531,14 +531,13 @@ the new app version and a higher `appVersionCode`.
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/changelog.json` — curated source for the in-app
**What's New** dialog and Android release history. Prepend one schema-3 entry
with a single descriptive release `title`, a plain-language `summary`, and a
complete `changes` inventory. Every user-visible change has a stable `id`, a
`kind` (`added`, `improved`, or `fixed`), a short title, a useful explanation,
and an optional `highlight: true`; select 1–4 highlights. Add `compatibility`
bullets only when users need an availability, migration, flavor, or Plugin
boundary, plus Android-only `playNotes`. The app derives toast counts and
previews from the same inventory and renders every change exactly once.
**What's New** dialog and Android release history. Prepend the newest entry
with one explicit `highlight` (`title`, plain-language `summary`, and 1–3
user-benefit bullets), up to two quieter `improvements`, Android-only
`playNotes`, a `toastDigest` with counts and 0–2 short previews for noteworthy
items beyond the hero, and the existing technical `sections` used by older clients.
Do not derive the highlight mechanically from `CHANGELOG.md`; choosing the
release's main reason to care is an editorial release-prep decision.
- `app/src/main/assets/whats_new.txt` — legacy in-app fallback generated from
the newest structured entry. Do not edit it independently.
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
@@ -552,8 +551,8 @@ the new app version and a higher `appVersionCode`.
block and the Gradle Play Publisher note are generated from `playNotes`.
After editing the newest structured entry, run
`python scripts/check-android-release-notes.py --write`, then run it again
without `--write` to validate complete unique change records, 1–4 highlights,
the current Android version, GitHub-release/changelog headings, derived files, and Play's
without `--write` to validate the 1–3 / 0–2 editorial limits, current Android
version, GitHub-release/changelog headings, derived files, and Play's
**500-character** limit. Frame Play copy around the release's themes, not a
feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
@@ -571,30 +570,28 @@ authoring contract; do not maintain a separate prompt file.
boundary that users must understand. Do not generate from commit titles or
a mixed-surface changelog block alone.
2. Before editing release files, show a temporary coverage ledger in the task
output. Map every selected Android source change to one stable change id and
one kind (`added`, `improved`, or `fixed`), and mark whether it is a
highlight. The ledger is review evidence, not a committed public artifact;
no selected user-visible change may disappear silently or be counted twice.
3. Write one release title that describes the release as a whole. Do not let a
narrow feature name, internal project label, or poetic codename replace the
title users see in the toast and history. Follow it with a one- or two-sentence
summary that gives the release's overall outcome without becoming a feature dump.
4. Select 1–4 highlights from the complete change inventory. A highlight is a
strong reason to care, not a second copy of the change: the app presents it
once in the highlight section and derives the remaining counts and previews
from non-highlighted changes.
5. Include every meaningful user-visible addition, improvement, and fix in
`changes`, using plain titles and enough explanation for someone to recognize
the affected behavior. Internal refactors, tests, CI mechanics, branch work,
and debugging history stay in `RELEASE_NOTES.md`, `CHANGELOG.md`, or engineering
records unless they materially change reliability, security, or compatibility.
output. Map every selected source change to exactly one placement:
`hero`, `secondary`, or `full-only`. Include the change kind (`feature`,
`change`, or `fix`) and a short reason. The ledger is review evidence, not a
committed public artifact; no selected source item may disappear silently.
3. Choose exactly one `hero`: the strongest user-facing reason to care about
the release. Its summary is one plain-language outcome, and its 1–3 bullets
are distinct user benefits rather than implementation steps or filler.
4. Use `secondary` for other important user-visible features and fixes. The
`toastDigest` counts only these items, excluding the hero. Preview the
strongest 1–2 secondary items in short phrases. If there are no legitimate
secondary items, set both counts to `0` and `preview` to `[]`; the app hides
the footer. Never invent an item to satisfy the layout.
5. Use `full-only` for technically relevant details that belong in
`RELEASE_NOTES.md` or `CHANGELOG.md` but would make the collapsed update card
noisy. Preserve user-relevant trust and compatibility limits; omit branches,
worktrees, CI mechanics, debugging history, and private/operator context.
6. Write each surface for its audience:
- `RELEASE_NOTES.md`: concise Summary plus Added/Changed/Fixed; keep the
deterministic Download and Install/Verify scaffolding intact.
- `CHANGELOG.md`: complete, crisp public history for the released surface.
- `changelog.json`: overall title/summary, complete typed changes, selected
highlights, compatibility boundaries, and Play copy. Counts and previews
are derived; never author a parallel digest.
- `changelog.json`: curated hero, optional improvements, digest, Play copy,
and compatibility `sections` for older clients.
- `playNotes`: Android-only themes within the rendered 500-character limit.
7. Before presenting the draft, check that wording begins with user outcomes,
avoids unexplained implementation terminology, uses exact public product
+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.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -7,6 +7,7 @@ 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
@@ -27,6 +28,50 @@ 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 {
@@ -1,160 +0,0 @@
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,10 +1,6 @@
<?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"
@@ -1,6 +0,0 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
/** Empty debug-only host populated by the Bot Chat lifecycle instrumentation. */
class BotChatBindingTestActivity : ComponentActivity()
@@ -1,3 +1,3 @@
v1.14.0 - Connections, delegated work, Git, and voice
v1.14.0 - Connections that follow you
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
+45 -110
View File
@@ -1,120 +1,55 @@
{
"schema": 3,
"schema": 2,
"versions": [
{
"version": "1.14.0",
"title": "Connections, delegated work, Git, and voice",
"title": "Connections that follow you",
"date": "2026-08-30",
"summary": "Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.",
"changes": [
{
"id": "route-aware-connections",
"kind": "improved",
"title": "Connections recover independently",
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.",
"highlight": true
},
{
"id": "delegated-agent-previews",
"kind": "added",
"title": "Follow delegated-agent activity",
"summary": "See lifecycle, progress, tool previews, and available read-only child history from the parent chat.",
"highlight": true
},
{
"id": "native-git-workspace",
"kind": "added",
"title": "Work with repositories from Android",
"summary": "Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.",
"highlight": true
},
{
"id": "voice-focus-controls",
"kind": "improved",
"title": "Steer voice at any time",
"summary": "Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.",
"highlight": true
},
{
"id": "non-blocking-release-notice",
"kind": "improved",
"title": "Release notes stay out of your way",
"summary": "A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings."
},
{
"id": "single-chat-presentation",
"kind": "improved",
"title": "Chat uses one consistent presentation",
"summary": "The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available."
},
{
"id": "wake-word-runtime",
"kind": "fixed",
"title": "Wake-word detection starts reliably",
"summary": "Compatible native voice components are now packaged for every supported phone architecture."
},
{
"id": "sphere-motion",
"kind": "fixed",
"title": "The visible Sphere keeps moving smoothly",
"summary": "Foreground animation no longer falls back to a stepped ambient pulse."
},
{
"id": "continuous-microphone-handoff",
"kind": "fixed",
"title": "Continuous voice keeps the microphone",
"summary": "The next listening turn waits for barge-in recording to release cleanly."
},
{
"id": "fresh-profile-drafts",
"kind": "fixed",
"title": "New chats keep the selected profile",
"summary": "Fresh drafts no longer reopen an older session or carry a Thread route into another profile."
},
{
"id": "provisional-thread-removal",
"kind": "fixed",
"title": "Provisional Threads can be removed safely",
"summary": "Local removal and later session promotion no longer risk duplicate rows or server history."
},
{
"id": "clarify-custom-answers",
"kind": "fixed",
"title": "Clarify keeps custom answers reachable",
"summary": "Other answers, keyboard Send, and expired prompts now behave consistently."
},
{
"id": "passive-session-observation",
"kind": "fixed",
"title": "Browsing no longer interrupts another client",
"summary": "Passive Android observation does not claim a turn owned by Desktop, TUI, or another client."
},
{
"id": "assistant-recovery-privacy",
"kind": "fixed",
"title": "Assistant sessions recover more clearly",
"summary": "No-speech feedback, recreated session state, and keyguard privacy now remain intact."
},
{
"id": "relay-auth-boundaries",
"kind": "fixed",
"title": "Protected Relay routes report the right problem",
"summary": "Authentication challenges are no longer presented as outages, while unsafe routes still fail closed."
},
{
"id": "connection-session-readiness",
"kind": "fixed",
"title": "Connections and sessions become ready sooner",
"summary": "Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history."
}
],
"compatibility": [
"Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.",
"The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.",
"Granular Device Control and the system Voice Focus overlay remain available only in the sideload build."
"highlight": {
"title": "Reliable routes",
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard or Relay authentication.",
"bullets": [
"Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.",
"Preview delegated agent work and use the optional native Git workspace.",
"Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls."
]
},
"improvements": [
"Wake-word detection now packages a compatible native runtime for every supported ABI.",
"Protected Relay health checks no longer appear as broken routes."
],
"toastDigest": {
"additionalFeatureCount": 2,
"fixCount": 10,
"preview": [
"delegated-agent previews",
"safer voice and sessions"
]
},
"playNotes": "Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.",
"sections": []
"sections": [
{
"header": "Use the best available route",
"bullets": [
"Resolve Dashboard, Relay, and optional API health independently across LAN, Tailscale, and public HTTPS.",
"Keep same-origin Relay ingress on the exact Dashboard origin that owns authentication."
]
},
{
"header": "Follow active work",
"bullets": [
"Preview delegated-agent lifecycle, progress, tools, and available read-only child history from the parent chat.",
"Review Git status, diffs, branches, staging, commits, and remotes through the optional Relay plugin."
]
},
{
"header": "Keep voice and conversations owned correctly",
"bullets": [
"Serialize microphone handoff and keep Voice Focus Stop and steering accessible across active phases.",
"Preserve fresh profile drafts, provisional Thread ownership, Clarify answers, passive observation, and Assistant privacy through reconnects."
]
}
]
},
{
"version": "1.13.2",
+8 -29
View File
@@ -1,31 +1,10 @@
v1.14.0 - Connections, delegated work, Git, and voice
v1.14.0 - Connections that follow you
Summary
* Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.
Reliable routes
* Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.
* Preview delegated agent work and use the optional native Git workspace.
* Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls.
Highlights
* Connections recover independently — Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.
* Follow delegated-agent activity — See lifecycle, progress, tool previews, and available read-only child history from the parent chat.
* Work with repositories from Android — Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.
* Steer voice at any time — Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.
Improved
* Release notes stay out of your way — A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings.
* Chat uses one consistent presentation — The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available.
Fixed
* Wake-word detection starts reliably — Compatible native voice components are now packaged for every supported phone architecture.
* The visible Sphere keeps moving smoothly — Foreground animation no longer falls back to a stepped ambient pulse.
* Continuous voice keeps the microphone — The next listening turn waits for barge-in recording to release cleanly.
* New chats keep the selected profile — Fresh drafts no longer reopen an older session or carry a Thread route into another profile.
* Provisional Threads can be removed safely — Local removal and later session promotion no longer risk duplicate rows or server history.
* Clarify keeps custom answers reachable — Other answers, keyboard Send, and expired prompts now behave consistently.
* Browsing no longer interrupts another client — Passive Android observation does not claim a turn owned by Desktop, TUI, or another client.
* Assistant sessions recover more clearly — No-speech feedback, recreated session state, and keyguard privacy now remain intact.
* Protected Relay routes report the right problem — Authentication challenges are no longer presented as outages, while unsafe routes still fail closed.
* Connections and sessions become ready sooner — Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history.
Compatibility
* Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.
* The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.
* Granular Device Control and the system Voice Focus overlay remain available only in the sideload build.
Also improved
* Wake-word detection now packages a compatible native runtime for every supported ABI.
* Protected Relay health checks no longer appear as broken routes.
@@ -294,55 +294,7 @@ fun VersionNotesBlock(
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val highlight = entry.highlight
if (entry.changes.isNotEmpty()) {
if (showVersionLine) {
Text(
text = entry.versionLine(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
entry.summary?.takeIf { it.isNotBlank() }?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
val highlights = entry.highlightedChanges()
if (highlights.isNotEmpty()) {
ReleaseSectionTitle(stringResource(R.string.changelog_highlights))
ReleaseChangeList(highlights)
}
listOf(
CHANGE_KIND_ADDED to R.string.changelog_added,
CHANGE_KIND_IMPROVED to R.string.changelog_improved,
CHANGE_KIND_FIXED to R.string.changelog_fixed,
).forEach { (kind, label) ->
val changes = entry.remainingChangesOfKind(kind)
if (changes.isNotEmpty()) {
ReleaseSectionTitle(stringResource(label))
ReleaseChangeList(changes)
}
}
if (entry.compatibility.isNotEmpty()) {
ReleaseSectionTitle(stringResource(R.string.changelog_compatibility))
VersionNotesBody(
listOf(WhatsNewGroup(header = null, bullets = entry.compatibility)),
)
}
} else if (highlight == null) {
if (highlight == null) {
if (showVersionLine) {
Text(
text = entry.subtitle(),
@@ -388,47 +340,6 @@ fun VersionNotesBlock(
}
}
@Composable
private fun ReleaseSectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 6.dp),
)
}
@Composable
private fun ColumnScope.ReleaseChangeList(changes: List<ChangelogChange>) {
changes.forEach { change ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "•",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = change.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
change.summary?.takeIf { it.isNotBlank() }?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// ──────────────────────────────────────────────────────────────────────────
// Structured changelog model + loader (kotlinx.serialization).
// ──────────────────────────────────────────────────────────────────────────
@@ -452,37 +363,16 @@ data class ChangelogHighlight(
@Serializable
data class ChangelogToastDigest(
val additionalFeatureCount: Int = 0,
val improvementCount: Int = 0,
val fixCount: Int = 0,
val preview: List<String> = emptyList(),
)
const val CHANGE_KIND_ADDED = "added"
const val CHANGE_KIND_IMPROVED = "improved"
const val CHANGE_KIND_FIXED = "fixed"
/** One complete, user-visible change in a release. */
@Serializable
data class ChangelogChange(
val id: String,
val kind: String,
val title: String,
val summary: String? = null,
val highlight: Boolean = false,
) {
fun fallbackText(): String =
summary?.takeIf { it.isNotBlank() }?.let { "$title — $it" } ?: title
}
/** A single released version's user-facing notes. */
@Serializable
data class ChangelogVersion(
val version: String,
val title: String? = null,
val date: String? = null,
val summary: String? = null,
val changes: List<ChangelogChange> = emptyList(),
val compatibility: List<String> = emptyList(),
val highlight: ChangelogHighlight? = null,
val improvements: List<String> = emptyList(),
val toastDigest: ChangelogToastDigest? = null,
@@ -503,48 +393,8 @@ data class ChangelogVersion(
return "v$version$datePart"
}
fun highlightedChanges(): List<ChangelogChange> = changes.filter { it.highlight }
fun remainingChanges(): List<ChangelogChange> = changes.filterNot { it.highlight }
fun remainingChangesOfKind(kind: String): List<ChangelogChange> =
remainingChanges().filter { it.kind == kind }
/** Derive compact toast metadata from the same changes the expanded view renders. */
fun resolvedToastDigest(): ChangelogToastDigest? {
if (changes.isEmpty()) return toastDigest
val remaining = remainingChanges()
if (remaining.isEmpty()) return null
return ChangelogToastDigest(
additionalFeatureCount = remaining.count { it.kind == CHANGE_KIND_ADDED },
improvementCount = remaining.count { it.kind == CHANGE_KIND_IMPROVED },
fixCount = remaining.count { it.kind == CHANGE_KIND_FIXED },
preview = remaining.take(2).map { it.title },
)
}
fun toGroups(): List<WhatsNewGroup> =
if (changes.isNotEmpty()) {
buildList {
val highlights = highlightedChanges()
if (highlights.isNotEmpty()) {
add(WhatsNewGroup("Highlights", highlights.map { it.fallbackText() }))
}
listOf(
CHANGE_KIND_ADDED to "Added",
CHANGE_KIND_IMPROVED to "Improved",
CHANGE_KIND_FIXED to "Fixed",
).forEach { (kind, label) ->
val items = remainingChangesOfKind(kind)
if (items.isNotEmpty()) {
add(WhatsNewGroup(label, items.map { it.fallbackText() }))
}
}
if (compatibility.isNotEmpty()) {
add(WhatsNewGroup("Compatibility", compatibility))
}
}
} else highlight?.let { curated ->
highlight?.let { curated ->
buildList {
add(WhatsNewGroup(curated.title.takeIf { it.isNotBlank() }, curated.bullets))
if (improvements.isNotEmpty()) {
@@ -185,10 +185,6 @@ internal fun WhatsNewToastContent(
) {
val title = stringResource(R.string.whats_new_title)
val highlight = entry.highlight
val digest = entry.resolvedToastDigest()
val releaseTitle = entry.title?.takeIf(String::isNotBlank) ?: highlight?.title.orEmpty()
val releaseSummary = entry.summary?.takeIf(String::isNotBlank)
?: highlight?.summary?.takeIf(String::isNotBlank)
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
@@ -224,14 +220,14 @@ internal fun WhatsNewToastContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = releaseTitle,
text = highlight?.title ?: entry.title.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
releaseSummary?.let { summary ->
highlight?.summary?.takeIf(String::isNotBlank)?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
@@ -255,9 +251,9 @@ internal fun WhatsNewToastContent(
)
}
}
digest?.takeIf {
it.additionalFeatureCount > 0 || it.improvementCount > 0 || it.fixCount > 0
}?.let { resolvedDigest ->
entry.toastDigest?.takeIf {
it.additionalFeatureCount > 0 || it.fixCount > 0
}?.let { digest ->
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f),
@@ -276,30 +272,21 @@ internal fun WhatsNewToastContent(
) {
Column(modifier = Modifier.weight(1f)) {
val counts = buildList {
if (resolvedDigest.additionalFeatureCount > 0) {
if (digest.additionalFeatureCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_additional_feature_count,
resolvedDigest.additionalFeatureCount,
resolvedDigest.additionalFeatureCount,
digest.additionalFeatureCount,
digest.additionalFeatureCount,
),
)
}
if (resolvedDigest.improvementCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_improvement_count,
resolvedDigest.improvementCount,
resolvedDigest.improvementCount,
),
)
}
if (resolvedDigest.fixCount > 0) {
if (digest.fixCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_fix_count,
resolvedDigest.fixCount,
resolvedDigest.fixCount,
digest.fixCount,
digest.fixCount,
),
)
}
@@ -311,11 +298,11 @@ internal fun WhatsNewToastContent(
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = resolvedDigest.preview.joinToString(", ") + "…",
text = digest.preview.joinToString(", ") + "…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -56,21 +56,11 @@ 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
@@ -84,52 +74,14 @@ fun BotChatScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
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 handler = remember(route.key) { ChatHandler() }
val context = LocalContext.current
// 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 messages by chatViewModel.messages.collectAsState()
val isStreaming by chatViewModel.isStreaming.collectAsState()
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
val error by handler.error.collectAsState()
val iconPath by profileIconFlow(route.connectionId, route.profileName)
val error by chatViewModel.error.collectAsState()
val iconPath by connectionViewModel
.profileIconFlow(route.connectionId, route.profileName)
.collectAsState(initial = null)
val listState = rememberLazyListState()
var composer by remember(route.key, sessionId) { mutableStateOf("") }
@@ -149,7 +101,11 @@ internal fun BotChatScreen(
selected?.name == route.profileName
}
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
historyLoader(route.profileName, storedSessionId, mode)
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = route.profileName,
mode = mode,
)
}
chatViewModel.updateApiClient(null)
chatViewModel.updateGatewayClient(gatewayClient)
+1 -10
View File
@@ -584,11 +584,6 @@
<string name="changelog_state_collapsed">Recolhido</string>
<string name="changelog_installed">Instalado</string>
<string name="changelog_also_improved">Também melhorado</string>
<string name="changelog_highlights">Destaques</string>
<string name="changelog_added">Adicionado</string>
<string name="changelog_improved">Melhorado</string>
<string name="changelog_fixed">Corrigido</string>
<string name="changelog_compatibility">Compatibilidade</string>
<string name="changelog_toast_also">Também: %1$s</string>
<string name="changelog_view_all">Ver tudo</string>
<plurals name="changelog_additional_feature_count">
@@ -599,10 +594,6 @@
<item quantity="one">%1$d correção</item>
<item quantity="other">%1$d correções</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d melhoria</item>
<item quantity="other">%1$d melhorias</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Voltar</string>
@@ -3248,7 +3239,7 @@
<string name="whats_new_full_history">Ver histórico completo</string>
<string name="whats_new_no_notes">Nenhuma nota de versão disponível</string>
<string name="whats_new_title">Novidades</string>
<string name="whats_new_subtitle">Destaques e detalhes completos da versão</string>
<string name="whats_new_subtitle">Destaques da versão mais recente</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Acesso temporário à tela expirou</string>
<string name="bridge_notification_auto_disabled_body">Inspeção e controle da tela foram desativados após ociosidade. As outras permissões não mudaram.</string>
@@ -621,11 +621,6 @@
<string name="changelog_state_collapsed">已收起</string>
<string name="changelog_installed">已安装</string>
<string name="changelog_also_improved">其他改进</string>
<string name="changelog_highlights">亮点</string>
<string name="changelog_added">新增</string>
<string name="changelog_improved">改进</string>
<string name="changelog_fixed">修复</string>
<string name="changelog_compatibility">兼容性</string>
<string name="changelog_toast_also">另外:%1$s</string>
<string name="changelog_view_all">查看全部</string>
<plurals name="changelog_additional_feature_count">
@@ -634,9 +629,6 @@
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 项修复</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="other">%1$d 项改进</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">诊断</string>
@@ -3347,7 +3339,7 @@
<string name="whats_new_full_history">查看完整历史记录</string>
<string name="whats_new_no_notes">无可用的发布说明</string>
<string name="whats_new_title">更新内容</string>
<string name="whats_new_subtitle">亮点和完整版本详情</string>
<string name="whats_new_subtitle">最新版本亮点</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">限时屏幕访问已到期</string>
<string name="bridge_notification_auto_disabled_body">空闲后,屏幕检查和控制已关闭。其他授权保持不变。</string>
+1 -10
View File
@@ -621,11 +621,6 @@
<string name="changelog_state_collapsed">Eingeklappt</string>
<string name="changelog_installed">Installiert</string>
<string name="changelog_also_improved">Außerdem verbessert</string>
<string name="changelog_highlights">Highlights</string>
<string name="changelog_added">Hinzugefügt</string>
<string name="changelog_improved">Verbessert</string>
<string name="changelog_fixed">Behoben</string>
<string name="changelog_compatibility">Kompatibilität</string>
<string name="changelog_toast_also">Außerdem: %1$s</string>
<string name="changelog_view_all">Alle anzeigen</string>
<plurals name="changelog_additional_feature_count">
@@ -636,10 +631,6 @@
<item quantity="one">%1$d Fehlerbehebung</item>
<item quantity="other">%1$d Fehlerbehebungen</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d Verbesserung</item>
<item quantity="other">%1$d Verbesserungen</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnose</string>
@@ -3416,7 +3407,7 @@
<string name="whats_new_full_history">Vollständigen Verlauf anzeigen</string>
<string name="whats_new_no_notes">Keine Versionshinweise verfügbar</string>
<string name="whats_new_title">Neuigkeiten</string>
<string name="whats_new_subtitle">Highlights und vollständige Versionsdetails</string>
<string name="whats_new_subtitle">Highlights der neuesten Version</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Zeitgesteuerter Bildschirmzugriff abgelaufen</string>
<string name="bridge_notification_auto_disabled_body">Bildschirmprüfung und -steuerung sind nach Inaktivität aus. Andere Berechtigungen bleiben unverändert.</string>
+1 -10
View File
@@ -556,11 +556,6 @@
<string name="changelog_state_collapsed">Contraído</string>
<string name="changelog_installed">Instalada</string>
<string name="changelog_also_improved">También mejorado</string>
<string name="changelog_highlights">Destacados</string>
<string name="changelog_added">Añadido</string>
<string name="changelog_improved">Mejorado</string>
<string name="changelog_fixed">Corregido</string>
<string name="changelog_compatibility">Compatibilidad</string>
<string name="changelog_toast_also">También: %1$s</string>
<string name="changelog_view_all">Ver todo</string>
<plurals name="changelog_additional_feature_count">
@@ -571,10 +566,6 @@
<item quantity="one">%1$d corrección</item>
<item quantity="other">%1$d correcciones</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d mejora</item>
<item quantity="other">%1$d mejoras</item>
</plurals>
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Atrás</string>
<string name="diag_status">Estado</string>
@@ -3080,7 +3071,7 @@
<string name="whats_new_full_history">Ver historial completo</string>
<string name="whats_new_no_notes">No hay notas de versión disponibles</string>
<string name="whats_new_title">¿Qué hay de nuevo?</string>
<string name="whats_new_subtitle">Destacados y detalles completos de la versión</string>
<string name="whats_new_subtitle">Novedades de la última versión</string>
<string name="bridge_notification_auto_disabled_title">Acceso de pantalla temporizado vencido</string>
<string name="bridge_notification_auto_disabled_body">La inspección y el control de pantalla están desactivados tras la inactividad. Los demás permisos no cambian.</string>
<string name="bridge_notification_control_title">El agente Hermes tiene control del dispositivo.</string>
+1 -9
View File
@@ -621,11 +621,6 @@
<string name="changelog_state_collapsed">折りたたみ済み</string>
<string name="changelog_installed">インストール済み</string>
<string name="changelog_also_improved">その他の改善</string>
<string name="changelog_highlights">ハイライト</string>
<string name="changelog_added">追加</string>
<string name="changelog_improved">改善</string>
<string name="changelog_fixed">修正</string>
<string name="changelog_compatibility">互換性</string>
<string name="changelog_toast_also">その他: %1$s</string>
<string name="changelog_view_all">すべて表示</string>
<plurals name="changelog_additional_feature_count">
@@ -634,9 +629,6 @@
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 件の修正</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="other">%1$d 件の改善</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">診断</string>
@@ -3423,7 +3415,7 @@
<string name="whats_new_full_history">全履歴を表示</string>
<string name="whats_new_no_notes">リリースノートはありません</string>
<string name="whats_new_title">新機能</string>
<string name="whats_new_subtitle">ハイライトと完全なリリース詳細</string>
<string name="whats_new_subtitle">最新リリースのハイライト</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">時間制限付き画面アクセスの期限切れ</string>
<string name="bridge_notification_auto_disabled_body">操作がなかったため、画面の検査と操作をオフにしました。他の許可は変更されません。</string>
+1 -12
View File
@@ -599,11 +599,6 @@
<string name="changelog_state_collapsed">Свернуто</string>
<string name="changelog_installed">Установлено</string>
<string name="changelog_also_improved">Также улучшено</string>
<string name="changelog_highlights">Основное</string>
<string name="changelog_added">Добавлено</string>
<string name="changelog_improved">Улучшено</string>
<string name="changelog_fixed">Исправлено</string>
<string name="changelog_compatibility">Совместимость</string>
<string name="changelog_toast_also">Также: %1$s</string>
<string name="changelog_view_all">Показать все</string>
<plurals name="changelog_additional_feature_count">
@@ -618,12 +613,6 @@
<item quantity="many">%1$d исправлений</item>
<item quantity="other">%1$d исправления</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d улучшение</item>
<item quantity="few">%1$d улучшения</item>
<item quantity="many">%1$d улучшений</item>
<item quantity="other">%1$d улучшения</item>
</plurals>
<string name="diag_title">Диагностика</string>
<string name="diag_back">Назад</string>
<string name="diag_status">Статус</string>
@@ -3286,7 +3275,7 @@
<string name="whats_new_full_history">Просмотреть полную историю</string>
<string name="whats_new_no_notes">Примечания к выпуску недоступны</string>
<string name="whats_new_title">Что нового</string>
<string name="whats_new_subtitle">Основное и полный список изменений</string>
<string name="whats_new_subtitle">Последние обновления</string>
<string name="bridge_notification_auto_disabled_title">Временный доступ к экрану истек</string>
<string name="bridge_notification_auto_disabled_body">Просмотр и управление экраном отключены после бездействия. Другие разрешения не изменены.</string>
<string name="bridge_notification_control_title">Агент Гермес имеет управление устройством</string>
+1 -10
View File
@@ -666,11 +666,6 @@
<string name="changelog_state_collapsed">Collapsed</string>
<string name="changelog_installed">Installed</string>
<string name="changelog_also_improved">Also improved</string>
<string name="changelog_highlights">Highlights</string>
<string name="changelog_added">Added</string>
<string name="changelog_improved">Improved</string>
<string name="changelog_fixed">Fixed</string>
<string name="changelog_compatibility">Compatibility</string>
<string name="changelog_toast_also">Also: %1$s</string>
<string name="changelog_view_all">View all</string>
<plurals name="changelog_additional_feature_count">
@@ -681,10 +676,6 @@
<item quantity="one">%1$d fix</item>
<item quantity="other">%1$d fixes</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d improvement</item>
<item quantity="other">%1$d improvements</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnostics</string>
@@ -3790,7 +3781,7 @@
<string name="whats_new_full_history">View full history</string>
<string name="whats_new_no_notes">No release notes available</string>
<string name="whats_new_title">What\u0027s new</string>
<string name="whats_new_subtitle">Highlights and complete release details</string>
<string name="whats_new_subtitle">Latest release highlights</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Timed screen access expired</string>
<string name="bridge_notification_auto_disabled_body">Screen inspection and control are off after being idle. Other grants are unchanged.</string>
@@ -19,17 +19,13 @@ import org.robolectric.annotation.GraphicsMode
class ChangelogHistoryScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun latestReleaseShowsHighlightsAndCompleteDetails() {
@Test fun latestReleaseIsCuratedAndInstalled() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("v1.14.0 — Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("Highlights").assertExists()
compose.onNodeWithText("Fixed").assertExists()
compose.onNodeWithText("Wake-word detection starts reliably").assertExists()
compose.onNodeWithText("Compatibility").assertExists()
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
}
@@ -61,11 +61,9 @@ class WhatsNewToastScreenshotTest {
}
}
}
compose.onNodeWithText("Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("Also: 2 improvements · 10 fixes").assertExists()
compose.onNodeWithText(
"Release notes stay out of your way, Chat uses one consistent presentation…",
).assertExists()
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Also: 2 fixes").assertExists()
compose.onNodeWithText("Accurate activity, safer return…").assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithContentDescription("Close").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/whats-new-toast-large-text.png")
@@ -86,7 +84,7 @@ class WhatsNewToastScreenshotTest {
}
}
compose.onNodeWithText("Connections, delegated work, Git, and voice").performClick()
compose.onNodeWithText("Supervised Mode").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithContentDescription("Close").performClick()
@@ -108,8 +106,7 @@ class WhatsNewToastScreenshotTest {
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Connections, delegated work, Git, and voice")
.performTouchInput { swipeLeft(durationMillis = 300L) }
compose.onNodeWithText("Supervised Mode").performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
assertTrue(dismissed)
@@ -37,25 +37,26 @@ class ChangelogParserTest {
}
@Test
fun parsesCompleteReleaseAndDerivesToastDigest() {
fun parsesCuratedHighlightAndImprovements() {
val raw = """
{
"schema": 3,
"schema": 2,
"versions": [
{
"version": "1.2.0",
"title": "A useful release",
"date": "2026-06-20",
"summary": "A plain-language explanation.",
"changes": [
{"id": "new-one", "kind": "added", "title": "New one",
"summary": "Use the new thing.", "highlight": true},
{"id": "better-one", "kind": "improved", "title": "Better one",
"summary": "The old thing is easier."},
{"id": "fixed-one", "kind": "fixed", "title": "Fixed one",
"summary": "The broken thing works."}
],
"compatibility": ["Existing connections keep working."],
"highlight": {
"title": "The main reason to care",
"summary": "A plain-language explanation.",
"bullets": ["a", "b", "c"]
},
"improvements": ["d", "e"],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"]
},
"playNotes": "Concise Play copy."
}
]
@@ -63,41 +64,17 @@ class ChangelogParserTest {
""".trimIndent()
val entry = ChangelogStore.parse(raw).versions.single()
val digest = entry.resolvedToastDigest()
assertEquals("A plain-language explanation.", entry.summary)
assertEquals(listOf("New one"), entry.highlightedChanges().map { it.title })
assertEquals(0, digest?.additionalFeatureCount)
assertEquals(1, digest?.improvementCount)
assertEquals(1, digest?.fixCount)
assertEquals(listOf("Better one", "Fixed one"), digest?.preview)
assertEquals(listOf("Existing connections keep working."), entry.compatibility)
assertEquals("The main reason to care", entry.highlight?.title)
assertEquals("A plain-language explanation.", entry.highlight?.summary)
assertEquals(listOf("a", "b", "c"), entry.highlight?.bullets)
assertEquals(listOf("d", "e"), entry.improvements)
assertEquals(1, entry.toastDigest?.additionalFeatureCount)
assertEquals(2, entry.toastDigest?.fixCount)
assertEquals(listOf("Secondary feature", "Important fix"), entry.toastDigest?.preview)
assertEquals("Concise Play copy.", entry.playNotes)
assertEquals("v1.2.0 · 2026-06-20", entry.versionLine())
assertEquals(
listOf("Highlights", "Improved", "Fixed", "Compatibility"),
entry.toGroups().map { it.header },
)
assertEquals(3, entry.toGroups().sumOf { it.bullets.size } - entry.compatibility.size)
}
@Test
fun completeReleaseRendersEveryChangeOnce() {
val entry = ChangelogVersion(
version = "1.2.0",
title = "Complete notes",
summary = "Everything users need to know.",
changes = listOf(
ChangelogChange("a", CHANGE_KIND_ADDED, "First", "First detail", highlight = true),
ChangelogChange("b", CHANGE_KIND_FIXED, "Second", "Second detail"),
),
)
val rendered = entry.toGroups().flatMap { it.bullets }
assertEquals(2, rendered.size)
assertEquals(1, rendered.count { it.startsWith("First") })
assertEquals(1, rendered.count { it.startsWith("Second") })
assertEquals(listOf("The main reason to care", "Also improved"), entry.toGroups().map { it.header })
}
@Test
@@ -1,233 +0,0 @@
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."
}
}
-98
View File
@@ -1,98 +0,0 @@
# Windows Android build lane
Hermes-Relay uses one machine-wide Android build lane on Windows. Every local
worktree shares the same named operating-system mutex through
`scripts/android-lane.ps1`. When commands use the wrapper, Gradle, Kotlin
compilation, lint, tests, assemblies, connected tests, and APK installation do
not compete for the same host resources and shared caches.
This is a local developer workflow. CI jobs run on separate hosts and are not
affected.
## Normal use
The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire
the lane automatically. For an ad hoc Gradle command, use:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:testSideloadDebugUnitTest `
--tests "*ChatViewModelTest*" `
--console=plain
```
The wrapper waits until the current owner exits and releases the mutex. Windows
also releases the mutex if its owner crashes; the next waiter reports that it
recovered an abandoned lane. Use Ctrl+C to cancel a waiter without stopping the
owner. A bounded wait is available when a caller has its own deadline:
```powershell
.\scripts\android-lane.ps1 --timeout-seconds 900 gradle :app:lintSideloadDebug
```
Check the lane without starting work:
```powershell
.\scripts\android-lane.ps1 status
```
`status` exits 0 when idle and 1 when busy. It intentionally does not expose the
owning process's arguments because Gradle properties can contain credentials.
Use `exec` for a connected/device workflow that must exclude every
wrapper-managed Gradle lane, including explicit APK installation:
```powershell
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py <arguments>
.\scripts\android-lane.ps1 exec adb -t <transport-id> install -r <apk>
```
Raw `gradlew`, Android Studio sync/build/run, and raw `adb install` do not pass
through the mutex. Before using one of those paths, check that the lane is idle
and keep all queued wrappers idle until it finishes. During final integration,
close or pause Android Studio's automatic Gradle activity and let one
coordinator own the sequence.
## Final coordinator gate
Run the final gates serially on the exact candidate head. Keep each focused
test invocation narrow so it releases the lane promptly for other queued work.
The coordinator then runs, in order:
1. Focused sideload unit tests.
2. Focused Google Play unit and policy tests.
3. Android lint.
4. Sideload and Google Play assemblies (or the release-equivalent both-flavor gate).
5. Connected/instrumentation and physical-device checks when the change needs them.
6. The explicit APK install only after every preceding gate passes.
Do not run lint, both-flavor builds, connected checks, or installation in
parallel. A timeout from an outer agent or terminal is not evidence that Gradle
failed; use the wrapper's process exit and Gradle output as the result.
## Resource policy
The normal serialized path retains the repository defaults:
- the shared Gradle user home, wrapper distributions, dependency cache, build
cache, configuration cache, and compatible warm daemon;
- `org.gradle.jvmargs=-Xmx4g`, `org.gradle.daemon=true`,
`org.gradle.caching=true`, `org.gradle.configuration-cache=true`, and
`org.gradle.parallel=true`;
- Gradle's normal worker selection inside the one active invocation.
`--max-workers` limits workers only inside one Gradle invocation. It does not
prevent another worktree, Android Studio, or an APK-install lane from running.
Likewise, `--no-daemon` can still start a single-use daemon to honor the required
JVM settings; it is not a cross-process isolation mechanism.
Use a worktree-specific `GRADLE_USER_HOME`, `--no-build-cache`,
`--max-workers=1`, or `-Pkotlin.compiler.execution.strategy=in-process` only to
recover from a confirmed daemon/cache failure or for an explicitly isolated
exception. Those modes discard normal cache and warm-daemon benefits, can use
more disk and memory, and must still acquire the Android lane.
If recovery is required, first preserve the failing output and verify that no
other owner is active. Do not kill unrelated Java, Gradle, Kotlin, Android
Studio, or ADB processes merely to clear the lane; an OS-abandoned mutex is
recoverable automatically.
+3 -4
View File
@@ -66,7 +66,6 @@ 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 |
@@ -111,8 +110,8 @@ turn is live. The external lane consumes the shared Python fixture and reads
its authoritative history over HTTP.
```powershell
.\scripts\android-lane.ps1 gradle :app:compileSideloadDebugAndroidTestKotlin
.\scripts\android-lane.ps1 gradle :app:assembleSideloadDebug :app:assembleSideloadDebugAndroidTest
.\gradlew.bat :app:compileSideloadDebugAndroidTestKotlin
.\gradlew.bat :app:assembleSideloadDebug :app:assembleSideloadDebugAndroidTest
```
The external test is opt-in through the instrumentation argument
@@ -126,7 +125,7 @@ transport ID, targets only `com.axiomlabs.hermesrelay.sideload`, and performs no
installation unless its corresponding install flag is supplied.
```powershell
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py `
python scripts/android-gateway-certify.py `
--transport-id <adb-transport-id> `
--apk app/build/outputs/apk/sideload/debug/<sideload-apk> `
--test-apk app/build/outputs/apk/androidTest/sideload/debug/<test-apk> `
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1 -1
View File
@@ -91,7 +91,7 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.14.0 - Connections, delegated work, Git, and voice
v1.14.0 - Connections that follow you
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
```
+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.10.0"
navigation-compose = "2.9.8"
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.45.0"
coil = "3.6.0"
haze = "1.7.3"
markdown-renderer = "0.44.0"
coil = "3.5.0"
haze = "1.7.2"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
-141
View File
@@ -1,141 +0,0 @@
# Hermes-Relay's machine-wide Windows lane for Gradle and Android device work.
# Every worktree uses the same named mutex. Keep this script dependency-free so
# it can run under the Windows PowerShell bundled with Windows.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$mutexName = "Global\HermesRelay.AndroidBuildLane.v1"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Show-Usage {
Write-Host @"
Usage:
.\scripts\android-lane.ps1 status
.\scripts\android-lane.ps1 [--timeout-seconds <seconds>] gradle <gradle-args...>
.\scripts\android-lane.ps1 [--timeout-seconds <seconds>] exec <command> <args...>
Examples:
.\scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --tests "*ChatViewModelTest*"
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py --help
The default wait is unbounded. Ctrl+C cancels a waiter without affecting the
current owner. Use exec for connected-test or APK-install workflows that must
serialize with wrapper-managed Gradle lanes. Keep Android Studio idle separately.
"@
}
function New-AndroidLaneMutex {
try {
return [System.Threading.Mutex]::new($false, $mutexName)
}
catch [System.UnauthorizedAccessException] {
throw "The machine-wide Hermes-Relay Android lane exists but this Windows account cannot open it. Run all worktrees under the same account or correct the mutex permissions."
}
}
function Test-AcquireMutex {
param(
[System.Threading.Mutex] $Mutex,
[TimeSpan] $Wait
)
try {
return $Mutex.WaitOne($Wait)
}
catch [System.Threading.AbandonedMutexException] {
Write-Warning "The previous Android lane owner exited without releasing the mutex; ownership was recovered."
return $true
}
}
$tokens = @($args)
if ($tokens.Count -eq 0 -or $tokens[0] -in @("help", "--help", "-h")) {
Show-Usage
exit 0
}
$timeoutSeconds = 0
if ($tokens[0] -eq "--timeout-seconds") {
if ($tokens.Count -lt 3) {
throw "--timeout-seconds requires a non-negative integer and a mode."
}
$parsedTimeout = 0
if (-not [int]::TryParse($tokens[1], [ref] $parsedTimeout) -or $parsedTimeout -lt 0) {
throw "--timeout-seconds must be a non-negative integer."
}
$timeoutSeconds = $parsedTimeout
$tokens = @($tokens[2..($tokens.Count - 1)])
}
$mode = $tokens[0]
$mutex = New-AndroidLaneMutex
$acquired = $false
try {
if ($mode -eq "status") {
$acquired = Test-AcquireMutex -Mutex $mutex -Wait ([TimeSpan]::Zero)
if ($acquired) {
Write-Host "Hermes-Relay Android lane: IDLE"
$mutex.ReleaseMutex()
$acquired = $false
exit 0
}
Write-Host "Hermes-Relay Android lane: BUSY"
exit 1
}
if ($mode -notin @("gradle", "exec")) {
throw "Unknown mode '$mode'. Use status, gradle, or exec."
}
if ($mode -eq "exec" -and $tokens.Count -lt 2) {
throw "exec requires a command."
}
$waitDescription = if ($timeoutSeconds -eq 0) { "without a timeout" } else { "for up to $timeoutSeconds seconds" }
Write-Host "Waiting for the machine-wide Hermes-Relay Android lane $waitDescription..."
$startedWaiting = [System.Diagnostics.Stopwatch]::StartNew()
while (-not $acquired) {
$sliceSeconds = 30
if ($timeoutSeconds -gt 0) {
$remaining = $timeoutSeconds - [int][Math]::Floor($startedWaiting.Elapsed.TotalSeconds)
if ($remaining -le 0) {
throw "Timed out waiting for the machine-wide Hermes-Relay Android lane after $timeoutSeconds seconds."
}
$sliceSeconds = [Math]::Min($sliceSeconds, $remaining)
}
$acquired = Test-AcquireMutex -Mutex $mutex -Wait ([TimeSpan]::FromSeconds($sliceSeconds))
if (-not $acquired) {
Write-Host "Still waiting for the Hermes-Relay Android lane ($([int]$startedWaiting.Elapsed.TotalSeconds)s elapsed)..."
}
}
Write-Host "Acquired the machine-wide Hermes-Relay Android lane."
Push-Location $repoRoot
try {
if ($mode -eq "gradle") {
$executable = Join-Path $repoRoot "gradlew.bat"
$commandArguments = if ($tokens.Count -gt 1) { @($tokens[1..($tokens.Count - 1)]) } else { @() }
}
else {
$executable = $tokens[1]
$commandArguments = if ($tokens.Count -gt 2) { @($tokens[2..($tokens.Count - 1)]) } else { @() }
}
& $executable @commandArguments
$commandExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
}
finally {
Pop-Location
}
exit $commandExitCode
}
finally {
if ($acquired) {
$mutex.ReleaseMutex()
Write-Host "Released the machine-wide Hermes-Relay Android lane."
}
$mutex.Dispose()
}
+4 -14
View File
@@ -86,23 +86,13 @@ def main() -> int:
print("\nAndroid repository checks passed.")
return 0
if sys.platform == "win32":
gradle = [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "android-lane.ps1"),
"gradle",
]
else:
gradle = [str(REPO_ROOT / "gradlew")]
gradle.extend([
wrapper = REPO_ROOT / ("gradlew.bat" if sys.platform == "win32" else "gradlew")
gradle = [
str(wrapper),
"--console=plain",
"--configuration-cache",
*tasks,
])
]
if not args.skip_tests:
for test_name in FOCUSED_TESTS:
gradle.extend(("--tests", test_name))
+3 -3
View File
@@ -68,9 +68,9 @@ echo ""
echo " Next steps:"
echo " 1. Update CHANGELOG.md, RELEASE_NOTES.md, and prepend the curated Android"
echo " release entry to app/src/main/assets/changelog.json. The newest entry"
echo " requires one overall title + summary, a complete changes[] inventory"
echo " with unique ids/kinds/titles/explanations and 1..4 highlights, optional"
echo " compatibility bullets, and playNotes. Toast counts/previews are derived."
echo " requires highlight {title,summary,bullets[1..3]}, improvements[0..2],"
echo " toastDigest {additionalFeatureCount,fixCount,preview[0..2]}, playNotes,"
echo " and the technical sections retained for history."
echo " 2. Refresh and validate the derived in-app fallback + Play notes:"
echo " python scripts/check-android-release-notes.py --write"
echo " python scripts/check-android-release-notes.py"
+62 -72
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env python3
"""Validate and synchronize Android release-note surfaces.
The newest ``changelog.json`` entry is the complete user-visible source for the
in-app What's New experience. ``--write`` refreshes the legacy text fallback,
Google Play notes, and the matching operator copy in
``docs/play-store-listing.md``. The technical CHANGELOG and GitHub release body
remain separate records, but their version headings are checked so release
prep cannot silently drift.
The newest ``changelog.json`` entry is the curated source for the in-app
What's New experience. ``--write`` refreshes the legacy text fallback, Google
Play notes, and the matching operator copy in ``docs/play-store-listing.md``.
The technical CHANGELOG and GitHub release body remain fuller records, but
their version headings are checked so release prep cannot silently drift.
"""
from __future__ import annotations
@@ -30,12 +29,6 @@ PLAY_SECTION_RE = re.compile(
r"\(≤500 characters\):\s+```\s*\n)(.*?)(\n```\s*\n## Category)",
re.DOTALL,
)
CHANGE_KINDS = ("added", "improved", "fixed")
CHANGE_KIND_LABELS = {
"added": "Added",
"improved": "Improved",
"fixed": "Fixed",
}
def _read_text(relative: pathlib.Path) -> str:
@@ -51,8 +44,8 @@ def _android_version() -> str:
def _latest_entry() -> dict[str, Any]:
data = json.loads(_read_text(CHANGELOG_JSON))
if not isinstance(data, dict) or data.get("schema") != 3:
raise ValueError("changelog.json must use schema 3")
if not isinstance(data, dict) or data.get("schema") != 2:
raise ValueError("changelog.json must use schema 2")
versions = data.get("versions")
if not isinstance(versions, list) or not versions or not isinstance(versions[0], dict):
raise ValueError("changelog.json must contain at least one version")
@@ -67,51 +60,60 @@ def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list
f"latest changelog version is {version!r}, expected Android {expected_version!r}"
)
for field in ("title", "date", "summary"):
for field in ("title", "date"):
if not isinstance(entry.get(field), str) or not entry[field].strip():
errors.append(f"latest changelog entry requires non-empty {field}")
changes = entry.get("changes")
if not isinstance(changes, list) or not changes:
errors.append("latest changelog entry requires a non-empty changes array")
changes = []
highlight = entry.get("highlight")
if not isinstance(highlight, dict):
errors.append("latest changelog entry requires a highlight object")
highlight = {}
for field in ("title", "summary"):
if not isinstance(highlight.get(field), str) or not highlight[field].strip():
errors.append(f"latest changelog highlight requires non-empty {field}")
seen_ids: set[str] = set()
highlight_count = 0
for index, change in enumerate(changes):
prefix = f"latest changelog change {index + 1}"
if not isinstance(change, dict):
errors.append(f"{prefix} must be an object")
continue
change_id = change.get("id")
if not isinstance(change_id, str) or not change_id.strip():
errors.append(f"{prefix} requires a non-empty id")
elif change_id in seen_ids:
errors.append(f"latest changelog change id {change_id!r} is duplicated")
bullets = highlight.get("bullets")
if not isinstance(bullets, list):
errors.append("latest changelog highlight bullets must be an array")
bullets = []
elif not 1 <= len(bullets) <= 3:
errors.append("latest changelog highlight must contain 1-3 bullets")
if any(not isinstance(item, str) or not item.strip() for item in bullets):
errors.append("latest changelog highlight bullets must be non-empty strings")
improvements = entry.get("improvements", [])
if not isinstance(improvements, list):
errors.append("latest changelog improvements must be an array")
improvements = []
elif len(improvements) > 2:
errors.append("latest changelog may contain at most 2 improvements")
if any(not isinstance(item, str) or not item.strip() for item in improvements):
errors.append("latest changelog improvements must be non-empty strings")
if len(bullets) + len(improvements) > 5:
errors.append("latest changelog may expose at most 5 total list items")
digest = entry.get("toastDigest")
if not isinstance(digest, dict):
errors.append("latest changelog entry requires a toastDigest object")
digest = {}
digest_counts: list[int] = []
for field in ("additionalFeatureCount", "fixCount"):
count = digest.get(field)
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
errors.append(f"latest changelog toastDigest {field} must be a non-negative integer")
else:
seen_ids.add(change_id)
if change.get("kind") not in CHANGE_KINDS:
errors.append(f"{prefix} kind must be one of {', '.join(CHANGE_KINDS)}")
for field in ("title", "summary"):
if not isinstance(change.get(field), str) or not change[field].strip():
errors.append(f"{prefix} requires non-empty {field}")
is_highlight = change.get("highlight", False)
if not isinstance(is_highlight, bool):
errors.append(f"{prefix} highlight must be a boolean")
elif is_highlight:
highlight_count += 1
if not 1 <= highlight_count <= 4:
errors.append("latest changelog must select 1-4 highlighted changes")
compatibility = entry.get("compatibility", [])
if not isinstance(compatibility, list):
errors.append("latest changelog compatibility must be an array")
elif any(not isinstance(item, str) or not item.strip() for item in compatibility):
errors.append("latest changelog compatibility items must be non-empty strings")
for legacy_field in ("highlight", "improvements", "toastDigest"):
if legacy_field in entry:
errors.append(f"latest changelog must derive presentation instead of defining {legacy_field}")
digest_counts.append(count)
digest_preview = digest.get("preview")
if not isinstance(digest_preview, list):
errors.append("latest changelog toastDigest preview must be an array")
digest_preview = []
elif any(not isinstance(item, str) or not item.strip() for item in digest_preview):
errors.append("latest changelog toastDigest preview items must be non-empty strings")
if len(digest_counts) == 2:
if sum(digest_counts) == 0 and digest_preview:
errors.append("latest changelog toastDigest preview must be empty when counts are zero")
elif sum(digest_counts) > 0 and not 1 <= len(digest_preview) <= 2:
errors.append("latest changelog toastDigest preview must contain 1-2 items when secondary items exist")
play_notes = entry.get("playNotes")
if not isinstance(play_notes, str) or not play_notes.strip():
@@ -122,31 +124,19 @@ def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list
def render_whats_new(entry: dict[str, Any]) -> str:
highlight = entry["highlight"]
lines = [
f"v{entry['version']} - {entry['title']}",
"",
"Summary",
f"* {entry['summary']}",
highlight["title"],
*(f"* {bullet}" for bullet in highlight["bullets"]),
]
changes = entry["changes"]
highlights = [change for change in changes if change.get("highlight", False)]
if highlights:
lines.extend(("", "Highlights", *(_render_change(change) for change in highlights)))
remaining = [change for change in changes if not change.get("highlight", False)]
for kind in CHANGE_KINDS:
items = [change for change in remaining if change["kind"] == kind]
if items:
lines.extend(("", CHANGE_KIND_LABELS[kind], *(_render_change(change) for change in items)))
compatibility = entry.get("compatibility", [])
if compatibility:
lines.extend(("", "Compatibility", *(f"* {item}" for item in compatibility)))
improvements = entry.get("improvements", [])
if improvements:
lines.extend(("", "Also improved", *(f"* {item}" for item in improvements)))
return "\n".join(lines) + "\n"
def _render_change(change: dict[str, Any]) -> str:
return f"* {change['title']} — {change['summary']}"
def render_play_notes(entry: dict[str, Any]) -> str:
return f"v{entry['version']} - {entry['title']}\n\n{entry['playNotes'].strip()}\n"
+16 -28
View File
@@ -4,11 +4,6 @@ REM Usage: scripts\dev.bat <command>
cd /d "%~dp0\.."
REM Secondary worktrees normally do not carry local.properties. Reuse the
REM standard per-user SDK location when the caller has not selected one.
if not defined ANDROID_HOME if exist "%LOCALAPPDATA%\Android\Sdk" set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
if not defined ANDROID_SDK_ROOT if defined ANDROID_HOME set "ANDROID_SDK_ROOT=%ANDROID_HOME%"
if "%1"=="" goto help
if "%1"=="build" goto build
if "%1"=="release" goto release
@@ -32,15 +27,14 @@ goto help
:build
echo Building sideload debug APK...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:assembleSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:assembleSideloadDebug --console=plain
echo APK: app\build\outputs\apk\sideload\debug\
goto end
:release
echo Building release APK...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle assembleRelease
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat assembleRelease
if errorlevel 1 goto end
echo.
echo Release APK:
dir /b app\build\outputs\apk\release\*.apk 2>nul
@@ -49,8 +43,8 @@ goto end
:bundle
echo Building release AAB (for Google Play upload)...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle bundleRelease
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat bundleRelease
if errorlevel 1 goto end
echo.
echo Release AAB:
dir /b app\build\outputs\bundle\release\*.aab 2>nul
@@ -59,8 +53,8 @@ goto end
:install
echo Building and installing sideload debug to connected device...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
echo Launching app...
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
@@ -70,8 +64,8 @@ goto end
:run
echo Building, installing, and launching sideload debug...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
REM so the `.MainActivity` shorthand no longer resolves correctly.
@@ -81,8 +75,7 @@ goto end
:compile
echo Compiling sideload debug Kotlin...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:compileSideloadDebugKotlin --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:compileSideloadDebugKotlin --console=plain
goto end
:testone
@@ -91,40 +84,35 @@ if "%~2"=="" (
exit /b 2
)
echo Running focused sideload test: %~2
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --tests "%~2" --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:testSideloadDebugUnitTest --tests "%~2" --console=plain
goto end
:installfast
echo Building arm64 sideload debug and installing to connected phone...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug -Phermes.devAbi=arm64-v8a --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:installSideloadDebug -Phermes.devAbi=arm64-v8a --console=plain
if errorlevel 1 goto end
echo Launching app...
adb shell am start -n com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
goto end
:test
echo Running sideload debug unit tests...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:testSideloadDebugUnitTest --console=plain
goto end
:lint
echo Running sideload debug lint...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:lintSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat :app:lintSideloadDebug --console=plain
goto end
:prepush
echo Running Android pre-push checks...
python scripts\android-prepush.py
if errorlevel 1 exit /b %errorlevel%
goto end
:clean
echo Cleaning build...
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle clean
if errorlevel 1 exit /b %errorlevel%
call gradlew.bat clean
goto end
:devices
@@ -12,78 +12,85 @@ release_notes = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_notes)
def entry(*, highlight_count: int = 2) -> dict[str, object]:
changes = [
{
"id": f"change-{index}",
"kind": ("added", "improved", "fixed")[index % 3],
"title": f"Change {index}",
"summary": f"User-facing explanation {index}.",
"highlight": index < highlight_count,
}
for index in range(5)
]
def entry(*, bullets: int = 3, improvements: int = 2) -> dict[str, object]:
return {
"version": "1.2.3",
"title": "A useful release",
"date": "2026-08-26",
"summary": "A short explanation in plain language.",
"changes": changes,
"compatibility": ["Works with existing connections."],
"highlight": {
"title": "The main reason to care",
"summary": "A short explanation in plain language.",
"bullets": [f"Benefit {index}" for index in range(bullets)],
},
"improvements": [f"Improvement {index}" for index in range(improvements)],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"],
},
"playNotes": "A concise Play Store summary.",
}
class AndroidReleaseNotesTest(unittest.TestCase):
def test_accepts_complete_change_inventory(self) -> None:
def test_allows_three_highlights_and_two_improvements(self) -> None:
self.assertEqual([], release_notes.validate_curated_entry(entry(), "1.2.3"))
def test_rejects_more_than_four_highlights(self) -> None:
errors = release_notes.validate_curated_entry(entry(highlight_count=5), "1.2.3")
self.assertIn("latest changelog must select 1-4 highlighted changes", errors)
def test_rejects_more_than_three_highlights(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=4, improvements=0), "1.2.3")
self.assertIn("latest changelog highlight must contain 1-3 bullets", errors)
def test_rejects_duplicate_change_ids(self) -> None:
release = entry()
release["changes"][1]["id"] = "change-0" # type: ignore[index]
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog change id 'change-0' is duplicated", errors)
def test_rejects_more_than_five_visible_items(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=3, improvements=3), "1.2.3")
self.assertIn("latest changelog may contain at most 2 improvements", errors)
self.assertIn("latest changelog may expose at most 5 total list items", errors)
def test_rejects_unknown_change_kind(self) -> None:
release = entry()
release["changes"][2]["kind"] = "technical" # type: ignore[index]
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog change 3 kind must be one of added, improved, fixed", errors)
def test_renders_complete_fallback_without_duplicate_highlights(self) -> None:
rendered = release_notes.render_whats_new(entry())
self.assertIn("Summary\n* A short explanation in plain language.", rendered)
self.assertIn("Highlights\n* Change 0 — User-facing explanation 0.", rendered)
self.assertIn("Fixed\n* Change 2 — User-facing explanation 2.", rendered)
self.assertEqual(1, rendered.count("Change 0 — User-facing explanation 0."))
self.assertIn("Compatibility\n* Works with existing connections.", rendered)
def test_renders_fallback_with_curated_groups(self) -> None:
rendered = release_notes.render_whats_new(entry(bullets=1, improvements=1))
self.assertIn("The main reason to care\n* Benefit 0", rendered)
self.assertIn("Also improved\n* Improvement 0", rendered)
def test_play_notes_include_version_and_stay_bounded(self) -> None:
rendered = release_notes.render_play_notes(entry())
self.assertTrue(rendered.startswith("v1.2.3 - A useful release"))
self.assertLessEqual(len(rendered), 500)
def test_allows_a_highlights_only_release(self) -> None:
def test_allows_a_hero_only_release_without_secondary_filler(self) -> None:
release = entry()
release["changes"] = release["changes"][:2] # type: ignore[index]
release["toastDigest"] = {
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": [],
}
self.assertEqual([], release_notes.validate_curated_entry(release, "1.2.3"))
def test_rejects_legacy_manual_digest(self) -> None:
def test_rejects_preview_when_secondary_counts_are_zero(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Duplicated summary"],
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": ["Invented filler"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog must derive presentation instead of defining toastDigest", errors)
self.assertIn("latest changelog toastDigest preview must be empty when counts are zero", errors)
def test_requires_a_bounded_preview_for_secondary_items(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["one", "two", "three"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn(
"latest changelog toastDigest preview must contain 1-2 items when secondary items exist",
errors,
)
if __name__ == "__main__":
-4
View File
@@ -88,10 +88,6 @@ 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,29 +77,6 @@ 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)
@@ -330,7 +307,6 @@ 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",
@@ -358,11 +334,7 @@ class ScenarioTestCase(unittest.TestCase):
from vanilla_gateway.scenario import Scenario
Scenario.from_dict(scenario)
def test_contract_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
("gateway.session_resume_durable",),
load_scenario("initial_history_bind").contract_requirements,
)
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
(
"gateway.message_complete",
@@ -1,14 +0,0 @@
{
"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.12.0"
id("org.jetbrains.compose") version "1.11.1"
}
kotlin {