Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
2bfe21eaff |
@@ -38,10 +38,11 @@ on:
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -168,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -100,6 +100,19 @@ instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-20 — Faster Android validation feedback
|
||||
|
||||
Android contributors now have one cross-platform pre-push command for locale,
|
||||
documentation, collection-API, and version checks plus primary Play-variant
|
||||
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||
the exhaustive all-variant lint gate.
|
||||
|
||||
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||
preserving every `main` run. A newer integration commit therefore stops paying
|
||||
for an older release smoke that can no longer become the tested release tip.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
|
||||
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
|
||||
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
|
||||
untrusted callback noise, and closes on completion, cancellation, navigation,
|
||||
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
|
||||
prewarm, and standard voice; sign-out clears both cookie and native sessions.
|
||||
Older gateways retain the identified WebView cookie fallback.
|
||||
|
||||
Before release, device-test the real Custom Tab → provider → loopback return,
|
||||
configuration/background transitions, Manage reload, Gateway chat ticket,
|
||||
standard voice, sign-out, and process relaunch. Native bearer exchange remains
|
||||
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
|
||||
using the native flow.
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
@@ -78,6 +96,19 @@ intentionally remain outside that code batch:
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||
must not claim a profile-scoped write that upstream ignores.
|
||||
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||
provider inventory was built inside that profile's runtime scope. Android
|
||||
now keys picker results to its active profile context and rejects late
|
||||
responses after a profile switch, but it deliberately does not send an
|
||||
invented `profile` parameter. API-server fallback can use the separate,
|
||||
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
@@ -1170,6 +1201,7 @@ Follow-ups:
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
|
||||
@@ -245,6 +245,7 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -142,6 +142,21 @@ data class ChatMessage(
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.MoaReference
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -43,6 +43,9 @@ class ChatHandler {
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
internal const val MAX_MESSAGES = 500
|
||||
private const val MAX_MOA_REFERENCES = 32
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
|
||||
private fun timestampToMillis(timestamp: Double?): Long {
|
||||
val value = timestamp ?: return 0L
|
||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
||||
*/
|
||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Fired for a canonical `@image:<path>` directive found on a persisted
|
||||
* USER history row. This is intentionally separate from free-form
|
||||
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
|
||||
* can reach this callback.
|
||||
*/
|
||||
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
|
||||
{ _, _ -> }
|
||||
|
||||
/**
|
||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||
@@ -512,6 +524,42 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a provisional post-interim segment back into its sealed
|
||||
* assistant bubble when the terminal text proves they are one response.
|
||||
* Tool/card state accumulated after the interim remains attached.
|
||||
*/
|
||||
fun reconcileInterimMessage(
|
||||
interimMessageId: String,
|
||||
currentMessageId: String,
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
toolCalls = mergedTools,
|
||||
thinkingContent = current?.thinkingContent
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: interim.thinkingContent,
|
||||
isThinkingStreaming = current?.isThinkingStreaming
|
||||
?: interim.isThinkingStreaming,
|
||||
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
|
||||
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
|
||||
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
|
||||
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
@@ -973,6 +1021,27 @@ class ChatHandler {
|
||||
startedAt = task.startedAt,
|
||||
)
|
||||
}
|
||||
val checkpointMoaReferences = assistant.moaReferences
|
||||
.filter { it.index in 1..MAX_MOA_REFERENCES }
|
||||
.distinctBy { it.index }
|
||||
.sortedBy { it.index }
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
.map { reference ->
|
||||
MoaReference(
|
||||
index = reference.index,
|
||||
count = reference.count,
|
||||
label = reference.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (reference.available) {
|
||||
reference.text.take(MAX_MOA_REFERENCE_CHARS)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
available = reference.available,
|
||||
)
|
||||
}
|
||||
val restoredMoaReferences = currentAssistant?.moaReferences
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -996,6 +1065,7 @@ class ChatHandler {
|
||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||
?: assistant.cardDispatches,
|
||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||
moaReferences = restoredMoaReferences,
|
||||
)
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
@@ -1158,6 +1228,7 @@ class ChatHandler {
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
// mutateMessage lookups find the newly-loaded messages.
|
||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||
|
||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
||||
@@ -1212,7 +1283,8 @@ class ChatHandler {
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
displayKind == "async_delegation_complete" ||
|
||||
displayKind == "auto_continue" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
@@ -1250,12 +1322,18 @@ class ChatHandler {
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||
PersistedImageReferenceParser.parse(rawContent)
|
||||
} else {
|
||||
PersistedImageReferences(rawContent, emptyList())
|
||||
}
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
persistedImages.cleanedText
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
@@ -1293,7 +1371,14 @@ class ChatHandler {
|
||||
// content-keyed queue. Inbound attachments are intentionally
|
||||
// excluded — they come back via the marker re-dispatch.
|
||||
val carriedAttachments = run {
|
||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
||||
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||
attachment.relayToken == null ||
|
||||
(
|
||||
role == MessageRole.USER &&
|
||||
attachment.relayToken in persistedImagePaths
|
||||
)
|
||||
}
|
||||
when {
|
||||
byId.isNotEmpty() -> byId
|
||||
role == MessageRole.USER ->
|
||||
@@ -1301,6 +1386,15 @@ class ChatHandler {
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (
|
||||
role == MessageRole.USER &&
|
||||
carriedAttachments.isEmpty() &&
|
||||
persistedImages.paths.isNotEmpty()
|
||||
) {
|
||||
persistedImages.paths.forEach { path ->
|
||||
pendingPersistedUserImages += messageId to path
|
||||
}
|
||||
}
|
||||
// Server reasoning is authoritative when present; absent, keep the
|
||||
// live-streamed thinking rather than blanking it on reload.
|
||||
val serverThinking =
|
||||
@@ -1345,6 +1439,10 @@ class ChatHandler {
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
// Keep sanitized advisor state while reconciling a still-live
|
||||
// row, but clear it once completion made history authoritative.
|
||||
// The server transcript never becomes the source of these blocks.
|
||||
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1437,6 +1535,9 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
}
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
@@ -1505,7 +1606,7 @@ class ChatHandler {
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
@@ -1539,6 +1640,7 @@ class ChatHandler {
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
"auto_continue" -> "Continued after an interrupted turn"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1565,6 +1667,7 @@ class ChatHandler {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(t)
|
||||
@@ -1753,6 +1856,7 @@ class ChatHandler {
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
hasModelConfig = item.hasModelConfig,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
@@ -2593,6 +2697,44 @@ class ChatHandler {
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
|
||||
val message = messages[targetIndex]
|
||||
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||
val reference = MoaReference(
|
||||
index = nextIndex,
|
||||
count = event.count,
|
||||
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = event.available,
|
||||
)
|
||||
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||
val exactReplay = existingAtIndex == reference
|
||||
val base = if (nextIndex == 1 && !exactReplay) {
|
||||
emptyList()
|
||||
} else {
|
||||
message.moaReferences
|
||||
}
|
||||
if (exactReplay) {
|
||||
messages
|
||||
} else {
|
||||
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||
.sortedBy(MoaReference::index)
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
messages.toMutableList().also {
|
||||
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
|
||||
@@ -50,6 +50,7 @@ data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
@@ -158,6 +159,7 @@ data class DashboardCustomEndpointDraft(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
@@ -1136,23 +1138,42 @@ class DashboardApiClient(
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.models
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||
.map(::JsonPrimitive)
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("models", JsonArray(it)) }
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
bearerAuth: DashboardBearerAuth? = null,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
bearerAuth?.let {
|
||||
builder.addInterceptor(it)
|
||||
builder.authenticator(it)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
@@ -1180,6 +1201,9 @@ class DashboardApiClient(
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
},
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
@@ -1338,6 +1362,35 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||
* not sufficient authority to receive the active connection's token.
|
||||
*/
|
||||
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||
candidateUrl.host == trustedUrl.host &&
|
||||
candidateUrl.port == trustedUrl.port &&
|
||||
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||
candidateUrl.query == null &&
|
||||
trustedUrl.query == null
|
||||
}
|
||||
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
|
||||
+200
-13
@@ -164,6 +164,7 @@ class GatewayChatClient(
|
||||
private const val CONNECT_ATTEMPTS = 2
|
||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
|
||||
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
|
||||
|
||||
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||
private const val MAX_TURN_REJOINS = 4
|
||||
@@ -284,6 +285,14 @@ class GatewayChatClient(
|
||||
private val _serverYolo = MutableStateFlow<Boolean?>(null)
|
||||
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
|
||||
|
||||
private val _serverApprovalMode = MutableStateFlow<GatewayApprovalMode?>(null)
|
||||
val serverApprovalMode: StateFlow<GatewayApprovalMode?> = _serverApprovalMode.asStateFlow()
|
||||
|
||||
private val _approvalModeCapability =
|
||||
MutableStateFlow(GatewayApprovalModeCapability.Unknown)
|
||||
val approvalModeCapability: StateFlow<GatewayApprovalModeCapability> =
|
||||
_approvalModeCapability.asStateFlow()
|
||||
|
||||
private val _serverFast = MutableStateFlow<Boolean?>(null)
|
||||
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
|
||||
|
||||
@@ -362,6 +371,15 @@ class GatewayChatClient(
|
||||
@Volatile
|
||||
private var activeTurn: GatewayTurn? = null
|
||||
|
||||
private data class RecoveryEvent(
|
||||
val sessionId: String,
|
||||
val type: String,
|
||||
val payload: JsonObject?,
|
||||
)
|
||||
|
||||
private val recoveryEventLock = Any()
|
||||
private var recoveryEvents: MutableList<RecoveryEvent>? = null
|
||||
|
||||
/**
|
||||
* Turns deliberately detached when the user switches profile/session.
|
||||
* Upstream continues them server-side; retain the live→durable binding so
|
||||
@@ -820,24 +838,33 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
response = rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents = mutableListOf()
|
||||
}
|
||||
response = try {
|
||||
rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
val recoveredLiveId = response.stringField("session_id")
|
||||
?: run {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (!preferredLiveId.isNullOrBlank()) {
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
||||
}
|
||||
@@ -854,6 +881,9 @@ class GatewayChatClient(
|
||||
user = value.stringField("user").orEmpty(),
|
||||
assistant = value.stringField("assistant").orEmpty(),
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
status = value.stringField("status"),
|
||||
error = value.stringField("error"),
|
||||
recoverable = value.booleanField("recoverable") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
@@ -862,8 +892,20 @@ class GatewayChatClient(
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
val autoContinue = (response["auto_continue"] as? JsonObject)?.let { value ->
|
||||
val attempt = value.stringField("attempt")?.toIntOrNull()
|
||||
?: (value["attempt"] as? JsonPrimitive)?.intOrNull
|
||||
if (attempt != null && attempt > 0) {
|
||||
GatewayAutoContinue(
|
||||
attempt = attempt,
|
||||
interruptedAt = value.stringField("interrupted_at")?.toDoubleOrNull(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (running) {
|
||||
if (running || autoContinue != null) {
|
||||
if (boundTurn == null || boundTurn.ended) {
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
@@ -873,6 +915,13 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
val buffered = synchronized(recoveryEventLock) {
|
||||
recoveryEvents
|
||||
?.filter { it.sessionId == recoveredLiveId }
|
||||
.orEmpty()
|
||||
.also { recoveryEvents = null }
|
||||
}
|
||||
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
@@ -884,6 +933,7 @@ class GatewayChatClient(
|
||||
}
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
@@ -914,6 +964,7 @@ class GatewayChatClient(
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
@@ -929,6 +980,7 @@ class GatewayChatClient(
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
queued = queued,
|
||||
autoContinue = autoContinue,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
@@ -1377,6 +1429,84 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the profile-persisted approval policy added in gateway contract v3.
|
||||
* Older gateways either reject the key or return no recognized value; both
|
||||
* downgrade this optional control without affecting chat or per-session YOLO.
|
||||
*/
|
||||
suspend fun getApprovalMode(): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.get",
|
||||
buildJsonObject { put("key", "approvals.mode") },
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val mode = GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
return Result.success(mode)
|
||||
}
|
||||
|
||||
/** Persist the selected approval policy for the active gateway profile. */
|
||||
suspend fun setApprovalMode(mode: GatewayApprovalMode): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.set",
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", mode.wireValue)
|
||||
},
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val authoritative =
|
||||
GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = authoritative
|
||||
return Result.success(authoritative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
|
||||
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
|
||||
@@ -1452,6 +1582,7 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
||||
@@ -1541,6 +1672,14 @@ class GatewayChatClient(
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
*/
|
||||
private fun applySessionInfo(info: JsonObject) {
|
||||
val contract = (info["desktop_contract"] as? JsonPrimitive)?.intOrNull
|
||||
if (contract != null && contract < 3) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
GatewayApprovalMode.fromWire(info.stringField("approval_mode"))?.let { mode ->
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
}
|
||||
if (info.containsKey("personality")) {
|
||||
_serverPersonality.value =
|
||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||
@@ -1845,6 +1984,25 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// A cold session.resume may schedule auto-continue before its RPC
|
||||
// response reaches Android. The recovery buffer is an ownership gate,
|
||||
// not an observational copy: an event is either claimed here for
|
||||
// replay or routed live below, never both. The resume response drains
|
||||
// and closes the gate under this same lock, so later frames route live.
|
||||
// Already-owned sibling sessions retain their background routing.
|
||||
val claimedByRecovery = !eventSessionId.isNullOrBlank() &&
|
||||
!backgroundTurns.containsKey(eventSessionId) &&
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents?.let { buffered ->
|
||||
if (buffered.size >= MAX_RECOVERY_BUFFERED_EVENTS) {
|
||||
buffered.removeAt(0)
|
||||
}
|
||||
buffered += RecoveryEvent(eventSessionId, type, payload)
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
if (claimedByRecovery) return
|
||||
|
||||
// A profile/session switch may leave an upstream turn running while a
|
||||
// different profile becomes visible. Its events must never paint the
|
||||
// new transcript, but the terminal event still needs to reconcile the
|
||||
@@ -1916,7 +2074,7 @@ class GatewayChatClient(
|
||||
// `/personality`, desktop, or TUI change keeps the app in sync. Falls
|
||||
// through to the turn dispatch below so an in-flight turn still sees it.
|
||||
if (type == "session.info" &&
|
||||
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
|
||||
(eventSessionId == null || eventSessionId == liveSessionId)
|
||||
) {
|
||||
// Connection-level session info (model / provider / effort / persona /
|
||||
// yolo / fast / usage) — shared with the session.resume result via
|
||||
@@ -2046,6 +2204,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||
@@ -2224,6 +2383,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
|
||||
@@ -2705,6 +2865,9 @@ class GatewayChatClient(
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onInterimReconciled = { text ->
|
||||
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
@@ -2717,6 +2880,7 @@ class GatewayChatClient(
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||
@@ -2798,6 +2962,29 @@ private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
msg.contains("unknown method", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Throwable?.isApprovalModeUnsupported(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
val message = rpcError.message.orEmpty()
|
||||
return rpcError.code == JSONRPC_METHOD_NOT_FOUND ||
|
||||
rpcError.code == 4002 ||
|
||||
message.contains("approval mode", ignoreCase = true) &&
|
||||
(
|
||||
message.contains("unknown", ignoreCase = true) ||
|
||||
message.contains("unsupported", ignoreCase = true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun approvalModeUnsupported(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval modes are not supported by this gateway",
|
||||
JSONRPC_METHOD_NOT_FOUND,
|
||||
)
|
||||
|
||||
private fun approvalModeRequiresLaunchProfile(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval mode is read-only for multiplexed non-launch profiles",
|
||||
)
|
||||
|
||||
private fun JsonObject.stringField(key: String): String? =
|
||||
(get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class GatewayEventMapper(
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var moaStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
@@ -216,13 +217,18 @@ class GatewayEventMapper(
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||
val error = payload.string("error")
|
||||
val text = payload.string("text")
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview ->
|
||||
preview.isNotEmpty() &&
|
||||
(text.startsWith(preview) || preview.startsWith(text))
|
||||
} == true
|
||||
if (reconcilesInterim) {
|
||||
callbacks.onInterimReconciled(text)
|
||||
} else if (!text.isNullOrEmpty() &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
@@ -233,6 +239,12 @@ class GatewayEventMapper(
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
if (failed) {
|
||||
callbacks.onStatusUpdate(
|
||||
ERROR_STATUS_KIND,
|
||||
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||
)
|
||||
}
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
@@ -290,9 +302,57 @@ class GatewayEventMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// MoA activity proves auto-compaction has resumed even though
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
"moa.reference" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val text = payload.string("text")?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
val available = !isFailedMoaReference(text)
|
||||
callbacks.onMoaReference(
|
||||
GatewayMoaReference(
|
||||
index = payload.int("index")?.takeIf { it > 0 },
|
||||
count = payload.int("count")
|
||||
?.takeIf { it > 0 },
|
||||
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||
.ifBlank { "Advisor" },
|
||||
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = available,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"moa.progress" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
|
||||
"moa.phase" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
when (payload.string("phase")?.lowercase()) {
|
||||
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||
"reference", "references" -> {
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy phase marker retained by upstream for older consumers.
|
||||
"moa.aggregating" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
setMoaStatus("MoA: aggregating…")
|
||||
}
|
||||
|
||||
"tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -324,15 +384,31 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
private fun clearActivityStatuses() {
|
||||
clearProviderWaitAndCompaction()
|
||||
if (!moaStatusActive) return
|
||||
moaStatusActive = false
|
||||
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun clearProviderWaitAndCompaction() {
|
||||
clearProviderWaitStatus()
|
||||
if (!compactionStatusActive) return
|
||||
compactionStatusActive = false
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun setMoaStatus(text: String) {
|
||||
moaStatusActive = true
|
||||
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
const val MOA_STATUS_KIND = "moa"
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
@@ -348,6 +424,11 @@ class GatewayEventMapper(
|
||||
"error",
|
||||
)
|
||||
|
||||
internal fun isFailedMoaReference(text: String): Boolean {
|
||||
val normalized = text.trimStart().lowercase()
|
||||
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
|
||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
||||
Ready,
|
||||
}
|
||||
|
||||
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
|
||||
enum class GatewayApprovalMode(val wireValue: String) {
|
||||
Manual("manual"),
|
||||
Smart("smart"),
|
||||
Off("off");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
|
||||
"manual" -> Manual
|
||||
"smart" -> Smart
|
||||
"off" -> Off
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
|
||||
enum class GatewayApprovalModeCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||
* unsupported, or sign-in-required verdict.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
else -> if (
|
||||
gateway == GatewayAvailability.Ready ||
|
||||
gateway == GatewayAvailability.Unknown
|
||||
) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
@@ -95,6 +122,9 @@ data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val recoverable: Boolean = false,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
@@ -102,6 +132,12 @@ data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||
data class GatewayAutoContinue(
|
||||
val attempt: Int,
|
||||
val interruptedAt: Double?,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
@@ -120,10 +156,11 @@ data class GatewaySessionRecovery(
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
val autoContinue: GatewayAutoContinue? = null,
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
get() = running || queued != null || autoContinue != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
@@ -310,6 +347,14 @@ data class GatewayModelProvider(
|
||||
val totalModels: Int = 0,
|
||||
)
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
@@ -317,6 +362,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean =
|
||||
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
/**
|
||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||
@@ -374,6 +428,12 @@ class GatewayTurnCallbacks(
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||
* existing segment should be replaced in place instead of opening a second
|
||||
* assistant bubble.
|
||||
*/
|
||||
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -398,6 +458,8 @@ class GatewayTurnCallbacks(
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -77,6 +78,10 @@ data class ServerCapabilities(
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||
val modelOptions: Boolean = false,
|
||||
/** Backend-acknowledged per-session model lock. */
|
||||
val sessionModelLock: Boolean = false,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
@@ -100,6 +105,8 @@ data class ServerCapabilities(
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
modelOptions = false,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,6 +199,129 @@ data class ApiModelOption(
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||
data class ApiProviderModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
internal fun parseApiProviderModelOptionsBody(
|
||||
json: Json,
|
||||
body: String,
|
||||
): ApiProviderModelOptions? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
enum class ApiModelRoutingErrorCode {
|
||||
INVENTORY_UNSUPPORTED,
|
||||
INVENTORY_UNAVAILABLE,
|
||||
PROVIDER_NOT_AUTHENTICATED,
|
||||
MODEL_NOT_AVAILABLE,
|
||||
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
LOCK_CAPABILITY_INCOMPLETE,
|
||||
LOCK_REJECTED,
|
||||
LOCK_ACK_MISMATCH,
|
||||
LEGACY_PROVIDER_UNSUPPORTED,
|
||||
}
|
||||
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
data class Locked(
|
||||
val sessionId: String,
|
||||
val model: String,
|
||||
val provider: String?,
|
||||
val effectiveModel: String = model,
|
||||
val effectiveProvider: String? = provider,
|
||||
) : ApiModelSelectionAck
|
||||
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||
}
|
||||
|
||||
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||
|
||||
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||
when {
|
||||
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||
ApiModelRoutingStrategy.LOCKED
|
||||
capabilities.sessionModelLock ->
|
||||
ApiModelRoutingStrategy.INCOMPLETE
|
||||
else ->
|
||||
ApiModelRoutingStrategy.LEGACY_HINT
|
||||
}
|
||||
|
||||
internal fun sessionTurnModelHint(
|
||||
acknowledgement: ApiModelSelectionAck,
|
||||
requestedModel: String?,
|
||||
): String? =
|
||||
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||
|
||||
internal data class ParsedApiModelLockAck(
|
||||
val sessionId: String?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val state: String?,
|
||||
val effectiveModel: String?,
|
||||
val effectiveProvider: String?,
|
||||
)
|
||||
|
||||
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||
val requested = runtime["requested"] as? JsonObject
|
||||
val effective = runtime["effective"] as? JsonObject
|
||||
return ParsedApiModelLockAck(
|
||||
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun confirmedRuntimeMatches(
|
||||
runtime: JsonObject?,
|
||||
expected: ApiModelSelectionAck.Locked,
|
||||
): Boolean {
|
||||
runtime ?: return false
|
||||
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
@@ -315,6 +447,8 @@ class HermesApiClient(
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
@@ -634,6 +768,205 @@ class HermesApiClient(
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||
suspend fun getProviderModelOptions(
|
||||
refresh: Boolean = false,
|
||||
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val suffix = if (refresh) "?refresh=true" else ""
|
||||
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
if (response.code == 404) {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
} else {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||
},
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
"Model inventory authorization failed (HTTP ${response.code})."
|
||||
} else {
|
||||
"Model inventory unavailable (HTTP ${response.code})."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||
?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory returned an invalid response.",
|
||||
),
|
||||
)
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and, on capable servers, persist a model/provider lock before a
|
||||
* session turn is submitted. This never writes global config.
|
||||
*/
|
||||
suspend fun acknowledgeSessionModelSelection(
|
||||
sessionId: String,
|
||||
model: String?,
|
||||
provider: String?,
|
||||
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||
val selectedModel = AgentDisplay.requestModelName(model)
|
||||
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// Capability snapshots can be populated by a disconnected startup
|
||||
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||
// false forever after the connection recovers.
|
||||
val capabilities = probeCapabilities()
|
||||
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||
val inventory = getProviderModelOptions().getOrElse {
|
||||
return@withContext Result.failure(it)
|
||||
}
|
||||
val aliases = getModelOptions()
|
||||
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val providerModel = selectedRoot ?: selectedModel
|
||||
val providerRow = when {
|
||||
selectedProvider != null ->
|
||||
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||
?: inventory.providers.firstOrNull {
|
||||
it.isCurrent && providerModel in it.models
|
||||
}
|
||||
} ?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not in the API server's authenticated inventory.",
|
||||
),
|
||||
)
|
||||
if (!providerRow.authenticated) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||
"The selected provider is not authenticated on this profile.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel !in providerRow.models) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not available from ${providerRow.name}.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel in providerRow.unavailableModels) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
"The selected model is not available on the authenticated account.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val body = kotlinx.serialization.json.buildJsonObject {
|
||||
put("model", selectedModel)
|
||||
put("provider", providerRow.slug)
|
||||
}
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
streamHttpFailureMessage(
|
||||
response.code,
|
||||
response.message,
|
||||
response.header("Retry-After"),
|
||||
responseBody,
|
||||
json,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val ack = parseApiModelLockAck(json, responseBody)
|
||||
if (
|
||||
ack?.sessionId != sessionId ||
|
||||
ack?.model != selectedModel ||
|
||||
ack?.provider != providerRow.slug ||
|
||||
ack?.state != "accepted" ||
|
||||
ack?.effectiveModel.isNullOrBlank() ||
|
||||
ack?.effectiveProvider.isNullOrBlank()
|
||||
) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||
"Server did not acknowledge the requested model lock.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val confirmedAck = requireNotNull(ack)
|
||||
Result.success(
|
||||
ApiModelSelectionAck.Locked(
|
||||
sessionId = sessionId,
|
||||
model = selectedModel,
|
||||
provider = providerRow.slug,
|
||||
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||
effectiveProvider = confirmedAck.effectiveProvider,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
"Model lock request failed before the message was sent.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||
"Server advertises an incomplete model-routing contract.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (selectedProvider != null) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val advertised = getModelOptions().map { it.id }
|
||||
if (selectedModel !in advertised) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"This Hermes version did not advertise the selected model for API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -756,6 +1089,7 @@ class HermesApiClient(
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
@@ -786,6 +1120,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
@@ -806,7 +1141,13 @@ class HermesApiClient(
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -882,9 +1223,17 @@ class HermesApiClient(
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
}
|
||||
} else if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
@@ -896,9 +1245,15 @@ class HermesApiClient(
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
} else if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
@@ -908,7 +1263,13 @@ class HermesApiClient(
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
@@ -987,7 +1348,13 @@ class HermesApiClient(
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server closed the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1897,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
if (!healthy) {
|
||||
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||
return@withContext ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
@@ -1544,7 +1914,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
if (advertisedCapabilities != null) {
|
||||
lastCapabilities = advertisedCapabilities
|
||||
return@withContext advertisedCapabilities
|
||||
}
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
@@ -1589,7 +1962,7 @@ class HermesApiClient(
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
).also { lastCapabilities = it }
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
* `instructions`, string only). Newer servers also parse per-request model
|
||||
* fields and reuse a backend-acknowledged session model lock when those
|
||||
* fields are omitted. Android therefore acknowledges a lock first and
|
||||
* omits `model` on that turn; the builder's model field remains only for
|
||||
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||
* `profile` are not parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
@Serializable
|
||||
data class NativeDashboardTokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String = "",
|
||||
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||
val provider: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
)
|
||||
|
||||
interface NativeDashboardTokenStore {
|
||||
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||
val coordinationKey: String
|
||||
fun load(): NativeDashboardTokens?
|
||||
fun save(tokens: NativeDashboardTokens)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||
NativeTokenRefreshCoordinator.clear(store)
|
||||
}
|
||||
|
||||
class EncryptedNativeDashboardTokenStore(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = tokenStoreKey
|
||||
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||
* Compose/SavedState UI state.
|
||||
*/
|
||||
class NativeDashboardAuthorization internal constructor(
|
||||
val authorizationUrl: String,
|
||||
internal val verifier: String,
|
||||
internal val state: String,
|
||||
internal val generation: Long,
|
||||
)
|
||||
|
||||
class NativeDashboardAuthClient(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
) {
|
||||
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||
|
||||
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||
NATIVE_PKCE_FLOW in status.authFlows
|
||||
|
||||
fun beginAuthorization(
|
||||
redirectUri: String,
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
val verifier = randomBytes(32).base64Url()
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
val state = randomBytes(24).base64Url()
|
||||
val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val callback = callbackTarget.toHttpUrlOrNull()
|
||||
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||
throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not use the expected loopback path",
|
||||
)
|
||||
}
|
||||
if (callback.queryParameter("state") != authorization.state) {
|
||||
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||
}
|
||||
callback.queryParameter("error")?.let {
|
||||
throw NativeDashboardCallbackException(
|
||||
message = "Gateway rejected native sign-in",
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val code = callback.queryParameter("code")
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not include an authorization code",
|
||||
)
|
||||
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||
return postTokens(
|
||||
path = "/auth/native/token",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = authorization.generation,
|
||||
commitAllowed = commitAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
authorization.generation,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearStoredSession() {
|
||||
clearNativeDashboardTokens(tokenStore)
|
||||
}
|
||||
|
||||
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load()
|
||||
?: tokens
|
||||
?: throw IOException("No native dashboard session is stored")
|
||||
// A sibling client may already have rotated the single-use refresh
|
||||
// token while this caller was waiting. Adopt that winner instead
|
||||
// of replaying the stale token.
|
||||
if (tokens != null && current != tokens) return@synchronized current
|
||||
if (current.refreshToken.isBlank()) {
|
||||
clearIfUnchanged(current)
|
||||
throw IOException("Native dashboard session cannot be refreshed")
|
||||
}
|
||||
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
try {
|
||||
postTokens(
|
||||
path = "/auth/native/refresh",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = generation,
|
||||
)
|
||||
} catch (error: NativeDashboardAuthHttpException) {
|
||||
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||
clearIfUnchanged(current)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postTokens(
|
||||
path: String,
|
||||
payload: String,
|
||||
clearOnAuthFailure: Boolean,
|
||||
expectedGeneration: Long,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val tokens = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||
tokenStore.clear()
|
||||
}
|
||||
throw NativeDashboardAuthHttpException(response.code)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||
.also {
|
||||
if (it.accessToken.isBlank()) {
|
||||
throw IOException("Dashboard token response did not include an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
if (!commitAllowed() ||
|
||||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||
expectedGeneration
|
||||
) {
|
||||
throw IOException("Dashboard sign-in is no longer active")
|
||||
}
|
||||
tokenStore.save(tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||
|
||||
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||
if (tokenStore.load() == expected) tokenStore.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||
val url = redirectUri.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||
"Native redirect must use the 127.0.0.1 loopback address"
|
||||
}
|
||||
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class NativeDashboardCallbackException(
|
||||
message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(url.scheme == "http" && url.host == "127.0.0.1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||
* stale bearer nor the authenticator can recurse into token rotation.
|
||||
*/
|
||||
class DashboardBearerAuth(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||
) : Interceptor, Authenticator {
|
||||
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val previous = response.request.header("Authorization") ?: return null
|
||||
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||
val tokens = usableTokens(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
forceRefresh: Boolean,
|
||||
failedAccessToken: String?,
|
||||
): NativeDashboardTokens? =
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load() ?: return@synchronized null
|
||||
// A request can receive its 401 after another client already
|
||||
// rotated the token. Retry with the winner; do not rotate again.
|
||||
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||
return@synchronized current
|
||||
}
|
||||
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||
runCatching { authClient.refresh(current) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
private object NativeTokenRefreshCoordinator {
|
||||
private val locks = ConcurrentHashMap<String, Any>()
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||
|
||||
fun currentGeneration(key: String): Long =
|
||||
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||
|
||||
fun beginAuthorization(key: String): Long =
|
||||
synchronized(lockFor(key)) {
|
||||
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||
}
|
||||
|
||||
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||
synchronized(lockFor(key)) {
|
||||
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||
generations[key] = expectedGeneration + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(store: NativeDashboardTokenStore) {
|
||||
synchronized(lockFor(store.coordinationKey)) {
|
||||
generations[store.coordinationKey] =
|
||||
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenExchange(
|
||||
val code: String,
|
||||
@SerialName("code_verifier") val codeVerifier: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenRefresh(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
val provider: String,
|
||||
)
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
private const val ACCEPT_POLL_MILLIS = 500
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||
|
||||
internal enum class DashboardRedirectAuthMode {
|
||||
NativePkce,
|
||||
WebView,
|
||||
}
|
||||
|
||||
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
|
||||
if ("native_pkce" in authFlows) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one native dashboard sign-in attempt.
|
||||
*
|
||||
* The listener and PKCE authorization are both local to [signIn], so leaving
|
||||
* the screen, cancellation, timeout, or callback completion closes the port
|
||||
* and discards verifier/state. Nothing secret enters Compose or saved state.
|
||||
*/
|
||||
class NativeDashboardSignInCoordinator(
|
||||
private val authClient: NativeDashboardAuthClient,
|
||||
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
|
||||
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
|
||||
) {
|
||||
suspend fun signIn(
|
||||
provider: String?,
|
||||
launchAuthorization: suspend (String) -> Unit,
|
||||
): NativeDashboardTokens =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMillis) {
|
||||
serverSocketFactory().use { server ->
|
||||
server.reuseAddress = false
|
||||
server.bind(
|
||||
InetSocketAddress(
|
||||
InetAddress.getByName("127.0.0.1"),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
)
|
||||
server.soTimeout = ACCEPT_POLL_MILLIS
|
||||
check(server.inetAddress.hostAddress == "127.0.0.1") {
|
||||
"Native sign-in listener did not bind to IPv4 loopback"
|
||||
}
|
||||
|
||||
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
|
||||
val authorization = authClient.beginAuthorization(redirectUri, provider)
|
||||
val attemptContext = currentCoroutineContext()
|
||||
var completed = false
|
||||
try {
|
||||
launchAuthorization(authorization.authorizationUrl)
|
||||
awaitValidCallback(
|
||||
server = server,
|
||||
authorization = authorization,
|
||||
commitAllowed = { attemptContext.isActive },
|
||||
).also { completed = true }
|
||||
} finally {
|
||||
if (!completed) {
|
||||
authClient.cancelAuthorization(authorization)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw IOException("Dashboard sign-in timed out")
|
||||
}
|
||||
|
||||
private suspend fun awaitValidCallback(
|
||||
server: ServerSocket,
|
||||
authorization: NativeDashboardAuthorization,
|
||||
commitAllowed: () -> Boolean,
|
||||
): NativeDashboardTokens {
|
||||
while (true) {
|
||||
val callback = acceptCallback(server)
|
||||
val tokens = callback.use { socket ->
|
||||
if (socket.inetAddress.hostAddress != "127.0.0.1") {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "403 Forbidden",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
val target = try {
|
||||
readCallbackTarget(
|
||||
input = socket.getInputStream(),
|
||||
expectedPort = server.localPort,
|
||||
)
|
||||
} catch (_: IOException) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
try {
|
||||
authClient.exchangeCallback(
|
||||
authorization,
|
||||
target,
|
||||
commitAllowed = commitAllowed,
|
||||
).also {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "200 OK",
|
||||
body = "Sign-in complete. You can return to Hermes Relay.",
|
||||
)
|
||||
}
|
||||
} catch (error: NativeDashboardCallbackException) {
|
||||
if (error.retryable) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (tokens != null) return tokens
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acceptCallback(server: ServerSocket): Socket {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
try {
|
||||
return server.accept().apply { soTimeout = 5_000 }
|
||||
} catch (_: SocketTimeoutException) {
|
||||
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
|
||||
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
|
||||
?: throw IOException("Native sign-in callback was empty")
|
||||
val requestParts = requestLine.split(' ')
|
||||
if (requestParts.size != 3 || requestParts[0] != "GET" ||
|
||||
!requestParts[1].startsWith("/") ||
|
||||
!requestParts[2].startsWith("HTTP/1.")
|
||||
) {
|
||||
throw IOException("Native sign-in callback request was malformed")
|
||||
}
|
||||
|
||||
var headerBytes = 0
|
||||
var host: String? = null
|
||||
while (true) {
|
||||
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
|
||||
?: throw IOException("Native sign-in callback headers were incomplete")
|
||||
headerBytes += line.length + 2
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Host:", ignoreCase = true)) {
|
||||
host = line.substringAfter(':').trim()
|
||||
}
|
||||
if (headerBytes >= MAX_HEADER_BYTES) {
|
||||
throw IOException("Native sign-in callback headers were too large")
|
||||
}
|
||||
}
|
||||
if (host != "127.0.0.1:$expectedPort") {
|
||||
throw IOException("Native sign-in callback host was not accepted")
|
||||
}
|
||||
return requestParts[1]
|
||||
}
|
||||
|
||||
private fun readAsciiLine(input: InputStream, limit: Int): String? {
|
||||
if (limit <= 0) throw IOException("Native sign-in callback was too large")
|
||||
val bytes = ArrayList<Byte>(minOf(limit, 128))
|
||||
var previous = -1
|
||||
while (bytes.size < limit) {
|
||||
val current = input.read()
|
||||
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
|
||||
"Native sign-in callback ended unexpectedly",
|
||||
)
|
||||
if (previous == '\r'.code && current == '\n'.code) {
|
||||
bytes.removeAt(bytes.lastIndex)
|
||||
return bytes.toByteArray().toString(Charsets.US_ASCII)
|
||||
}
|
||||
bytes += current.toByte()
|
||||
previous = current
|
||||
}
|
||||
throw IOException("Native sign-in callback line was too large")
|
||||
}
|
||||
|
||||
private fun writeResponse(socket: Socket, status: String, body: String) {
|
||||
val html = """
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body><p>${escapeHtml(body)}</p></body></html>
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
val headers = buildString {
|
||||
append("HTTP/1.1 ").append(status).append("\r\n")
|
||||
append("Content-Type: text/html; charset=utf-8\r\n")
|
||||
append("Content-Length: ").append(html.size).append("\r\n")
|
||||
append("Cache-Control: no-store\r\n")
|
||||
append("Connection: close\r\n\r\n")
|
||||
}.toByteArray(Charsets.US_ASCII)
|
||||
runCatching {
|
||||
socket.getOutputStream().apply {
|
||||
write(headers)
|
||||
write(html)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeHtml(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/**
|
||||
* Parsed form of upstream's persisted user-image directives.
|
||||
*
|
||||
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
|
||||
* Unknown or malformed directives stay visible as text. Valid directives are
|
||||
* removed from the bubble so a host-local path is never exposed in the UI.
|
||||
*/
|
||||
internal data class PersistedImageReferences(
|
||||
val cleanedText: String,
|
||||
val paths: List<String>,
|
||||
)
|
||||
|
||||
internal object PersistedImageReferenceParser {
|
||||
private const val MAX_INPUT_CHARS = 256 * 1024
|
||||
private const val MAX_PATH_CHARS = 2_048
|
||||
private const val MAX_ATTACHMENTS = 8
|
||||
|
||||
private val imageExtensions = setOf(
|
||||
"avif",
|
||||
"bmp",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
)
|
||||
|
||||
fun parse(content: String): PersistedImageReferences {
|
||||
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
|
||||
return PersistedImageReferences(content, emptyList())
|
||||
}
|
||||
|
||||
val keptLines = ArrayList<String>()
|
||||
val paths = ArrayList<String>()
|
||||
|
||||
for (line in content.lines()) {
|
||||
val path = parseDirectiveLine(line)
|
||||
if (path == null) {
|
||||
keptLines += line
|
||||
} else if (paths.size < MAX_ATTACHMENTS) {
|
||||
paths += path
|
||||
}
|
||||
// Recognized refs beyond the attachment cap are still removed:
|
||||
// exposing a server-local path is worse than omitting an excessive
|
||||
// attachment from a deliberately bounded gallery.
|
||||
}
|
||||
|
||||
return PersistedImageReferences(
|
||||
cleanedText = keptLines.joinToString("\n").trim(),
|
||||
paths = paths,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDirectiveLine(line: String): String? {
|
||||
if (!line.startsWith("@image:")) return null
|
||||
val rawValue = line.removePrefix("@image:")
|
||||
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
|
||||
|
||||
val path = unwrapCanonicalValue(rawValue) ?: return null
|
||||
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
|
||||
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun unwrapCanonicalValue(value: String): String? {
|
||||
val first = value.first()
|
||||
if (first !in charArrayOf('`', '"', '\'')) {
|
||||
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
|
||||
}
|
||||
if (value.length < 3 || value.last() != first) return null
|
||||
val inner = value.substring(1, value.lastIndex)
|
||||
return inner.takeIf { first !in it }
|
||||
}
|
||||
|
||||
private fun isAbsolutePath(path: String): Boolean =
|
||||
path.startsWith("/") ||
|
||||
(
|
||||
path.length >= 3 &&
|
||||
path[0].isLetter() &&
|
||||
path[1] == ':' &&
|
||||
(path[2] == '\\' || path[2] == '/')
|
||||
)
|
||||
|
||||
private fun hasImageExtension(path: String): Boolean {
|
||||
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -41,15 +41,15 @@ import java.util.concurrent.TimeUnit
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||
* must carry the same exact-origin cookie or native bearer session used by
|
||||
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||
@@ -66,12 +66,10 @@ class StandardHermesVoiceClient(
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
@@ -102,7 +100,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
@@ -116,6 +114,7 @@ class StandardHermesVoiceClient(
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
@@ -138,7 +137,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
@@ -162,13 +161,14 @@ class StandardHermesVoiceClient(
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val callClient = callClient(baseUrl)
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
@@ -195,7 +195,14 @@ class StandardHermesVoiceClient(
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
private fun callClient(baseUrl: String): OkHttpClient =
|
||||
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||
|
||||
private fun executeJson(
|
||||
request: Request,
|
||||
operation: String,
|
||||
callClient: OkHttpClient,
|
||||
): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
+5
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val resolvedLastActivity: Double?
|
||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||
@@ -391,7 +392,9 @@ data class HermesSseEvent(
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
val usage: UsageInfo? = null,
|
||||
// Native session routing proof on run.started and terminal events.
|
||||
val runtime: JsonObject? = null,
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
|
||||
@@ -154,7 +154,6 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
@@ -599,15 +598,9 @@ fun RelayApp() {
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.cookieJar(
|
||||
DynamicDashboardCookieJar {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
)
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
dashboardHttpClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||
},
|
||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||
// Live read (null for the default profile) — sent defensively on
|
||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
||||
@@ -1476,10 +1469,10 @@ fun RelayApp() {
|
||||
// The VM's cached per-connection store — the prewarm must NOT
|
||||
// construct its own (each instance lazily pays a multi-second
|
||||
// Keystore keyset build under a process-global Tink lock).
|
||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
||||
?: return@LaunchedEffect
|
||||
prewarmDashboardManage(
|
||||
cookieStore = cookieStore,
|
||||
clientFactory = {
|
||||
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||
},
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
@@ -2301,8 +2294,8 @@ fun RelayApp() {
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
dashboardUrl = voiceDashboardUrl,
|
||||
dashboardCookieStoreProvider = {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
dashboardClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||
},
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
@@ -2640,6 +2633,17 @@ fun RelayApp() {
|
||||
onNavigateToRealtimeVoice = {
|
||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
||||
},
|
||||
onNavigateToImageGenerationLab = {
|
||||
terminalAppContext.startActivity(
|
||||
android.content.Intent().apply {
|
||||
setClassName(
|
||||
terminalAppContext,
|
||||
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.RealtimeVoiceTest.route) {
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
|
||||
/**
|
||||
* Stable, presentation-only summary for a message's attachment group.
|
||||
*
|
||||
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
|
||||
* by the existing attachment pipeline; this helper only chooses the compact
|
||||
* label shown while that pipeline is folded away.
|
||||
*/
|
||||
internal data class AttachmentGroupSummary(
|
||||
val count: Int,
|
||||
val firstName: String?,
|
||||
val firstType: AttachmentRenderMode,
|
||||
val remainingCount: Int,
|
||||
)
|
||||
|
||||
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
|
||||
val first = attachments.firstOrNull() ?: return null
|
||||
return AttachmentGroupSummary(
|
||||
count = attachments.size,
|
||||
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
|
||||
firstType = first.renderMode,
|
||||
remainingCount = (attachments.size - 1).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack-style disclosure for all attachments belonging to one message.
|
||||
*
|
||||
* The fold state is saveable and keyed by the message's stable Compose
|
||||
* identity, so attachment lifecycle updates do not unexpectedly reopen a
|
||||
* group the user collapsed. The compact header always remains available,
|
||||
* making preview, retry, download, and file actions recoverable with one tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CollapsibleAttachmentGroup(
|
||||
messageKey: String,
|
||||
attachments: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val summary = attachmentGroupSummary(attachments) ?: return
|
||||
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
|
||||
val stateLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
|
||||
)
|
||||
val actionLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
|
||||
)
|
||||
val typeLabel = stringResource(summary.firstType.labelResource())
|
||||
val detail = when {
|
||||
summary.firstName != null && summary.remainingCount > 0 ->
|
||||
stringResource(
|
||||
R.string.attachment_group_named_more,
|
||||
summary.firstName,
|
||||
typeLabel,
|
||||
summary.remainingCount,
|
||||
)
|
||||
summary.firstName != null ->
|
||||
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
|
||||
summary.remainingCount > 0 ->
|
||||
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
|
||||
else -> typeLabel
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-toggle-$messageKey")
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = actionLabel
|
||||
role = Role.Button
|
||||
stateDescription = stateLabel
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.attachment_group_count,
|
||||
summary.count,
|
||||
summary.count,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-content-$messageKey"),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
|
||||
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
|
||||
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
|
||||
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
|
||||
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
|
||||
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
|
||||
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
|
||||
}
|
||||
@@ -86,6 +86,8 @@ import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
@@ -676,6 +678,11 @@ fun AgentInfoSheet(
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
val approvalMode by chatViewModel.approvalMode.collectAsState()
|
||||
val approvalModeCapability by chatViewModel.approvalModeCapability.collectAsState()
|
||||
val approvalModeWritable by chatViewModel.approvalModeWritable.collectAsState()
|
||||
val approvalModeReadOnlyForProfile by
|
||||
chatViewModel.approvalModeReadOnlyForProfile.collectAsState()
|
||||
val fastEnabled by chatViewModel.fastEnabled.collectAsState()
|
||||
// YOLO / Fast are gateway-only. This says whether the gateway is present (or
|
||||
// still being probed) so we can SHOW those controls — present-but-loading
|
||||
@@ -688,6 +695,7 @@ fun AgentInfoSheet(
|
||||
// Pull the gateway's curated provider/model list (model.options) when the
|
||||
// sheet opens — the real switchable models, grouped by provider.
|
||||
LaunchedEffect(Unit) { chatViewModel.refreshModelOptions() }
|
||||
LaunchedEffect(Unit) { chatViewModel.refreshApprovalMode() }
|
||||
// Re-pull server-supplied personalities (list + default + active) on open so
|
||||
// a server-side change shows without an app reload.
|
||||
LaunchedEffect(Unit) { chatViewModel.refreshPersonalities() }
|
||||
@@ -803,7 +811,6 @@ fun AgentInfoSheet(
|
||||
val switchedToProfileSoulToast = stringResource(R.string.conn_info_switched_to_profile_soul)
|
||||
val personalityClearedToast = stringResource(R.string.conn_info_personality_cleared)
|
||||
val personalityToast = stringResource(R.string.conn_info_personality)
|
||||
val usingServerDefaultModelToast = stringResource(R.string.conn_info_using_server_default_model)
|
||||
val modelToast = stringResource(R.string.conn_info_model)
|
||||
val switchedToConnectionToast = stringResource(R.string.conn_info_switched_to_connection)
|
||||
val copyPairingCodeDesc = stringResource(R.string.conn_info_copy_pairing_code)
|
||||
@@ -1327,7 +1334,6 @@ fun AgentInfoSheet(
|
||||
onSelect = {
|
||||
if (selectedModelOverride != null) {
|
||||
chatViewModel.selectModel(null)
|
||||
toast(usingServerDefaultModelToast)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1365,6 +1371,21 @@ fun AgentInfoSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model.id) {
|
||||
chatViewModel.selectApiModel(model.id)
|
||||
toast(modelToast.format(model.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
@@ -1396,8 +1417,25 @@ fun AgentInfoSheet(
|
||||
val gatewayUnavailableApiServer = stringResource(R.string.conn_info_gateway_unavailable_api_server)
|
||||
val gatewayUnavailableSignIn = stringResource(R.string.conn_info_gateway_unavailable_sign_in)
|
||||
val safetySpeedTitle = stringResource(R.string.conn_info_safety_speed_title)
|
||||
val approvalModeTitle = stringResource(R.string.conn_info_approval_mode_title)
|
||||
val approvalModeDesc = stringResource(R.string.conn_info_approval_mode_desc)
|
||||
val approvalModeUnsupported =
|
||||
stringResource(R.string.conn_info_approval_mode_unsupported)
|
||||
val approvalModeProfileReadOnly =
|
||||
stringResource(R.string.conn_info_approval_mode_profile_read_only)
|
||||
val approvalManual = stringResource(R.string.conn_info_approval_mode_manual)
|
||||
val approvalManualDesc =
|
||||
stringResource(R.string.conn_info_approval_mode_manual_desc)
|
||||
val approvalSmart = stringResource(R.string.conn_info_approval_mode_smart)
|
||||
val approvalSmartDesc =
|
||||
stringResource(R.string.conn_info_approval_mode_smart_desc)
|
||||
val approvalOff = stringResource(R.string.conn_info_approval_mode_off)
|
||||
val approvalOffDesc =
|
||||
stringResource(R.string.conn_info_approval_mode_off_desc)
|
||||
val yoloModeTitle = stringResource(R.string.conn_info_yolo_mode_title)
|
||||
val yoloModeDesc = stringResource(R.string.conn_info_yolo_mode_desc)
|
||||
val yoloModeDesc = stringResource(R.string.conn_info_yolo_mode_desc_ephemeral)
|
||||
val yoloModeProfileOff =
|
||||
stringResource(R.string.conn_info_yolo_mode_profile_off)
|
||||
val approvalsOff = stringResource(R.string.conn_info_approvals_off)
|
||||
val fastModeTitle = stringResource(R.string.conn_info_fast_mode_title)
|
||||
val fastModeDesc = stringResource(R.string.conn_info_fast_mode_desc)
|
||||
@@ -1426,6 +1464,78 @@ fun AgentInfoSheet(
|
||||
)
|
||||
}
|
||||
|
||||
Text(approvalModeTitle, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = approvalModeDesc,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (approvalModeReadOnlyForProfile) {
|
||||
Text(
|
||||
text = approvalModeProfileReadOnly,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
when (approvalModeCapability) {
|
||||
GatewayApprovalModeCapability.Unknown -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.conn_info_checking),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
GatewayApprovalModeCapability.Unsupported -> {
|
||||
Text(
|
||||
text = approvalModeUnsupported,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
GatewayApprovalModeCapability.Supported -> {
|
||||
listOf(
|
||||
Triple(
|
||||
GatewayApprovalMode.Manual,
|
||||
approvalManual,
|
||||
approvalManualDesc,
|
||||
),
|
||||
Triple(
|
||||
GatewayApprovalMode.Smart,
|
||||
approvalSmart,
|
||||
approvalSmartDesc,
|
||||
),
|
||||
Triple(
|
||||
GatewayApprovalMode.Off,
|
||||
approvalOff,
|
||||
approvalOffDesc,
|
||||
),
|
||||
).forEach { (mode, label, description) ->
|
||||
ProfileRadioRow(
|
||||
primary = label,
|
||||
secondary = description,
|
||||
selected = approvalMode == mode,
|
||||
enabled =
|
||||
gatewayControlsAvailable &&
|
||||
approvalModeWritable &&
|
||||
!isStreaming,
|
||||
onSelect = {
|
||||
if (approvalMode != mode) {
|
||||
chatViewModel.setApprovalMode(mode)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// YOLO — bypasses command approvals. On-state is loud.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1435,7 +1545,11 @@ fun AgentInfoSheet(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(yoloModeTitle, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = yoloModeDesc,
|
||||
text = if (approvalMode == GatewayApprovalMode.Off) {
|
||||
yoloModeProfileOff
|
||||
} else {
|
||||
yoloModeDesc
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (yoloEnabled == true) {
|
||||
MaterialTheme.colorScheme.error
|
||||
@@ -1448,7 +1562,7 @@ fun AgentInfoSheet(
|
||||
available = gatewayControlsAvailable,
|
||||
gatewayReady = gatewayReady,
|
||||
value = yoloEnabled,
|
||||
enabled = !isStreaming,
|
||||
enabled = !isStreaming && approvalMode != GatewayApprovalMode.Off,
|
||||
label = "agentSheetYolo",
|
||||
onChange = { chatViewModel.setYolo(it) },
|
||||
)
|
||||
|
||||
+918
-11
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -139,6 +138,8 @@ fun MessageBubble(
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
imageGenerationStylePreference: String = "rotate",
|
||||
imageGenerationRotationIndex: Int = 0,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -211,6 +212,23 @@ fun MessageBubble(
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val hasImageGenerationCall = remember(message.toolCalls) {
|
||||
message.toolCalls.any {
|
||||
it.name.trim().lowercase() == "image_generate"
|
||||
}
|
||||
}
|
||||
val imageGenerationStartMillis = remember(message.toolCalls) {
|
||||
imageGenerationStartedAt(message.toolCalls)
|
||||
}
|
||||
val imageGenerationVisualStyle = remember(
|
||||
imageGenerationStylePreference,
|
||||
imageGenerationRotationIndex,
|
||||
) {
|
||||
resolveImageGenerationVisualStyle(
|
||||
preference = imageGenerationStylePreference,
|
||||
rotationIndex = imageGenerationRotationIndex,
|
||||
)
|
||||
}
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -297,6 +315,30 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && showThinking) {
|
||||
message.moaReferences.forEach { reference ->
|
||||
ThinkingBlock(
|
||||
thinkingContent = if (reference.available) {
|
||||
reference.text
|
||||
} else {
|
||||
"Advisor unavailable."
|
||||
},
|
||||
isStreaming = false,
|
||||
headerText = buildString {
|
||||
append("Advisor ")
|
||||
append(reference.index)
|
||||
reference.count?.let { append("/").append(it) }
|
||||
append(" · ")
|
||||
append(reference.label)
|
||||
},
|
||||
accessibilityLabel = "Mixture of Agents advisor response",
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message bubble.
|
||||
//
|
||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||
@@ -485,44 +527,78 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
// Image generation owns the bubble's progress slot. Keep the
|
||||
// selected progress treatment mounted under the real result,
|
||||
// then reveal the same collapsible attachment surface without
|
||||
// rebuilding the surrounding message bubble.
|
||||
if (hasImageGenerationCall) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationResultTransition(
|
||||
generating = showImageGeneration,
|
||||
startedAtMillis = imageGenerationStartMillis,
|
||||
visualStyle = imageGenerationVisualStyle,
|
||||
) {
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator — only while awaiting the first token. Once
|
||||
|
||||
@@ -39,6 +39,8 @@ fun ThinkingBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
headerText: String? = null,
|
||||
accessibilityLabel: String = "Thinking",
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
@@ -65,13 +67,13 @@ fun ThinkingBlock(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Psychology,
|
||||
contentDescription = "Thinking",
|
||||
contentDescription = accessibilityLabel,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
||||
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
|
||||
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
|
||||
) {
|
||||
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val imageStyleOptions = listOf(
|
||||
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
|
||||
"grid" to stringResource(R.string.appearance_image_generation_grid),
|
||||
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
|
||||
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
imageStyleOptions.forEach { (id, label) ->
|
||||
FilterChip(
|
||||
selected = imageGenerationStyle == id,
|
||||
onClick = { connectionViewModel.setImageGenerationStyle(id) },
|
||||
label = { Text(label) },
|
||||
leadingIcon = if (imageGenerationStyle == id) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -663,9 +663,24 @@ fun ChatScreen(
|
||||
// Animation settings
|
||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
||||
val imageGenerationOrdinals = remember(messages) {
|
||||
var nextOrdinal = 0
|
||||
buildMap {
|
||||
messages.forEach { message ->
|
||||
val generationCount = message.toolCalls.count {
|
||||
it.name.trim().equals("image_generate", ignoreCase = true)
|
||||
}
|
||||
if (generationCount > 0) {
|
||||
put(message.uiKey, nextOrdinal + generationCount - 1)
|
||||
nextOrdinal += generationCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||
@@ -2284,6 +2299,9 @@ fun ChatScreen(
|
||||
isLastInGroup = isLastInGroup,
|
||||
retainStreamingLayout = retainLiveLayout,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
imageGenerationStylePreference = imageGenerationStyle,
|
||||
imageGenerationRotationIndex =
|
||||
imageGenerationOrdinals[message.uiKey] ?: 0,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
@@ -2836,6 +2854,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
group = "Routes",
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
|
||||
+84
-28
@@ -107,6 +107,7 @@ import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
@@ -464,15 +465,8 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun loadDashboardSection(
|
||||
@@ -1548,6 +1542,41 @@ private fun ManageOverviewBody(
|
||||
)
|
||||
}
|
||||
}
|
||||
status?.componentHealth
|
||||
?.takeIf { it.supported }
|
||||
?.let { health ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RelaySectionCaption(
|
||||
title = stringResource(R.string.dashboard_metric_dashboard),
|
||||
meta = health.overall?.replaceFirstChar(Char::uppercase)
|
||||
?: stringResource(R.string.conn_label_status),
|
||||
)
|
||||
dashboardComponentHealthLines(
|
||||
health = health,
|
||||
connectedLabel = stringResource(R.string.dashboard_component_connected),
|
||||
serverErrorsLabel = stringResource(R.string.dashboard_component_server_errors_5m),
|
||||
).forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val signInStatus = status
|
||||
if (signInStatus?.authRequired == true && authenticated != true) {
|
||||
item {
|
||||
@@ -1916,7 +1945,7 @@ private suspend fun fetchDashboardSectionStateWith(
|
||||
* start lands on an already-populated Manage tab.
|
||||
*/
|
||||
internal suspend fun prewarmDashboardManage(
|
||||
cookieStore: DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
effectiveProfileName: String? = null,
|
||||
@@ -1945,12 +1974,7 @@ internal suspend fun prewarmDashboardManage(
|
||||
// encrypted cookie store per section: 8 Keystore keyset builds, each
|
||||
// holding Tink's process-global lock for seconds on StrongBox devices,
|
||||
// which starved main-thread keystore users and froze the UI at startup.
|
||||
val client = withContext(Dispatchers.IO) {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStore = cookieStore),
|
||||
)
|
||||
}
|
||||
val client = withContext(Dispatchers.IO) { clientFactory() }
|
||||
try {
|
||||
val preamble = try {
|
||||
fetchDashboardPreamble(client)
|
||||
@@ -2598,6 +2622,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -2634,16 +2659,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
statusText = context.getString(R.string.dashboard_oauth_verifying)
|
||||
scope.launch {
|
||||
try {
|
||||
val session = withDashboardClient(
|
||||
clientFactory = {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { client ->
|
||||
val session = withDashboardClient(clientFactory = clientFactory) { client ->
|
||||
client.currentSession().getOrNull()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
@@ -3466,6 +3482,7 @@ private fun CustomEndpointDialog(
|
||||
var discoverModels by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
||||
}
|
||||
var validatedModels by remember(existing) { mutableStateOf(emptyList<String>()) }
|
||||
var busy by remember(existing) { mutableStateOf(false) }
|
||||
var message by remember(existing) { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -3474,6 +3491,7 @@ private fun CustomEndpointDialog(
|
||||
name = name.trim(),
|
||||
baseUrl = baseUrl.trim(),
|
||||
model = model.trim(),
|
||||
models = validatedModels,
|
||||
apiKey = apiKey.takeIf { it.isNotBlank() },
|
||||
contextLength = contextLength.toIntOrNull(),
|
||||
discoverModels = discoverModels,
|
||||
@@ -3488,8 +3506,24 @@ private fun CustomEndpointDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
|
||||
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
|
||||
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
|
||||
OutlinedTextField(
|
||||
baseUrl,
|
||||
{
|
||||
baseUrl = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
model,
|
||||
{
|
||||
model = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
@@ -3518,6 +3552,11 @@ private fun CustomEndpointDialog(
|
||||
busy = false
|
||||
message = result.fold(
|
||||
onSuccess = { validation ->
|
||||
validatedModels = validation.models
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(256)
|
||||
validation.message.ifBlank {
|
||||
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
||||
}
|
||||
@@ -3837,6 +3876,23 @@ internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryI
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
internal fun dashboardComponentHealthLines(
|
||||
health: DashboardComponentHealthRollup,
|
||||
connectedLabel: String = "connected",
|
||||
serverErrorsLabel: String = "server errors / 5m",
|
||||
): List<String> = health.components.map { component ->
|
||||
buildList {
|
||||
add("${component.name}: ${component.status}")
|
||||
component.message?.takeIf(String::isNotBlank)?.let(::add)
|
||||
if (component.configured != null || component.connected != null) {
|
||||
add("${component.connected ?: 0}/${component.configured ?: 0} $connectedLabel")
|
||||
}
|
||||
component.unhandled5xxCount5m
|
||||
?.takeIf { it > 0 }
|
||||
?.let { add("$it $serverErrorsLabel") }
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun summarizeRoot(root: JsonElement): String {
|
||||
return when (root) {
|
||||
is JsonObject -> {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,9 +51,17 @@ import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
@@ -66,7 +75,8 @@ fun DashboardSignInScreen(
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
@@ -78,22 +88,22 @@ fun DashboardSignInScreen(
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var redirectAuthMode by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf(DashboardRedirectAuthMode.WebView)
|
||||
}
|
||||
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
@@ -115,7 +125,7 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
invalidateDashboardManageCache(appContext.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
@@ -132,11 +142,13 @@ fun DashboardSignInScreen(
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
actionIsError = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
providers = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -157,6 +169,7 @@ fun DashboardSignInScreen(
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
actionIsError = false
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
@@ -167,9 +180,11 @@ fun DashboardSignInScreen(
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
@@ -177,11 +192,75 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
launchNativeDashboardAuthorization(context, authorizationUrl)
|
||||
}
|
||||
}
|
||||
val client = clientFactory()
|
||||
val session = try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = context.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
nativeSignInJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(dashboardUrl, connectionId) {
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider
|
||||
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
|
||||
?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
clientFactory = clientFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
@@ -198,7 +277,10 @@ fun DashboardSignInScreen(
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
onError = {
|
||||
actionMessage = it
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,7 +289,10 @@ fun DashboardSignInScreen(
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = {
|
||||
nativeSignInJob?.cancel()
|
||||
onBack()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
@@ -235,8 +320,17 @@ fun DashboardSignInScreen(
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +395,13 @@ private fun DashboardSignInForm(
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativePkce: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
nativeTransportEligible: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -328,12 +427,19 @@ private fun DashboardSignInForm(
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight,
|
||||
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_native_signin_requires_https),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
@@ -360,7 +466,23 @@ private fun DashboardSignInForm(
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (actionIsError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +491,7 @@ private fun DashboardOAuthDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -408,10 +531,7 @@ private fun DashboardOAuthDialog(
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
|
||||
@@ -73,6 +73,7 @@ fun DeveloperSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -372,6 +373,47 @@ fun DeveloperSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNavigateToImageGenerationLab) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = stringResource(
|
||||
R.string.dev_settings_open_image_generation_lab_cd
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Lock developer options
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
|
||||
internal fun launchNativeDashboardAuthorization(
|
||||
context: Context,
|
||||
authorizationUrl: String,
|
||||
) {
|
||||
val uri = Uri.parse(authorizationUrl)
|
||||
val customTab = CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||
.build()
|
||||
.also {
|
||||
if (context !is Activity) {
|
||||
it.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
customTab.launchUrl(context, uri)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,9 +112,7 @@ import com.hermesandroid.relay.network.relay.VoiceProviderValidationResponse
|
||||
import com.hermesandroid.relay.network.upstream.ConfigFieldType
|
||||
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.ElevenLabsVoices
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
||||
import com.hermesandroid.relay.network.upstream.configValueAt
|
||||
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
||||
@@ -184,12 +182,12 @@ fun VoiceSettingsScreen(
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
/**
|
||||
* Dashboard base URL + per-connection cookie store provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, cookie auth).
|
||||
* Dashboard base URL + trusted per-connection client provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, session auth).
|
||||
* Null on connections with no dashboard — the editor card is then hidden.
|
||||
*/
|
||||
dashboardUrl: String? = null,
|
||||
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
|
||||
dashboardClientProvider: ((String) -> DashboardApiClient)? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -208,17 +206,12 @@ fun VoiceSettingsScreen(
|
||||
// just observes it; the editor cards push saves back through the VM.
|
||||
val configState by settingsViewModel.configState.collectAsState()
|
||||
|
||||
// Standard-path server voice-config editor client (dashboard cookie auth).
|
||||
// Standard-path server voice-config editor client (cookie or native bearer).
|
||||
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
|
||||
// the connection has no dashboard, which hides the card entirely.
|
||||
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
||||
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
||||
DashboardApiClient(
|
||||
baseUrl = url,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
|
||||
),
|
||||
)
|
||||
dashboardClientProvider?.invoke(url)
|
||||
}
|
||||
DisposableEffect(dashboardConfigClient) {
|
||||
onDispose { dashboardConfigClient?.shutdown() }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.ToolsetInfo
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -217,6 +218,19 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime API route only after the optional fallback was explicitly
|
||||
* configured. Discovery may attach a conventional same-host API candidate to a
|
||||
* Dashboard route, but that candidate alone must not enable API traffic.
|
||||
*/
|
||||
internal fun resolveEffectiveApiServerUrl(
|
||||
savedUrl: String,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (savedUrl.isBlank()) return ""
|
||||
return endpoint?.api?.url?.takeIf { it.isNotBlank() } ?: savedUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Relay transport metadata to the connection's existing standard routes
|
||||
* without adopting the Relay QR's API/Dashboard identity.
|
||||
@@ -391,6 +405,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Animation
|
||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
||||
private val KEY_IMAGE_GENERATION_STYLE = stringPreferencesKey("image_generation_style")
|
||||
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
|
||||
|
||||
// Chat scroll behavior
|
||||
@@ -778,7 +793,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
|
||||
private fun effectiveApiServerUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
|
||||
resolveEffectiveApiServerUrl(
|
||||
savedUrl = _apiServerUrl.value,
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
)
|
||||
|
||||
private fun effectiveRelayUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
||||
@@ -854,7 +872,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// --- API Server state ---
|
||||
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
|
||||
// Blank is the unhydrated sentinel. Seeding this with the legacy localhost
|
||||
// default made a discovered remote API candidate look explicitly configured
|
||||
// during the first DataStore frame, briefly building an unauthenticated
|
||||
// Sessions client before a Dashboard-only connection restored its saved
|
||||
// blank URL.
|
||||
private val _apiServerUrl = MutableStateFlow("")
|
||||
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||
|
||||
private val _apiServerReachable = MutableStateFlow(false)
|
||||
@@ -961,6 +984,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
||||
upstreamTransport.activeDashboardCookieStore()
|
||||
|
||||
/** Trusted active-connection clients used by the shared dashboard sign-in route. */
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient =
|
||||
upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
|
||||
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient =
|
||||
upstreamTransport.dashboardHttpClientForActive(dashboardUrl)
|
||||
|
||||
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
|
||||
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
@@ -1020,17 +1053,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||
|
||||
/**
|
||||
* Runtime route for chat/API traffic. The persisted API URL remains the
|
||||
* connection's base config; a resolver-selected endpoint temporarily wins
|
||||
* so paired devices can roam between LAN, Tailscale, and operator VPN
|
||||
* routes without rewriting stored settings.
|
||||
* Runtime route for chat/API traffic. Once API fallback is explicitly
|
||||
* configured, a resolver-selected endpoint temporarily wins so paired
|
||||
* devices can roam without rewriting stored settings. Discovery alone
|
||||
* never enables the optional API surface.
|
||||
*/
|
||||
val effectiveApiServerUrl: StateFlow<String> = combine(
|
||||
_apiServerUrl,
|
||||
connectionManager.activeEndpoint,
|
||||
) { savedUrl, endpoint ->
|
||||
endpoint?.api?.url ?: savedUrl
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
||||
resolveEffectiveApiServerUrl(savedUrl, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
* Whether a chat turn is currently streaming — mirrored from
|
||||
@@ -1745,6 +1778,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val imageGenerationStyle: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_IMAGE_GENERATION_STYLE] ?: "rotate" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "rotate")
|
||||
|
||||
fun setAnimationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
@@ -1777,6 +1814,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageGenerationStyle(value: String) {
|
||||
val normalized = value.takeIf {
|
||||
it in setOf("rotate", "grid", "sphere", "nodes")
|
||||
} ?: "rotate"
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_IMAGE_GENERATION_STYLE] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth auto-scroll during chat streaming.
|
||||
// When enabled, the chat list smoothly follows new tokens, tool cards, and
|
||||
// reasoning deltas as they stream in — but only while the user is at the
|
||||
@@ -4927,7 +4975,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
upstreamTransport.clearDashboardAuthentication(connectionId)
|
||||
}
|
||||
connectionStore.setDashboardStatus(
|
||||
connectionId = connectionId,
|
||||
|
||||
@@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -99,6 +100,104 @@ private enum class StandardSpeechStreamState {
|
||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
||||
|
||||
internal data class AssistantSpeechDelta(
|
||||
val message: ChatMessage,
|
||||
val text: String,
|
||||
val startsNewBubble: Boolean,
|
||||
)
|
||||
|
||||
internal data class AssistantSpeechBatch(
|
||||
val deltas: List<AssistantSpeechDelta>,
|
||||
val assistantMessages: List<ChatMessage>,
|
||||
val aggregateText: String,
|
||||
val hasTurnAssistant: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-voice-turn cursor over every assistant bubble created after the user
|
||||
* submits the turn. Tool-using Hermes runs may finalize an interim assistant
|
||||
* bubble and then append a second bubble with the actual answer; tracking only
|
||||
* the last bubble drops one of those segments.
|
||||
*
|
||||
* Existing stable UI keys are fenced at construction so StateFlow replay and
|
||||
* later history reconciliation cannot narrate an older session after adopting
|
||||
* a server message ID. Content rewrites are adopted silently unless they
|
||||
* preserve the exact prior prefix: only genuine suffix growth is speech.
|
||||
*/
|
||||
internal class AssistantSpeechCursor(
|
||||
baselineMessages: List<ChatMessage>,
|
||||
) {
|
||||
private val baselineAssistantKeys = baselineMessages.asSequence()
|
||||
.filter { it.role == MessageRole.ASSISTANT }
|
||||
.mapTo(mutableSetOf()) { it.uiKey }
|
||||
private val observedContent = mutableMapOf<String, String>()
|
||||
|
||||
fun poll(messages: List<ChatMessage>): AssistantSpeechBatch {
|
||||
val turnAssistants = messages.filter {
|
||||
it.role == MessageRole.ASSISTANT && it.uiKey !in baselineAssistantKeys
|
||||
}
|
||||
val deltas = buildList {
|
||||
turnAssistants.forEach { message ->
|
||||
val key = message.uiKey
|
||||
val firstObservation = key !in observedContent
|
||||
val hasPriorBubbleSpeech = observedContent.values.any { it.isNotEmpty() }
|
||||
val previous = observedContent[key].orEmpty()
|
||||
val current = message.content
|
||||
if (current.length > previous.length && current.startsWith(previous)) {
|
||||
add(
|
||||
AssistantSpeechDelta(
|
||||
message = message,
|
||||
text = current.substring(previous.length),
|
||||
startsNewBubble = firstObservation && hasPriorBubbleSpeech,
|
||||
),
|
||||
)
|
||||
}
|
||||
observedContent[key] = current
|
||||
}
|
||||
}
|
||||
return AssistantSpeechBatch(
|
||||
deltas = deltas,
|
||||
assistantMessages = turnAssistants,
|
||||
aggregateText = turnAssistants
|
||||
.map { it.content.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n"),
|
||||
hasTurnAssistant = turnAssistants.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds one voice request to its chat session. Existing-session turns are
|
||||
* fixed immediately. A brand-new chat starts with no session id, so the first
|
||||
* server id is accepted only while the locally submitted user row is still in
|
||||
* that session's message list; switching to another existing session while
|
||||
* creation is pending therefore fails closed.
|
||||
*/
|
||||
internal class VoiceTurnSessionFence(initialSessionId: String?) {
|
||||
private var boundSessionId: String? = initialSessionId
|
||||
private val startedWithoutSession = initialSessionId == null
|
||||
private var submittedUserUiKey: String? = null
|
||||
|
||||
fun bindSubmittedUser(uiKey: String?) {
|
||||
submittedUserUiKey = uiKey
|
||||
}
|
||||
|
||||
fun accepts(sessionId: String?, messages: List<ChatMessage>): Boolean {
|
||||
boundSessionId?.let { return sessionId == it }
|
||||
if (!startedWithoutSession) return false
|
||||
if (sessionId == null) return true
|
||||
|
||||
val userKey = submittedUserUiKey ?: return false
|
||||
val ownsSubmittedTurn = messages.any {
|
||||
it.role == MessageRole.USER && it.uiKey == userKey
|
||||
}
|
||||
if (!ownsSubmittedTurn) return false
|
||||
boundSessionId = sessionId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||
|
||||
@@ -372,7 +471,7 @@ data class VoiceStats(
|
||||
* ### Sentence-boundary streaming TTS
|
||||
* The SSE stream emits text one token at a time, but TTS wants whole
|
||||
* sentences to sound natural. We observe [ChatViewModel.messages],
|
||||
* extract deltas from the currently-streaming assistant message, and
|
||||
* extract deltas from every assistant message created by the active run, and
|
||||
* feed each completed sentence into a bounded [ttsQueue]. A dedicated
|
||||
* consumer coroutine pulls from the queue, synthesizes each sentence,
|
||||
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
|
||||
@@ -380,10 +479,9 @@ data class VoiceStats(
|
||||
* ### Integration note (V2a → V2b cleanup)
|
||||
* This first version uses the public [ChatViewModel.messages] StateFlow
|
||||
* to observe streaming deltas rather than adding a `// VOICE HOOK`
|
||||
* callback inside ChatViewModel. It's clean but depends on the
|
||||
* "last message with isStreaming=true" invariant — if ChatViewModel
|
||||
* ever streams multiple assistant messages concurrently this will need
|
||||
* a dedicated per-turn flow. See `DEVLOG.md` and V2b ticket.
|
||||
* callback inside ChatViewModel. A per-turn cursor fences pre-existing
|
||||
* history and follows all assistant bubbles until ChatViewModel reports
|
||||
* that the complete Hermes run has ended.
|
||||
*/
|
||||
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -672,8 +770,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Tracks which assistant-message IDs have already been consumed so
|
||||
* we don't re-process older turns when the history list updates. */
|
||||
private var lastObservedMessageId: String? = null
|
||||
private var lastObservedContentLength: Int = 0
|
||||
private var assistantSpeechCursor: AssistantSpeechCursor? = null
|
||||
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
|
||||
private var sentenceBuffer: StringBuilder = StringBuilder()
|
||||
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
||||
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
||||
@@ -776,15 +874,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var ttsChunksThisResponse: Int = 0
|
||||
private var lastTtsChunkFinishedAtMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Assistant-message-id that already existed BEFORE the current turn's
|
||||
* [chatVm.sendMessage] call. The stream observer ignores any emission
|
||||
* whose `lastAssistant.id` equals this, so StateFlow's initial replay
|
||||
* of the previous turn's response doesn't get spoken as a reply to
|
||||
* the current voice input.
|
||||
*/
|
||||
private var ignoreAssistantId: String? = null
|
||||
|
||||
/** MP3 files produced by synthesize — trimmed to [TTS_CACHE_CAP]. */
|
||||
private val ttsFileHistory = ArrayDeque<File>()
|
||||
|
||||
@@ -1737,8 +1826,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -2144,8 +2233,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -3061,8 +3150,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Reset sentence buffering state for the new turn.
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = AssistantSpeechCursor(chatVm.messages.value)
|
||||
voiceTurnSessionFence = VoiceTurnSessionFence(chatVm.currentSessionId.value)
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -3073,14 +3162,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
// Capture the id of the assistant message that currently sits at
|
||||
// the end of history. StateFlow.collect replays the current value
|
||||
// to new subscribers, so without this guard the observer would
|
||||
// treat the previous turn's full response as one giant delta for
|
||||
// the new turn and TTS the wrong answer.
|
||||
ignoreAssistantId = chatVm.messages.value
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
@@ -3089,7 +3170,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Route the transcribed text through the normal chat pipeline.
|
||||
// This will create a user message + kick off the SSE stream.
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
val submittedUserUiKey =
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
|
||||
}
|
||||
|
||||
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
|
||||
@@ -3147,8 +3230,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -4223,64 +4306,62 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
||||
* push the new delta into [sentenceBuffer], and flush completed
|
||||
* sentences into [ttsQueue]. On isStreaming=false, flush the remaining
|
||||
* buffer and end the turn.
|
||||
* Observe every assistant bubble created by the active Hermes run. A tool
|
||||
* turn can finalize one bubble while the run is still active and later
|
||||
* append the final answer in another bubble, so completion is keyed to
|
||||
* [ChatViewModel.isStreaming], not an individual message flag.
|
||||
*/
|
||||
private fun startStreamObserver(chatVm: ChatViewModel) {
|
||||
streamObserverJob?.cancel()
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
chatVm.messages.collect { messages ->
|
||||
val lastAssistant = messages.lastOrNull {
|
||||
it.role == MessageRole.ASSISTANT
|
||||
} ?: return@collect
|
||||
|
||||
// Skip the assistant message that existed BEFORE the current
|
||||
// turn's sendMessage. Without this, StateFlow's replay of the
|
||||
// current list (containing the PREVIOUS turn's response) gets
|
||||
// treated as a delta and the agent voices the old answer.
|
||||
if (lastAssistant.id == ignoreAssistantId) return@collect
|
||||
|
||||
val msgId = lastAssistant.id
|
||||
if (lastObservedMessageId == null) {
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
} else if (lastObservedMessageId != msgId) {
|
||||
// A new assistant turn appeared — flush whatever's left
|
||||
// from the previous one, then switch tracking.
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
resetBrokeredToolSpeechState()
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
}
|
||||
|
||||
observeHermesToolLoopForSpeech(lastAssistant)
|
||||
|
||||
val content = lastAssistant.content
|
||||
if (content.length > lastObservedContentLength) {
|
||||
val delta = content.substring(lastObservedContentLength)
|
||||
lastObservedContentLength = content.length
|
||||
onStreamDelta(delta, content)
|
||||
}
|
||||
|
||||
if (!lastAssistant.isStreaming && lastObservedContentLength > 0) {
|
||||
// Stream ended — mark complete so the chunker stops
|
||||
// holding short trailing sentences, cancel the idle
|
||||
// timer (we know exactly when the stream is done), and
|
||||
// flush any trailing buffer.
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
// Speaking state will naturally end when TTS queue drains.
|
||||
// We can't easily wait here without blocking the collector;
|
||||
// the TTS consumer transitions back to Idle.
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
val cursor = assistantSpeechCursor ?: AssistantSpeechCursor(chatVm.messages.value).also {
|
||||
assistantSpeechCursor = it
|
||||
}
|
||||
val sessionFence = voiceTurnSessionFence
|
||||
?: VoiceTurnSessionFence(chatVm.currentSessionId.value).also {
|
||||
voiceTurnSessionFence = it
|
||||
}
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
combine(
|
||||
chatVm.messages,
|
||||
chatVm.isStreaming,
|
||||
chatVm.currentSessionId,
|
||||
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
|
||||
.collect { (messages, runActive, sessionId) ->
|
||||
if (!sessionFence.accepts(sessionId, messages)) {
|
||||
cancelStandardSpeechStream("chat session changed")
|
||||
streamObserverJob?.cancel()
|
||||
return@collect
|
||||
}
|
||||
|
||||
val batch = cursor.poll(messages)
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginAssistantSpeechBubble() {
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (standardSpeechStreamOwnsReply()) {
|
||||
offerStandardSpeechText("\n\n")
|
||||
} else {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -4,12 +4,18 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardBearerAuth
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.clearNativeDashboardTokens
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -78,6 +84,10 @@ class UpstreamTransportController(
|
||||
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
||||
private val dashboardCookieStores =
|
||||
ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
|
||||
private val dashboardTokenStores =
|
||||
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
|
||||
private var dashboardHttpClientCache:
|
||||
Triple<String, String, okhttp3.OkHttpClient>? = null
|
||||
|
||||
/**
|
||||
* Cookie store for [connectionId] — ONE instance per connection,
|
||||
@@ -107,6 +117,28 @@ class UpstreamTransportController(
|
||||
return dashboardCookieStoreFor(connectionId)
|
||||
}
|
||||
|
||||
private fun dashboardTokenStoreFor(connectionId: String): EncryptedNativeDashboardTokenStore {
|
||||
val key = tokenStoreKeyProvider(connectionId)
|
||||
?: com.hermesandroid.relay.data.Connection.buildTokenStoreKey(connectionId)
|
||||
return dashboardTokenStores.getOrPut(connectionId) {
|
||||
EncryptedNativeDashboardTokenStore(context, key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bearerAuthForTrustedDashboard(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (activeConnectionIdProvider() != connectionId) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
|
||||
)
|
||||
}
|
||||
|
||||
// --- DashboardApiClient factory ----------------------------------------
|
||||
|
||||
/**
|
||||
@@ -120,6 +152,7 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,9 +166,80 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let {
|
||||
bearerAuthForTrustedDashboard(it, dashboardUrl)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Native PKCE client for the active connection's exact trusted dashboard
|
||||
* base. Setup probes and stale routes never receive the encrypted bearer
|
||||
* store.
|
||||
*/
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
|
||||
val connectionId = activeConnectionIdProvider() ?: return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return NativeDashboardAuthClient(
|
||||
baseUrl = dashboardUrl,
|
||||
tokenStore = dashboardTokenStoreFor(connectionId),
|
||||
)
|
||||
}
|
||||
|
||||
/** Exact-origin authenticated HTTP client for non-REST dashboard consumers such as voice. */
|
||||
@Synchronized
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient {
|
||||
val connectionId = activeConnectionIdProvider() ?: "unassociated"
|
||||
dashboardHttpClientCache?.let { (cachedConnection, cachedUrl, client) ->
|
||||
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
|
||||
disposeDashboardHttpClient(client)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
return DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
|
||||
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
|
||||
},
|
||||
).also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearDashboardAuthentication(connectionId: String) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
clearNativeDashboardTokens(dashboardTokenStoreFor(connectionId))
|
||||
dashboardHttpClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.let(::disposeDashboardHttpClient)
|
||||
if (dashboardHttpClientCache?.first == connectionId) {
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
gatewayClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.shutdown()
|
||||
if (gatewayClientCache?.first == connectionId) {
|
||||
gatewayClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
runCatching { client.cache?.close() }
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
|
||||
// --- Gateway availability ----------------------------------------------
|
||||
|
||||
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
@@ -226,6 +330,8 @@ class UpstreamTransportController(
|
||||
synchronized(this) {
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
gatewayClientCache = null
|
||||
dashboardHttpClientCache?.third?.let(::disposeDashboardHttpClient)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2465,6 +2465,23 @@
|
||||
<string name="attachment_unmute">Ativar som</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Anexo</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d anexo</item>
|
||||
<item quantity="other">%1$d anexos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Recolher anexos</string>
|
||||
<string name="attachment_group_expand">Expandir anexos</string>
|
||||
<string name="attachment_group_collapsed">Recolhido</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagem</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Áudio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Arquivo</string>
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
||||
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
|
||||
@@ -3177,4 +3194,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">erros do servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Geração de imagens</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterne entre as três animações de progresso ou mantenha um estilo em todas as gerações.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Grade</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nós</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratório de geração de imagens</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Visualize os ciclos de geração e a revelação final da imagem</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir o laboratório de geração de imagens</string>
|
||||
<string name="dashboard_native_signin_opening">Conclua o login no navegador e volte aqui.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Login pelo navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil do Hermes. Aplica-se a todos os chats e dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Atualize o Hermes para escolher um modo de aprovação do perfil. O YOLO por chat continuará disponível.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Somente leitura para este perfil multiplexado. O modo atual aparece após o início da sessão do perfil; alterá-lo exige RPCs de configuração por perfil do upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Perguntar antes de cada chamada de ferramenta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Perguntar apenas quando o Hermes detectar risco elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desativado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Ignorar permanentemente as aprovações deste perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -2583,6 +2583,22 @@
|
||||
<string name="attachment_unmute">取消静音</string>
|
||||
<string name="attachment_mute">静音</string>
|
||||
<string name="attachment_title">附件</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">%1$d 个附件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">收起附件</string>
|
||||
<string name="attachment_group_expand">展开附件</string>
|
||||
<string name="attachment_group_collapsed">已收起</string>
|
||||
<string name="attachment_group_expanded">已展开</string>
|
||||
<string name="attachment_type_image">图片</string>
|
||||
<string name="attachment_type_video">视频</string>
|
||||
<string name="attachment_type_audio">音频</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">文本</string>
|
||||
<string name="attachment_type_file">文件</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||
@@ -3271,4 +3287,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
||||
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
|
||||
<string name="dashboard_component_connected">已连接</string>
|
||||
<string name="dashboard_component_server_errors_5m">服务器错误 / 5 分钟</string>
|
||||
<string name="appearance_image_generation_style">图像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">轮换使用三种进度动画,或让每次生成都使用同一种样式。</string>
|
||||
<string name="appearance_image_generation_rotate">轮换</string>
|
||||
<string name="appearance_image_generation_grid">网格</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">节点</string>
|
||||
<string name="dev_settings_image_generation_lab">图像生成实验室</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">预览生成循环和最终图像显现效果</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">打开图像生成实验室</string>
|
||||
<string name="dashboard_native_signin_opening">请在浏览器中完成登录,然后返回此处。</string>
|
||||
<string name="dashboard_native_signin_cancelled">已取消浏览器登录。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
|
||||
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
|
||||
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
|
||||
<string name="conn_info_approval_mode_desc">此 Hermes 配置文件的持久策略。适用于所有聊天和设备。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">请更新 Hermes 以选择配置文件批准模式。单聊天 YOLO 仍可使用。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">此多路复用配置文件为只读。配置文件会话启动后会显示当前模式;更改模式需要 upstream 提供按配置文件划分的配置 RPC。</string>
|
||||
<string name="conn_info_approval_mode_manual">手动</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">每次调用受保护工具前都询问。</string>
|
||||
<string name="conn_info_approval_mode_smart">智能</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">仅在 Hermes 检测到较高风险时询问。</string>
|
||||
<string name="conn_info_approval_mode_off">关闭</string>
|
||||
<string name="conn_info_approval_mode_off_desc">始终跳过此配置文件的批准。</string>
|
||||
</resources>
|
||||
|
||||
@@ -2586,6 +2586,23 @@
|
||||
<string name="attachment_unmute">Ton an</string>
|
||||
<string name="attachment_mute">Stummschalten</string>
|
||||
<string name="attachment_title">Anhang</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d Anhang</item>
|
||||
<item quantity="other">%1$d Anhänge</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Anhänge einklappen</string>
|
||||
<string name="attachment_group_expand">Anhänge ausklappen</string>
|
||||
<string name="attachment_group_collapsed">Eingeklappt</string>
|
||||
<string name="attachment_group_expanded">Ausgeklappt</string>
|
||||
<string name="attachment_type_image">Bild</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">Datei</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
||||
@@ -3337,4 +3354,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
|
||||
<string name="dashboard_component_connected">verbunden</string>
|
||||
<string name="dashboard_component_server_errors_5m">Serverfehler / 5 Min.</string>
|
||||
<string name="appearance_image_generation_style">Bildgenerierung</string>
|
||||
<string name="appearance_image_generation_style_desc">Wechsle zwischen allen drei Fortschrittsanimationen oder verwende bei jeder Generierung denselben Stil.</string>
|
||||
<string name="appearance_image_generation_rotate">Wechseln</string>
|
||||
<string name="appearance_image_generation_grid">Raster</string>
|
||||
<string name="appearance_image_generation_sphere">Kugel</string>
|
||||
<string name="appearance_image_generation_nodes">Knoten</string>
|
||||
<string name="dev_settings_image_generation_lab">Bildgenerierungs-Labor</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Generierungsschleifen und die abschließende Bildenthüllung als Vorschau anzeigen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Bildgenerierungs-Labor öffnen</string>
|
||||
<string name="dashboard_native_signin_opening">Schließe die Anmeldung im Browser ab und kehre dann hierher zurück.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser-Anmeldung abgebrochen.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
|
||||
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
|
||||
<string name="conn_info_approval_mode_desc">Dauerhafte Richtlinie für dieses Hermes-Profil. Gilt für alle Chats und Geräte.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Aktualisiere Hermes, um einen Profil-Bestätigungsmodus auszuwählen. Chatbezogenes YOLO bleibt verfügbar.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Schreibgeschützt für dieses multiplexte Profil. Der aktuelle Modus erscheint nach dem Start der Profilsitzung; Änderungen erfordern profilbezogene Konfigurations-RPCs von Upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manuell</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Vor jedem geschützten Werkzeugaufruf nachfragen.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Nur nachfragen, wenn Hermes ein erhöhtes Risiko erkennt.</string>
|
||||
<string name="conn_info_approval_mode_off">Aus</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Bestätigungen für dieses Profil dauerhaft umgehen.</string>
|
||||
</resources>
|
||||
|
||||
@@ -2342,6 +2342,23 @@
|
||||
<string name="attachment_unmute">Dejar de silenciar</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Adjunto</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d archivo adjunto</item>
|
||||
<item quantity="other">%1$d archivos adjuntos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Contraer archivos adjuntos</string>
|
||||
<string name="attachment_group_expand">Expandir archivos adjuntos</string>
|
||||
<string name="attachment_group_collapsed">Contraído</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagen</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
@@ -3022,4 +3039,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">errores del servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Generación de imágenes</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterna entre las tres animaciones de progreso o conserva un estilo para cada generación.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Cuadrícula</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nodos</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratorio de generación de imágenes</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Previsualiza los ciclos de generación y la revelación final de la imagen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir el laboratorio de generación de imágenes</string>
|
||||
<string name="dashboard_native_signin_opening">Completa el inicio de sesión en el navegador y vuelve aquí.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Inicio de sesión en el navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil de Hermes. Se aplica a todos los chats y dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Actualiza Hermes para elegir un modo de aprobación del perfil. YOLO por chat seguirá disponible.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Solo lectura para este perfil multiplexado. El modo actual aparece al iniciar la sesión del perfil; cambiarlo requiere RPC de configuración por perfil de upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Preguntar antes de cada llamada a una herramienta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Preguntar solo cuando Hermes detecte un riesgo elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desactivado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Omitir permanentemente las aprobaciones para este perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -2597,6 +2597,22 @@
|
||||
<string name="attachment_unmute">ミュートを解除する</string>
|
||||
<string name="attachment_mute">ミュート</string>
|
||||
<string name="attachment_title">アタッチメント</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">添付ファイル %1$d 件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">添付ファイルを折りたたむ</string>
|
||||
<string name="attachment_group_expand">添付ファイルを展開する</string>
|
||||
<string name="attachment_group_collapsed">折りたたみ済み</string>
|
||||
<string name="attachment_group_expanded">展開済み</string>
|
||||
<string name="attachment_type_image">画像</string>
|
||||
<string name="attachment_type_video">動画</string>
|
||||
<string name="attachment_type_audio">音声</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">テキスト</string>
|
||||
<string name="attachment_type_file">ファイル</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||
@@ -3337,4 +3353,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
||||
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
|
||||
<string name="dashboard_component_connected">接続済み</string>
|
||||
<string name="dashboard_component_server_errors_5m">サーバーエラー / 5分</string>
|
||||
<string name="appearance_image_generation_style">画像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">3種類の進行アニメーションを順番に使うか、毎回同じスタイルを使用します。</string>
|
||||
<string name="appearance_image_generation_rotate">ローテーション</string>
|
||||
<string name="appearance_image_generation_grid">グリッド</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">ノード</string>
|
||||
<string name="dev_settings_image_generation_lab">画像生成ラボ</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">生成ループと最終画像の表示をプレビューします</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">画像生成ラボを開く</string>
|
||||
<string name="dashboard_native_signin_opening">ブラウザでサインインを完了してから、ここに戻ってください。</string>
|
||||
<string name="dashboard_native_signin_cancelled">ブラウザでのサインインをキャンセルしました。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
|
||||
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
|
||||
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
|
||||
<string name="conn_info_approval_mode_desc">このHermesプロフィールに対する永続的なポリシーです。すべてのチャットとデバイスに適用されます。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">プロフィール承認モードを選択するにはHermesを更新してください。チャット単位のYOLOは引き続き利用できます。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">この多重化プロフィールでは読み取り専用です。現在のモードはプロフィールセッション開始後に表示され、変更にはupstreamのプロフィール別設定RPCが必要です。</string>
|
||||
<string name="conn_info_approval_mode_manual">手動</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">保護されたツール呼び出しのたびに確認します。</string>
|
||||
<string name="conn_info_approval_mode_smart">スマート</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Hermesが高いリスクを検出した場合にのみ確認します。</string>
|
||||
<string name="conn_info_approval_mode_off">オフ</string>
|
||||
<string name="conn_info_approval_mode_off_desc">このプロフィールの承認を常に省略します。</string>
|
||||
</resources>
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
<string name="conn_label_session">Session</string>
|
||||
<string name="conn_label_route">Route</string>
|
||||
<string name="conn_label_status">Status</string>
|
||||
<string name="dashboard_component_connected">connected</string>
|
||||
<string name="dashboard_component_server_errors_5m">server errors / 5m</string>
|
||||
<string name="conn_label_connect">Connect</string>
|
||||
<string name="conn_label_connections">Connections</string>
|
||||
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</string>
|
||||
@@ -1223,6 +1225,12 @@
|
||||
<string name="appearance_font">Font</string>
|
||||
<string name="appearance_font_desc">Sets the typeface across the whole app. Code and timestamps stay monospaced.</string>
|
||||
<string name="appearance_animation">Animation</string>
|
||||
<string name="appearance_image_generation_style">Image generation</string>
|
||||
<string name="appearance_image_generation_style_desc">Rotate through all three progress animations, or keep one style for every generation.</string>
|
||||
<string name="appearance_image_generation_rotate">Rotate</string>
|
||||
<string name="appearance_image_generation_grid">Grid</string>
|
||||
<string name="appearance_image_generation_sphere">Sphere</string>
|
||||
<string name="appearance_image_generation_nodes">Nodes</string>
|
||||
<string name="appearance_ascii_sphere">ASCII sphere</string>
|
||||
<string name="appearance_ascii_sphere_desc">Show animated sphere on empty chat screen and ambient mode</string>
|
||||
<string name="appearance_behind_messages">Behind messages</string>
|
||||
@@ -1284,6 +1292,9 @@
|
||||
<string name="dev_settings_realtime_voice_lab">Realtime voice lab</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Open the provider websocket testbench for dev builds</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Open realtime voice lab</string>
|
||||
<string name="dev_settings_image_generation_lab">Image generation lab</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Preview generation loops and the final image reveal</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Open image generation lab</string>
|
||||
<string name="dev_settings_lock_dev_options">Lock developer options</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Hide this section and disable experimental features</string>
|
||||
<string name="dev_settings_locked_toast">Developer options locked</string>
|
||||
@@ -1850,6 +1861,10 @@
|
||||
<string name="dashboard_oauth_verifying">Verifying dashboard session…</string>
|
||||
<string name="dashboard_oauth_not_accepted">Sign-in was not accepted yet. Finish the dashboard flow to continue.</string>
|
||||
<string name="dashboard_oauth_verify_failed">Dashboard sign-in verification failed</string>
|
||||
<string name="dashboard_native_signin_opening">Complete sign-in in your browser, then return here.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser sign-in cancelled.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
|
||||
<string name="dashboard_close_signin">Close sign-in</string>
|
||||
|
||||
<!-- Error body -->
|
||||
@@ -2462,7 +2477,19 @@
|
||||
<string name="conn_info_yes">Yes</string>
|
||||
<string name="conn_info_yes_hidden">Yes (hidden)</string>
|
||||
<string name="conn_info_yolo_mode_desc">Bypass approval prompts for tool calls.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Bypass approval prompts for this chat only. Resets when the session changes.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profile approvals are Off, so this chat already bypasses prompts. Choose Manual or Smart before using the per-chat override.</string>
|
||||
<string name="conn_info_yolo_mode_title">YOLO mode</string>
|
||||
<string name="conn_info_approval_mode_title">Profile approval mode</string>
|
||||
<string name="conn_info_approval_mode_desc">Persistent policy for this Hermes profile. Applies across chats and devices.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Update Hermes to choose a profile approval mode. Per-chat YOLO remains available.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Read-only for this multiplexed profile. Its current mode appears after the profile session starts; changing it requires upstream profile-scoped config RPCs.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Ask before every protected tool call.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Ask only when Hermes detects elevated risk.</string>
|
||||
<string name="conn_info_approval_mode_off">Off</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Persistently bypass approvals for this profile.</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
|
||||
@@ -2691,6 +2718,23 @@
|
||||
<string name="attachment_unmute">Unmute</string>
|
||||
<string name="attachment_mute">Mute</string>
|
||||
<string name="attachment_title">Attachment</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d attachment</item>
|
||||
<item quantity="other">%1$d attachments</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Collapse attachments</string>
|
||||
<string name="attachment_group_expand">Expand attachments</string>
|
||||
<string name="attachment_group_collapsed">Collapsed</string>
|
||||
<string name="attachment_group_expanded">Expanded</string>
|
||||
<string name="attachment_type_image">Image</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">File</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||
|
||||
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
startedAt = 1_002L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Safe advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||
id = "run-1",
|
||||
title = "Research",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnMoaReferenceCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -15,6 +17,8 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -800,6 +804,57 @@ class ChatHandlerTest {
|
||||
assertEquals("2 background tasks completed", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_rendersAutoContinueAsNeutralSystemTimelineRow() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "continue-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("private continuation prompt"),
|
||||
displayKind = "auto_continue",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals(MessageRole.SYSTEM, msg.role)
|
||||
assertEquals("Continued after an interrupted turn", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "candidate",
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "provisional",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.reconcileInterimMessage(
|
||||
interimMessageId = "interim",
|
||||
currentMessageId = "provisional",
|
||||
content = "candidate answer",
|
||||
)
|
||||
|
||||
val assistant = handler.messages.value.single()
|
||||
assertEquals("interim", assistant.id)
|
||||
assertEquals("candidate answer", assistant.content)
|
||||
assertTrue(assistant.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_skipsToolMessages() {
|
||||
val items = listOf(
|
||||
@@ -849,6 +904,231 @@ class ChatHandlerTest {
|
||||
assertEquals("", handler.messages.value[0].content)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: persisted user image references (HRUI-073) ---
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_liftsCaptionFirstPersistedImageRef() {
|
||||
val requested = mutableListOf<Pair<String, String>>()
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requested += messageId to path
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(listOf("image-user-1" to "/tmp/cat.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsImageOnlyTurnAndParsesQuotedSpacedPath() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-2",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"@image:`/tmp/Hermes composer images/holiday photo.webp`"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("", handler.messages.value.single().content)
|
||||
assertEquals(
|
||||
listOf("/tmp/Hermes composer images/holiday photo.webp"),
|
||||
requested,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsMultipleRefsInOrder() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-3",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"Compare these\n@image:/tmp/a.png\n@image:\"/tmp/two images/b.jpg\""
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("Compare these", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/a.png", "/tmp/two images/b.jpg"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsRefFromNativeVisionContentArray() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val nativeVisionContent = buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Describe this\n@image:/tmp/native.png")
|
||||
}
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
put(
|
||||
"image_url",
|
||||
buildJsonObject { put("url", "data:image/png;base64,AAAA") },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-4", role = "user", content = nativeVisionContent))
|
||||
)
|
||||
|
||||
assertEquals("Describe this", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/native.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsMalformedUnknownAndInlineImageDirectivesAsText() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val content = listOf(
|
||||
"@image:relative.png",
|
||||
"@image:`/tmp/unclosed.png",
|
||||
"@image:/etc/passwd",
|
||||
"mention @image:/tmp/inline.png here",
|
||||
).joinToString("\n")
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-5", role = "user", content = JsonPrimitive(content)))
|
||||
)
|
||||
|
||||
assertEquals(content, handler.messages.value.single().content)
|
||||
assertTrue(requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_canRenderPathFreeUnavailableImageState() {
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = path.substringAfterLast('/'),
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-6",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/deleted.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
assertEquals("deleted.png", message.attachments.single().fileName)
|
||||
assertFalse(message.attachments.single().errorMessage.orEmpty().contains("/tmp/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coldResumeDispatchesPersistedImageOnlyOnceAcrossReloads() {
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requestCount++
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "resume.png",
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
relayToken = path,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "image-user-cold",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/resume.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals(1, requestCount)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_immediateReloadCarriesLocalImageWithoutDuplicateFetch() {
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = "optimistic-image",
|
||||
role = MessageRole.USER,
|
||||
content = "What is this?",
|
||||
timestamp = 1L,
|
||||
attachments = listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "base64-pixels",
|
||||
fileName = "cat.png",
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { _, _ -> requestCount++ }
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "server-image",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("server-image", message.id)
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals("base64-pixels", message.attachments.single().content)
|
||||
assertEquals(0, requestCount)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: outbound attachment preservation (GAP 1) ---
|
||||
|
||||
@Test
|
||||
@@ -1737,6 +2017,20 @@ class ChatHandlerTest {
|
||||
completedAt = 7L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Recovered advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 1,
|
||||
@@ -1755,10 +2049,107 @@ class ChatHandlerTest {
|
||||
assertFalse(restored.toolCalls[0].isComplete)
|
||||
assertTrue(restored.toolCalls[1].isComplete)
|
||||
assertEquals(true, restored.toolCalls[1].success)
|
||||
assertEquals(listOf(1, 2), restored.moaReferences.map { it.index })
|
||||
assertEquals("Recovered advice", restored.moaReferences.first().text)
|
||||
assertFalse(restored.moaReferences.last().available)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
assertEquals(listOf(1), handler.messages.value.single().moaReferences.map { it.index })
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), handler.messages.value.single().moaReferences.map { it.index })
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Updated second"),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated second",
|
||||
handler.messages.value.single().moaReferences.single { it.index == 2 }.text,
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "New first"),
|
||||
)
|
||||
|
||||
val reset = handler.messages.value.single().moaReferences
|
||||
assertEquals(listOf(1), reset.map { it.index })
|
||||
assertEquals("New first", reset.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_doesNotPersistMoaReferenceBlocks() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer",
|
||||
timestamp = 1L,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Answer"))),
|
||||
)
|
||||
|
||||
assertTrue(handler.messages.value.single().moaReferences.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_preservesMoaReferencesWhileMatchingTurnIsStillLive() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Partial",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Partial"))),
|
||||
)
|
||||
|
||||
assertEquals("Transient", handler.messages.value.single().moaReferences.single().text)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||
|
||||
+4
-1
@@ -686,6 +686,7 @@ class DashboardApiClientTest {
|
||||
name = "Local",
|
||||
baseUrl = "https://llm.example/v1",
|
||||
model = "qwen",
|
||||
models = listOf("qwen", "qwen-vl", " qwen ", ""),
|
||||
apiKey = "never-persist-this",
|
||||
)
|
||||
|
||||
@@ -701,7 +702,9 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||
val save = server.takeRequest()
|
||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
||||
assertTrue(save.body.readUtf8().contains("never-persist-this"))
|
||||
val saveBody = save.body.readUtf8()
|
||||
assertTrue(saveBody.contains("never-persist-this"))
|
||||
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
|
||||
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||
|
||||
+301
-1
@@ -63,6 +63,9 @@ class GatewayClientHarness(
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
var recoveryInflightError: String? = null
|
||||
var recoveryInflightRecoverable: Boolean = false
|
||||
var recoveryAutoContinueAttempt: Int? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryQueuedUser: String? = null
|
||||
@@ -93,6 +96,12 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var reasoningDisplay = "hide"
|
||||
|
||||
@Volatile
|
||||
var approvalMode = "smart"
|
||||
|
||||
/** Config keys rejected with the older-gateway unknown-key response. */
|
||||
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@Volatile
|
||||
var askResponseStatus = "ok"
|
||||
|
||||
@@ -141,6 +150,23 @@ class GatewayClientHarness(
|
||||
)
|
||||
return
|
||||
}
|
||||
val configKey = (params["key"] as? JsonPrimitive)?.contentOrNull
|
||||
if (
|
||||
(method == "config.get" || method == "config.set") &&
|
||||
configKey in unsupportedConfigKeys
|
||||
) {
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("error", buildJsonObject {
|
||||
put("code", 4002)
|
||||
put("message", "unknown config key: $configKey")
|
||||
})
|
||||
}.toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
val result: JsonObject? = when (method) {
|
||||
"session.create" -> buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
@@ -250,6 +276,7 @@ class GatewayClientHarness(
|
||||
put("value", reasoningEffort)
|
||||
put("display", reasoningDisplay)
|
||||
}
|
||||
"approvals.mode" -> buildJsonObject { put("value", approvalMode) }
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||
@@ -268,6 +295,14 @@ class GatewayClientHarness(
|
||||
put("key", "fast")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
||||
}
|
||||
"approvals.mode" -> {
|
||||
approvalMode =
|
||||
(params["value"] as? JsonPrimitive)?.contentOrNull ?: approvalMode
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", approvalMode)
|
||||
}
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
@@ -299,16 +334,27 @@ class GatewayClientHarness(
|
||||
put("info", buildJsonObject { put("project", project) })
|
||||
}
|
||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||
if (recoveryRunning || recoveryInflightStreaming != null) {
|
||||
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
|
||||
put("inflight", buildJsonObject {
|
||||
put("user", "research this")
|
||||
put("assistant", recoveryAssistant)
|
||||
put("streaming", inflightStreaming)
|
||||
recoveryInflightError?.let { error ->
|
||||
put("status", "error")
|
||||
put("error", error)
|
||||
put("recoverable", recoveryInflightRecoverable)
|
||||
}
|
||||
})
|
||||
}
|
||||
recoveryQueuedUser?.let { user ->
|
||||
put("queued", buildJsonObject { put("user", user) })
|
||||
}
|
||||
recoveryAutoContinueAttempt?.let { attempt ->
|
||||
put("auto_continue", buildJsonObject {
|
||||
put("attempt", attempt)
|
||||
put("interrupted_at", 1_700_000_000.0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
|
||||
@@ -427,6 +473,7 @@ class GatewayChatClientTest {
|
||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val moaReferences = ConcurrentLinkedQueue<GatewayMoaReference>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val reconcileRequests = AtomicInteger(0)
|
||||
val completeLatch = CountDownLatch(1)
|
||||
@@ -447,6 +494,7 @@ class GatewayChatClientTest {
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
onToolGenerating = { toolGenerating += it ?: "" },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
@@ -491,6 +539,14 @@ class GatewayChatClientTest {
|
||||
client = buildClient(rpcTimeoutMs, promptSubmitTimeoutMs, turnIdleTimeoutMs)
|
||||
}
|
||||
|
||||
private fun waitUntil(timeoutMs: Long = 2_000L, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition() && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
harness = GatewayClientHarness()
|
||||
@@ -1642,6 +1698,146 @@ class GatewayChatClientTest {
|
||||
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval mode get and set use profile config without session yolo scope`() {
|
||||
harness.approvalMode = "smart"
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Off) }
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, fetched.getOrThrow())
|
||||
assertEquals(GatewayApprovalMode.Off, updated.getOrThrow())
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Supported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(GatewayApprovalMode.Off, client.serverApprovalMode.value)
|
||||
val getRpc = harness.awaitRpc("config.get")
|
||||
assertEquals("approvals.mode", (getRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
val setRpc = harness.awaitRpc("config.set")
|
||||
assertEquals("approvals.mode", (setRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("off", (setRpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(setRpc.containsKey("scope"))
|
||||
assertFalse(setRpc.containsKey("session_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info reconciles known approval modes and ignores unknown values`() {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject {
|
||||
put("approval_mode", "manual")
|
||||
put("desktop_contract", 3)
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil { client.serverApprovalMode.value == GatewayApprovalMode.Manual }
|
||||
assertEquals(GatewayApprovalModeCapability.Supported, client.approvalModeCapability.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "future-mode") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("desktop_contract", 2) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil {
|
||||
client.approvalModeCapability.value ==
|
||||
GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older gateway rejection disables only approval mode capability`() {
|
||||
harness.unsupportedConfigKeys += "approvals.mode"
|
||||
|
||||
val first = runBlocking { client.getApprovalMode() }
|
||||
val configGetsAfterFirst = harness.rpcLog.count { (method, _) -> method == "config.get" }
|
||||
val second = runBlocking { client.getApprovalMode() }
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
assertTrue(second.isFailure)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unsupported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(
|
||||
configGetsAfterFirst,
|
||||
harness.rpcLog.count { (method, _) -> method == "config.get" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiplexed profile approval mode is read only until upstream scopes config rpc`() {
|
||||
client.sessionProfileProvider = { "work" }
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Manual) }
|
||||
|
||||
assertTrue(fetched.isFailure)
|
||||
assertTrue(updated.isFailure)
|
||||
assertTrue(
|
||||
fetched.exceptionOrNull()?.message.orEmpty().contains("read-only"),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
harness.rpcLog.count { (method, _) ->
|
||||
method == "config.get" || method == "config.set"
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unknown,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale session info cannot overwrite approval mode after session clear`() {
|
||||
harness.approvalMode = "smart"
|
||||
assertEquals(GatewayApprovalMode.Smart, runBlocking { client.getApprovalMode() }.getOrThrow())
|
||||
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
client.clearSession()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "off") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reasoning settings update targets live session when present`() {
|
||||
val r = Recorder()
|
||||
@@ -1960,6 +2156,110 @@ class GatewayChatClientTest {
|
||||
recovery.handle!!.detach()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn exposes retained terminal failure without live handle`() {
|
||||
harness.recoveryInflightStreaming = false
|
||||
harness.recoveryAssistant = "partial answer"
|
||||
harness.recoveryInflightError = "provider failed"
|
||||
harness.recoveryInflightRecoverable = true
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertFalse(recovery.hasPendingWork)
|
||||
assertEquals("error", recovery.inflight?.status)
|
||||
assertEquals("provider failed", recovery.inflight?.error)
|
||||
assertTrue(recovery.inflight?.recoverable == true)
|
||||
assertNull(recovery.handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto continue buffers message start racing resume acknowledgement`() {
|
||||
runBlocking {
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued answer") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(1, recovery.autoContinue?.attempt)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals(listOf("continued answer"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume race delivers auto continue events through exactly one owner`() {
|
||||
runBlocking {
|
||||
// Keep the recovered live id warm so the early message.start could be
|
||||
// accepted by normal unsolicited routing while session.resume is also
|
||||
// buffering it. Recovery must exclusively claim the frame instead.
|
||||
assertTrue(client.prewarmAwait("stored-42"))
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
client.setUnsolicitedTurnProvider {
|
||||
GatewayInboundTurnRegistration(recorder.callbacks) { true }
|
||||
}
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued once") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(listOf("continued once"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps inflight and queued resume live`() {
|
||||
harness.recoveryInflightStreaming = true
|
||||
|
||||
+12
-4
@@ -5,8 +5,9 @@ import org.junit.Test
|
||||
|
||||
/**
|
||||
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
||||
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
|
||||
* and only when the dashboard probe reports Ready.
|
||||
* tier sits above the capability-preferred SSE endpoint for "auto". An
|
||||
* unresolved cold-start probe remains on Gateway until it produces a
|
||||
* definitive fallback verdict.
|
||||
*/
|
||||
class GatewayEndpointResolutionTest {
|
||||
|
||||
@@ -35,9 +36,16 @@ class GatewayEndpointResolutionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back to capability preference for every non-ready state`() {
|
||||
fun `auto stays on gateway while cold-start availability is unresolved`() {
|
||||
assertEquals(
|
||||
"gateway",
|
||||
resolveStreamingEndpointPreference("auto", GatewayAvailability.Unknown, fullCaps),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back after a definitive non-ready verdict`() {
|
||||
listOf(
|
||||
GatewayAvailability.Unknown,
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported,
|
||||
|
||||
+177
-1
@@ -19,6 +19,7 @@ class GatewayEventMapperTest {
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||
val reconciledInterims = mutableListOf<String>()
|
||||
val thinkingDeltas = mutableListOf<String>()
|
||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||
@@ -26,6 +27,7 @@ class GatewayEventMapperTest {
|
||||
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
|
||||
val toolGenerating = mutableListOf<String?>()
|
||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||
val moaReferences = mutableListOf<GatewayMoaReference>()
|
||||
val interactions = mutableListOf<GatewayAsk>()
|
||||
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
|
||||
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
|
||||
@@ -45,6 +47,7 @@ class GatewayEventMapperTest {
|
||||
onStart = { starts++ },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
||||
onInterimReconciled = { text -> reconciledInterims += text },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||
@@ -57,6 +60,7 @@ class GatewayEventMapperTest {
|
||||
onError = { errors += it },
|
||||
onToolGenerating = { toolGenerating += it },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { interactionExpiries += it },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
@@ -231,6 +235,93 @@ class GatewayEventMapperTest {
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete equal to interim reconciles one bubble`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete extending interim replaces it with full final`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed truncated final replaces longer interim`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete preserves partial and reports failed status`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj(
|
||||
"""{"text":"partial answer","status":"error","error":"provider failed","partial":true,"recoverable":true}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("partial answer"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "provider failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
assertTrue(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete without text renders error fallback`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"status":"error","error":"agent build failed","recoverable":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Error: agent build failed"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "agent build failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already streamed interim seals without replaying text`() {
|
||||
val r = Recorder()
|
||||
@@ -732,6 +823,91 @@ class GatewayEventMapperTest {
|
||||
assertTrue(r.interactions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa progress uses one transient slot and transitions to aggregating`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":2,"refs_total":3,"label":"advisor-b"}"""))
|
||||
mapper.onEvent("moa.phase", obj("""{"phase":"aggregator","refs_done":3,"refs_total":3}"""))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: 2/3 advisors complete",
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
),
|
||||
r.statusUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy moa aggregating maps to the same transient phase`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent("moa.aggregating", obj("""{"aggregator":"local:aggregate"}"""))
|
||||
|
||||
assertEquals(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
r.statusUpdates.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa references retain safe blocks and neutralize failure sentinels`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":3,"label":"advisor-b","text":"Useful second opinion"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":3,"label":"advisor-a","text":" [failed: private provider detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":3,"count":3,"label":"advisor-c","text":"[skipped: interrupted by user]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayMoaReference(2, 3, "advisor-b", "Useful second opinion"),
|
||||
GatewayMoaReference(1, 3, "advisor-a", "", available = false),
|
||||
GatewayMoaReference(3, 3, "advisor-c", "", available = false),
|
||||
),
|
||||
r.moaReferences,
|
||||
)
|
||||
assertTrue(r.moaReferences.filterNot { it.available }.all { it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all failed moa references surface only neutral unavailable state`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":2,"label":"advisor-a","text":"[failed: secret detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":2,"label":"advisor-b","text":"[skipped: recursive preset]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), r.moaReferences.mapNotNull { it.index })
|
||||
assertTrue(r.moaReferences.all { !it.available && it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message output clears active moa status`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":1,"refs_total":2}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"Final answer"}"""))
|
||||
|
||||
assertEquals(listOf(GatewayEventMapper.MOA_STATUS_KIND), r.statusClears)
|
||||
}
|
||||
|
||||
// --- Forward compat ---
|
||||
|
||||
@Test
|
||||
@@ -761,7 +937,7 @@ class GatewayEventMapperTest {
|
||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.aggregating",
|
||||
"tool.output_risk", "moa.reference", "moa.progress", "moa.phase", "moa.aggregating",
|
||||
).forEach { type ->
|
||||
// message.complete/error end the turn; use a fresh mapper for each
|
||||
mapperWith(Recorder()).onEvent(type, null)
|
||||
|
||||
@@ -230,6 +230,8 @@ class HermesApiClientTest {
|
||||
"run_events_sse": true,
|
||||
"session_resources": true,
|
||||
"session_chat_streaming": true,
|
||||
"model_options": true,
|
||||
"session_model_lock": true,
|
||||
"skills_api": true
|
||||
},
|
||||
"endpoints": {
|
||||
@@ -237,6 +239,8 @@ class HermesApiClientTest {
|
||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
||||
"model_options": {"method": "GET", "path": "/api/model/options"},
|
||||
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||
}
|
||||
@@ -249,9 +253,137 @@ class HermesApiClientTest {
|
||||
assertEquals(true, capabilities?.sessionsChatStream)
|
||||
assertEquals(true, capabilities?.portable)
|
||||
assertEquals(true, capabilities?.runs)
|
||||
assertEquals(true, capabilities?.modelOptions)
|
||||
assertEquals(true, capabilities?.sessionModelLock)
|
||||
assertEquals("sessions", capabilities?.preferredChatEndpoint())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_preserveAuthenticatedAndUnavailableInventory() {
|
||||
val parsed = parseApiProviderModelOptionsBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""
|
||||
{
|
||||
"model": "grok-4.3",
|
||||
"provider": "xai",
|
||||
"providers": [
|
||||
{
|
||||
"slug": "xai",
|
||||
"name": "xAI",
|
||||
"authenticated": true,
|
||||
"is_current": true,
|
||||
"models": ["grok-4.3", "grok-4.2"],
|
||||
"unavailable_models": ["grok-4.2"],
|
||||
"free_tier": true,
|
||||
"total_models": 2
|
||||
},
|
||||
{
|
||||
"slug": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"authenticated": false,
|
||||
"models": ["claude-opus-4-6"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("grok-4.3", parsed?.currentModel)
|
||||
assertEquals("xai", parsed?.currentProvider)
|
||||
assertEquals(listOf("grok-4.3", "grok-4.2"), parsed?.providers?.first()?.models)
|
||||
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
|
||||
assertTrue(parsed?.providers?.first()?.authenticated == true)
|
||||
assertFalse(parsed?.providers?.last()?.authenticated == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_requireProviderEnvelope() {
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, """{"data":[]}"""))
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, "not-json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelLockAck_requiresExplicitRequestedRouteAndAcceptedState() {
|
||||
val ack = parseApiModelLockAck(
|
||||
Json,
|
||||
"""
|
||||
{
|
||||
"object": "hermes.session.model_lock",
|
||||
"session_id": "session-1",
|
||||
"runtime": {
|
||||
"requested": {"model": "grok-4.3", "provider": "xai"},
|
||||
"effective": {"model": "grok-4.3", "provider": "xai"},
|
||||
"model_lock": "accepted"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("session-1", ack?.sessionId)
|
||||
assertEquals("grok-4.3", ack?.model)
|
||||
assertEquals("xai", ack?.provider)
|
||||
assertEquals("accepted", ack?.state)
|
||||
assertEquals("grok-4.3", ack?.effectiveModel)
|
||||
assertEquals("xai", ack?.effectiveProvider)
|
||||
assertNull(parseApiModelLockAck(Json, """{"session_id":"session-1"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsWithoutSessionLockUsesLegacyHintContract() {
|
||||
val capabilities = ServerCapabilities(
|
||||
sessionsApi = true,
|
||||
sessionsChatStream = true,
|
||||
runs = false,
|
||||
portable = true,
|
||||
healthy = true,
|
||||
modelOptions = true,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
|
||||
assertEquals(ApiModelRoutingStrategy.LEGACY_HINT, apiModelRoutingStrategy(capabilities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalRuntimeMustConfirmExactEffectiveRoute() {
|
||||
val expected = ApiModelSelectionAck.Locked(
|
||||
sessionId = "session-1",
|
||||
model = "fast-route",
|
||||
provider = "openai",
|
||||
effectiveModel = "gpt-5-mini",
|
||||
effectiveProvider = "openai",
|
||||
)
|
||||
val confirmed = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val wrongProvider = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"azure"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val merelyAccepted = Json.parseToJsonElement(
|
||||
"""{"model_lock":"accepted","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
|
||||
assertTrue(confirmedRuntimeMatches(confirmed, expected))
|
||||
assertFalse(confirmedRuntimeMatches(wrongProvider, expected))
|
||||
assertFalse(confirmedRuntimeMatches(merelyAccepted, expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedLockOmitsTurnModelWhileLegacyFallbackKeepsHint() {
|
||||
assertNull(
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.Locked("session-1", "grok-4.3", "xai"),
|
||||
"grok-4.3",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"fast-route",
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.LegacyModelHint("fast-route"),
|
||||
"fast-route",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
||||
val body = """{"status":"ok"}"""
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ModelOptionsResponseFenceTest {
|
||||
@Test
|
||||
fun acceptsOnlySameGenerationAndProfile() {
|
||||
assertTrue(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(3, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::beta"))
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardAuthTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: MemoryNativeTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = MemoryNativeTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilityGate_requiresAdvertisedNativeFlow() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
|
||||
assertFalse(client.supportsNativePkce(DashboardStatus(authRequired = true)))
|
||||
assertTrue(
|
||||
client.supportsNativePkce(
|
||||
DashboardStatus(authRequired = true, authFlows = listOf("cookie", "native_pkce")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beginAuthorization_usesS256StateAndStrictLoopbackRedirect() {
|
||||
val client = NativeDashboardAuthClient(server.url("/prefix").toString(), store)
|
||||
val authorization = client.beginAuthorization(
|
||||
redirectUri = "http://127.0.0.1:43123/callback",
|
||||
provider = "nous",
|
||||
)
|
||||
val url = java.net.URI(authorization.authorizationUrl)
|
||||
val query = url.rawQuery.split("&").associate {
|
||||
val pair = it.split("=", limit = 2)
|
||||
java.net.URLDecoder.decode(pair[0], "UTF-8") to
|
||||
java.net.URLDecoder.decode(pair[1], "UTF-8")
|
||||
}
|
||||
|
||||
assertEquals("/prefix/auth/native/authorize", url.path)
|
||||
assertEquals("S256", query["code_challenge_method"])
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertTrue(query.getValue("code_challenge").length >= 43)
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun beginAuthorization_rejectsHostnameLoopback() {
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
.beginAuthorization("http://localhost:43123/callback")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_validatesStateAndStoresTokens() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"access","refresh_token":"refresh","expires_at":2000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val tokens = client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=one-time-code&state=${authorization.state}",
|
||||
)
|
||||
|
||||
assertEquals("access", tokens.accessToken)
|
||||
assertEquals(tokens, store.load())
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/auth/native/token", request.path)
|
||||
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
assertEquals("one-time-code", payload.getValue("code").jsonPrimitive.content)
|
||||
val verifier = payload.getValue("code_verifier").jsonPrimitive.content
|
||||
val expectedChallenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.first { it.startsWith("code_challenge=") }
|
||||
.substringAfter("=")
|
||||
.let { java.net.URLDecoder.decode(it, Charsets.UTF_8) }
|
||||
assertEquals(expectedChallenge, authorizeChallenge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_rejectsWrongStateWithoutNetworkOrStorage() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(authorization, "/callback?code=attacker-code&state=wrong")
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, server.requestCount)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotRestoreTokensAfterSessionClear() {
|
||||
val responseStarted = CountDownLatch(1)
|
||||
val releaseResponse = CountDownLatch(1)
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
responseStarted.countDown()
|
||||
check(releaseResponse.await(5, TimeUnit.SECONDS))
|
||||
return MockResponse().setBody(
|
||||
"""{"access_token":"late","refresh_token":"late-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
val exchange = Thread {
|
||||
runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=late-code&state=${authorization.state}",
|
||||
)
|
||||
}.exceptionOrNull()?.let(failure::set)
|
||||
}.apply { start() }
|
||||
|
||||
assertTrue(responseStarted.await(5, TimeUnit.SECONDS))
|
||||
client.clearStoredSession()
|
||||
releaseResponse.countDown()
|
||||
exchange.join(5_000)
|
||||
|
||||
assertFalse(exchange.isAlive)
|
||||
assertTrue(failure.get() is java.io.IOException)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotCommitAfterAttemptCancellation() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"cancelled","refresh_token":"refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=code&state=${authorization.state}",
|
||||
commitAllowed = { false },
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedBearerPolicy_rejectsCleartextDashboardRoute() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "must-not-refresh",
|
||||
expiresAt = 1,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = "http://hermes.local:9119",
|
||||
trusted = "http://hermes.local:9119",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bearerAuth_refreshesNearExpiryAndAuthenticatesTicketRequest() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"new-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}"""))
|
||||
val client = DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
|
||||
assertEquals("ticket", result.ticket)
|
||||
val refresh = server.takeRequest()
|
||||
assertEquals("/auth/native/refresh", refresh.path)
|
||||
assertFalse(refresh.headers.names().contains("Authorization"))
|
||||
val ticket = server.takeRequest()
|
||||
assertEquals("Bearer new-access", ticket.getHeader("Authorization"))
|
||||
assertEquals("new-refresh", store.load()?.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hostileSetupOrigin_neverReceivesActiveConnectionBearer() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 3000,
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"auth_required":false}"""))
|
||||
val hostileUrl = server.url("/attacker").toString()
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = hostileUrl,
|
||||
trusted = "https://trusted.example/hermes",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
hostileUrl,
|
||||
DashboardApiClient.defaultClient(bearerAuth = bearer),
|
||||
)
|
||||
|
||||
kotlinx.coroutines.runBlocking { client.getStatus().getOrThrow() }
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(null, server.takeRequest().getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentClients_rotateSingleUseRefreshTokenExactlyOnce() {
|
||||
val shared = AtomicReference<NativeDashboardTokens?>(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "single-use-refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
val refreshCalls = AtomicInteger()
|
||||
val ticketCalls = AtomicInteger()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
|
||||
"/auth/native/refresh" -> {
|
||||
refreshCalls.incrementAndGet()
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"rotated-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
"/api/auth/ws-ticket" -> {
|
||||
ticketCalls.incrementAndGet()
|
||||
if (request.getHeader("Authorization") == "Bearer new-access") {
|
||||
MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}""")
|
||||
} else {
|
||||
MockResponse().setResponseCode(401)
|
||||
}
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
val storeA = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val storeB = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val clientA = dashboardClientWithBearer(storeA)
|
||||
val clientB = dashboardClientWithBearer(storeB)
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(2)
|
||||
val failures = java.util.Collections.synchronizedList(mutableListOf<Throwable>())
|
||||
|
||||
listOf(clientA, clientB).forEach { client ->
|
||||
Thread {
|
||||
try {
|
||||
start.await()
|
||||
kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
} catch (error: Throwable) {
|
||||
failures += error
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
start.countDown()
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(failures.toString(), failures.isEmpty())
|
||||
assertEquals(1, refreshCalls.get())
|
||||
assertEquals(2, ticketCalls.get())
|
||||
assertEquals("rotated-refresh", shared.get()?.refreshToken)
|
||||
}
|
||||
|
||||
private fun dashboardClientWithBearer(store: NativeDashboardTokenStore): DashboardApiClient =
|
||||
DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class MemoryNativeTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = "memory-${System.identityHashCode(this)}"
|
||||
private var tokens: NativeDashboardTokens? = null
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedMemoryNativeTokenStore(
|
||||
override val coordinationKey: String,
|
||||
private val shared: AtomicReference<NativeDashboardTokens?>,
|
||||
) : NativeDashboardTokenStore {
|
||||
override fun load(): NativeDashboardTokens? = shared.get()
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
shared.set(tokens)
|
||||
}
|
||||
override fun clear() {
|
||||
shared.set(null)
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardSignInCoordinatorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: CoordinatorTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = CoordinatorTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_bindsBeforeLaunch_forwardsProviderAndExchangesValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val authClient = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val coordinator = NativeDashboardSignInCoordinator(authClient)
|
||||
|
||||
val tokens = completeSignIn(coordinator, provider = "google")
|
||||
|
||||
assertEquals("access-1", tokens.accessToken)
|
||||
assertEquals(tokens, store.tokens)
|
||||
val authorizeRequest = server.takeRequest()
|
||||
assertEquals("/auth/native/token", authorizeRequest.path)
|
||||
assertTrue(authorizeRequest.body.readUtf8().contains("\"code\":\"code-1\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_ignoresWrongStateThenAcceptsValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
)
|
||||
|
||||
coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn("github") { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val redirect = URI(query(authorize)["redirect_uri"]!!)
|
||||
val state = query(authorize)["state"]!!
|
||||
|
||||
val rejected = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=attacker&state=wrong",
|
||||
)
|
||||
assertTrue(rejected.startsWith("HTTP/1.1 400"))
|
||||
assertFalse(result.isCompleted)
|
||||
|
||||
val accepted = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=$state",
|
||||
)
|
||||
assertTrue(accepted.startsWith("HTTP/1.1 200"))
|
||||
assertEquals("access-1", result.await().accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_timeoutClosesEphemeralListener() = runBlocking {
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
authClient = NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
timeoutMillis = 100,
|
||||
)
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
runBlocking {
|
||||
coordinator.signIn("google") { authorizationUrl.complete(it) }
|
||||
}
|
||||
}
|
||||
val redirect = URI(query(URI(authorizationUrl.await()))["redirect_uri"]!!)
|
||||
assertThrows(Exception::class.java) {
|
||||
Socket("127.0.0.1", redirect.port).use { }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redirectMode_requiresExactCapability_andNativeTransportRequiresHttps() {
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "native_pkce")),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "NATIVE_PKCE")),
|
||||
)
|
||||
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
|
||||
}
|
||||
|
||||
private suspend fun completeSignIn(
|
||||
coordinator: NativeDashboardSignInCoordinator,
|
||||
provider: String,
|
||||
): NativeDashboardTokens = coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn(provider) { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val authorizeQuery = query(authorize)
|
||||
assertEquals(provider, authorizeQuery["provider"])
|
||||
assertEquals("S256", authorizeQuery["code_challenge_method"])
|
||||
val redirect = URI(authorizeQuery["redirect_uri"]!!)
|
||||
assertEquals("127.0.0.1", redirect.host)
|
||||
assertTrue(redirect.port > 0)
|
||||
val response = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=${authorizeQuery["state"]}",
|
||||
)
|
||||
assertTrue(response.startsWith("HTTP/1.1 200"))
|
||||
result.await()
|
||||
}
|
||||
|
||||
private fun sendCallback(redirect: URI, target: String): String =
|
||||
Socket("127.0.0.1", redirect.port).use { socket ->
|
||||
socket.getOutputStream().write(
|
||||
"GET $target HTTP/1.1\r\nHost: 127.0.0.1:${redirect.port}\r\n\r\n"
|
||||
.toByteArray(StandardCharsets.US_ASCII),
|
||||
)
|
||||
socket.getOutputStream().flush()
|
||||
BufferedReader(InputStreamReader(socket.getInputStream())).readLine()
|
||||
}
|
||||
|
||||
private fun query(uri: URI): Map<String, String> =
|
||||
uri.rawQuery.orEmpty()
|
||||
.split('&')
|
||||
.filter(String::isNotBlank)
|
||||
.associate { part ->
|
||||
val pieces = part.split('=', limit = 2)
|
||||
URLDecoder.decode(pieces[0], StandardCharsets.UTF_8) to
|
||||
URLDecoder.decode(pieces.getOrElse(1) { "" }, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun tokenResponse(): MockResponse = MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"expires_at": 4102444800,
|
||||
"provider": "google",
|
||||
"user_id": "user-1"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private class CoordinatorTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey = "coordinator-test"
|
||||
var tokens: NativeDashboardTokens? = null
|
||||
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -152,7 +152,7 @@ class StandardHermesVoiceClientTest {
|
||||
socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
okHttpClient = DashboardApiClient.defaultClient(),
|
||||
dashboardHttpClientProvider = { DashboardApiClient.defaultClient() },
|
||||
dashboardUrlProvider = { server.url("/").toString() },
|
||||
webSocketFactory = socketFactory,
|
||||
)
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import org.junit.Assert.assertEquals
|
||||
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(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class CollapsibleAttachmentGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `summary keeps filename type and count for any attachment state`() {
|
||||
val summary = attachmentGroupSummary(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "application/pdf",
|
||||
content = "",
|
||||
fileName = "report.pdf",
|
||||
state = AttachmentState.FAILED,
|
||||
),
|
||||
Attachment(
|
||||
contentType = "application/octet-stream",
|
||||
content = "",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
AttachmentGroupSummary(
|
||||
count = 2,
|
||||
firstName = "report.pdf",
|
||||
firstType = AttachmentRenderMode.PDF,
|
||||
remainingCount = 1,
|
||||
),
|
||||
summary,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed group stays collapsed when attachment lifecycle updates`() {
|
||||
var attachments by mutableStateOf(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "result.png",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = "stable-message",
|
||||
attachments = attachments,
|
||||
) {
|
||||
Text("Attachment preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
compose.onNodeWithContentDescription("Collapse attachments").assertExists()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithContentDescription("Expand attachments").assertExists()
|
||||
|
||||
compose.runOnIdle {
|
||||
attachments = attachments.map {
|
||||
it.copy(
|
||||
content = "loaded",
|
||||
cachedUri = "content://media/result",
|
||||
state = AttachmentState.LOADED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
}
|
||||
}
|
||||
+50
@@ -3,11 +3,18 @@ package com.hermesandroid.relay.ui.components
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageGenerationPlaceholderTest {
|
||||
|
||||
@Test
|
||||
fun `duration label is stable and clamps negative elapsed time`() {
|
||||
assertEquals("12.4s", formatGenerationDuration(12_440))
|
||||
assertEquals("0.0s", formatGenerationDuration(-100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active image generation uses diffusion placeholder`() {
|
||||
val active = ToolCall(
|
||||
@@ -119,4 +126,47 @@ class ImageGenerationPlaceholderTest {
|
||||
assertTrue(early < resolved)
|
||||
assertTrue(reset < resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rubiks sphere animates exactly one outer slice at a time`() {
|
||||
(0..100).forEach { frame ->
|
||||
val angles = rubiksSliceAngles(frame / 100f)
|
||||
val activeSlices = listOf(angles.topY, angles.frontZ, angles.rightX)
|
||||
.count { kotlin.math.abs(it) > 0.0001f }
|
||||
|
||||
assertTrue("overlapping slices at frame $frame", activeSlices <= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotate preference cycles all image generation styles`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 0),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("rotate", 1),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("rotate", 2),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 3),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned image generation preference ignores rotation index`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("sphere", 99),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("nodes", 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -28,12 +29,17 @@ class ImageGenerationPlaceholderUiTest {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
ImageGenerationPlaceholder(Modifier.padding(16.dp))
|
||||
ImageGenerationPlaceholder(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
phaseOverride = 0.5f,
|
||||
elapsedOverrideMillis = 12_400,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.mainClock.advanceTimeBy(2_400)
|
||||
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
||||
compose.onNodeWithText("12.4s").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealth
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -9,6 +11,30 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardManageParityTest {
|
||||
@Test
|
||||
fun componentHealthLines_surfaceDegradedDetailsWithoutChangingReachability() {
|
||||
val lines = dashboardComponentHealthLines(
|
||||
DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = "degraded",
|
||||
components = listOf(
|
||||
DashboardComponentHealth(
|
||||
name = "platforms",
|
||||
status = "degraded",
|
||||
configured = 3,
|
||||
connected = 1,
|
||||
unhandled5xxCount5m = 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("platforms: degraded · 1/3 connected · 2 server errors / 5m"),
|
||||
lines,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
||||
val oauth = Json.parseToJsonElement(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AssistantSpeechCursorTest {
|
||||
@Test
|
||||
fun `speaks every assistant bubble created during one tool run`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
val interim = message(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "I'll check that.",
|
||||
streaming = false,
|
||||
)
|
||||
val first = cursor.poll(history + interim)
|
||||
assertEquals(listOf("I'll check that."), first.deltas.map { it.text })
|
||||
assertTrue(first.hasTurnAssistant)
|
||||
|
||||
val final = message(
|
||||
id = "final",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "The check is complete.",
|
||||
streaming = false,
|
||||
)
|
||||
val second = cursor.poll(history + interim + final)
|
||||
assertEquals(listOf("The check is complete."), second.deltas.map { it.text })
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("I'll check that.\n\nThe check is complete.", second.aggregateText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history id adoption preserves stable ui identity without replay`() {
|
||||
val live = message(
|
||||
id = "client-id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Already spoken.",
|
||||
uiKey = "stable-bubble",
|
||||
)
|
||||
val cursor = AssistantSpeechCursor(listOf(live))
|
||||
val reconciled = live.copy(id = "server-id", uiKey = "stable-bubble")
|
||||
|
||||
val batch = cursor.poll(listOf(reconciled))
|
||||
|
||||
assertTrue(batch.deltas.isEmpty())
|
||||
assertFalse(batch.hasTurnAssistant)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new bubble requests a speech boundary without trailing punctuation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "Let me check")
|
||||
val final = message("final", MessageRole.ASSISTANT, "Done.")
|
||||
|
||||
val first = cursor.poll(listOf(interim))
|
||||
val second = cursor.poll(listOf(interim, final))
|
||||
|
||||
assertFalse(first.deltas.single().startsNewBubble)
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("Done.", second.deltas.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `baseline history and repeated emissions are never replayed`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
assertTrue(cursor.poll(history).deltas.isEmpty())
|
||||
assertFalse(cursor.poll(history).hasTurnAssistant)
|
||||
|
||||
val current = history + message("new", MessageRole.ASSISTANT, "Fresh reply.")
|
||||
assertEquals(listOf("Fresh reply."), cursor.poll(current).deltas.map { it.text })
|
||||
assertTrue(cursor.poll(current).deltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only strict suffix growth is spoken after transcript reconciliation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
|
||||
cursor.poll(listOf(message("answer", MessageRole.ASSISTANT, "Working on")))
|
||||
val grown = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Working on it now.")),
|
||||
)
|
||||
assertEquals(listOf(" it now."), grown.deltas.map { it.text })
|
||||
|
||||
val rewritten = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Done.")),
|
||||
)
|
||||
assertTrue(rewritten.deltas.isEmpty())
|
||||
assertEquals("Done.", rewritten.aggregateText)
|
||||
}
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
streaming: Boolean = false,
|
||||
uiKey: String = id,
|
||||
) = ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
isStreaming = streaming,
|
||||
uiKey = uiKey,
|
||||
)
|
||||
}
|
||||
+34
@@ -177,6 +177,40 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(gatewayClient.hasActiveTurn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayRichCardActionStaysOnGatewayInsteadOfDrainingThroughSessionsApi() {
|
||||
viewModel.sseFallbackEndpoint = "sessions"
|
||||
val cardMessageId = "card-message"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = cardMessageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.onTextDelta(
|
||||
cardMessageId,
|
||||
"""
|
||||
CARD:{"type":"approval_request","id":"test-card","actions":[{"label":"Approve","value":"approve","mode":"send_text"}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
handler.onTurnComplete(cardMessageId)
|
||||
val card = handler.messages.value.single { it.id == cardMessageId }.cards.single()
|
||||
val apiRequestsBeforeAction = apiServer.requestCount
|
||||
|
||||
viewModel.dispatchCardAction(
|
||||
messageId = cardMessageId,
|
||||
cardKey = card.id!!,
|
||||
action = card.actions.single(),
|
||||
)
|
||||
|
||||
val submit = gatewayHarness.awaitRpc("prompt.submit")
|
||||
assertEquals("approve", (submit["text"] as JsonPrimitive).content)
|
||||
assertEquals(apiRequestsBeforeAction, apiServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyPersonalityCatalogLoadsAndSurvivesRefreshFailure() {
|
||||
viewModel.updateApiClient(null)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.LooperMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
@LooperMode(LooperMode.Mode.PAUSED)
|
||||
class ConnectionViewModelColdStartTest {
|
||||
private lateinit var application: Application
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
runBlocking {
|
||||
application.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `API fallback stays absent before persisted connection hydration`() {
|
||||
val viewModel = ConnectionViewModel(application)
|
||||
|
||||
assertEquals("", viewModel.apiServerUrl.value)
|
||||
assertEquals("", viewModel.effectiveApiServerUrl.value)
|
||||
assertNull(viewModel.apiClient.value)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,31 @@ import org.junit.Test
|
||||
|
||||
class EffectiveDashboardRouteTest {
|
||||
|
||||
@Test
|
||||
fun `discovered API route stays dormant when fallback is not configured`() {
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
)
|
||||
|
||||
assertEquals("", resolveEffectiveApiServerUrl("", tailscale))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API route wins after fallback is configured`() {
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
resolveEffectiveApiServerUrl("http://192.168.1.20:8642", tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected route dashboard wins over explicit primary dashboard`() {
|
||||
val connection = connection(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceTurnSessionFenceTest {
|
||||
@Test
|
||||
fun `pending new chat rejects switch to unrelated existing session`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = null)
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertTrue(fence.accepts(sessionId = null, messages = emptyList()))
|
||||
assertFalse(
|
||||
fence.accepts(
|
||||
sessionId = "existing-session",
|
||||
messages = listOf(user("other-user")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending new chat adopts only session containing submitted turn`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = null)
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
val messages = listOf(user("voice-user"))
|
||||
|
||||
assertTrue(fence.accepts(sessionId = "new-session", messages = messages))
|
||||
assertTrue(fence.accepts(sessionId = "new-session", messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = "different-session", messages = messages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing session is fixed for complete voice turn`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = "active")
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertTrue(fence.accepts(sessionId = "active", messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = null, messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
}
|
||||
|
||||
private fun user(uiKey: String) = ChatMessage(
|
||||
id = "id-$uiKey",
|
||||
role = MessageRole.USER,
|
||||
content = "Voice request",
|
||||
timestamp = 1L,
|
||||
uiKey = uiKey,
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpstreamTransportControllerAuthClientTest {
|
||||
@Test
|
||||
fun dashboardHttpClient_isReusedUntilRouteChangesThenDisposed() {
|
||||
var dashboardUrl = "https://hermes.example.test"
|
||||
val controller = UpstreamTransportController(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
activeConnectionIdProvider = { null },
|
||||
dashboardUrlProvider = { dashboardUrl },
|
||||
gatewayKeepAliveProvider = { false },
|
||||
)
|
||||
|
||||
val first = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
val reused = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
assertSame(first, reused)
|
||||
|
||||
dashboardUrl = "https://hermes.example.test/alternate"
|
||||
val moved = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
assertNotSame(first, moved)
|
||||
assertTrue(first.dispatcher.executorService.isShutdown)
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,68 @@ Use this reference for Quest visual tone, but do not force the phone layout into
|
||||
- Status logs can become compact floating diagnostic panels.
|
||||
- QR pairing should use the same state model and log language, adapted to headset camera/input constraints.
|
||||
|
||||
## Motion Language
|
||||
|
||||
Motion should explain an agent state change. It is not ambient decoration.
|
||||
For generative work, the preferred visual grammar is:
|
||||
|
||||
`unresolved material → recognizable structure → verified success → controlled dissolution`
|
||||
|
||||
The first and last frames of a loop must match exactly. Construction and
|
||||
deconstruction should use related trajectories so the animation feels
|
||||
reversible rather than resetting. Keep the background stable across the seam;
|
||||
moving lights, unmatched point positions, and phase discontinuities make even a
|
||||
technically continuous loop appear to flash.
|
||||
|
||||
### Image-generation progress
|
||||
|
||||
The image-generation treatments establish the current reference:
|
||||
|
||||
- **Grid:** pixels diffuse into and out of a clean, symmetrical field. Preserve
|
||||
the smooth pixel-wave materialization; do not expose the background through a
|
||||
nearly complete grid in a way that makes the surface look torn or dirty.
|
||||
- **Sphere:** sparse depth particles are pulled into a structured spherical
|
||||
surface and released along the same paths. The resolved form is divided into
|
||||
thirds and may perform Rubik-like outer-slice turns. Each turn owns exactly
|
||||
one valid outer layer at a time; overlapping slice animations read as broken
|
||||
geometry. Avoid jellyfish motion, wavy path lines, or a simple spin-and-breathe
|
||||
loop.
|
||||
- **Nodes:** orphan nodes and short path fragments converge into an exact,
|
||||
symmetrical lattice. Edges connect progressively only as their endpoints
|
||||
resolve. Once the complete topology is valid, show one clean success sweep
|
||||
and a restrained settle pulse before deconstruction.
|
||||
|
||||
Appearance offers **Rotate**, **Grid**, **Sphere**, and **Nodes**. Rotate is the
|
||||
default and assigns one complete treatment per generation in repeating order;
|
||||
it must not swap styles while a single image is being generated.
|
||||
|
||||
Particles should be structural material with an origin and destination, not a
|
||||
generic wallpaper effect. Each treatment can use different material—pixels,
|
||||
depth dust, nodes, or path fragments—while retaining the same
|
||||
construct/verify/deconstruct story.
|
||||
|
||||
### Result handoff
|
||||
|
||||
The progress animation and generated result are one continuous surface:
|
||||
|
||||
1. Advance the active animation to a stable resolved keyframe.
|
||||
2. Keep that resolved form mounted beneath the result.
|
||||
3. Materialize the real result through geometry related to the progress motif.
|
||||
4. Let the result take visual ownership before removing the progress layer.
|
||||
|
||||
Do not insert a blank frame, rebuild the surrounding bubble, hard-swap the
|
||||
content, or stop the animation at an arbitrary phase. The preferred image
|
||||
reveal is a smooth diagonal pixel wave, long enough for the materialization to
|
||||
be legible but without delaying access to the result.
|
||||
|
||||
### Iteration and verification
|
||||
|
||||
Animation work should use a debug-only lab reachable from Developer Options.
|
||||
The lab should offer deterministic style selection, speed controls, restart,
|
||||
and an explicit result reveal. It must not ship in release variants. Use the
|
||||
fast sideload build/deploy loop for visual tuning, then run the full Android
|
||||
gate only after the motion is accepted.
|
||||
|
||||
## Implementation Notes For Future Sessions
|
||||
|
||||
When implementing this reference:
|
||||
|
||||
+51
-1
@@ -977,7 +977,16 @@ The plan called for adding a `// VOICE HOOK` callback to `ChatViewModel` so `Voi
|
||||
- Observes the same state the chat UI observes — no divergence risk.
|
||||
- Transcribed user text routes through the existing `chatVm.sendMessage(text)` path, so voice utterances appear as normal user messages in chat history. Load the session on another device and you see the transcript.
|
||||
|
||||
**Trade-off documented in a KDoc comment:** relies on the "last `isStreaming=true` message is the current turn" invariant. If `ChatViewModel` ever streams multiple assistant messages concurrently (multi-agent hand-off, for example) this needs a dedicated per-turn flow. Flagged for Phase 3+ review.
|
||||
**Follow-up (2026-07-25):** the observer no longer relies on a single
|
||||
`isStreaming=true` assistant message. A per-turn cursor follows every new
|
||||
assistant bubble until the run-level `ChatViewModel.isStreaming` state ends,
|
||||
so tool handoffs and the final answer are narrated in order. The cursor fences
|
||||
the pre-turn stable UI identities and submitted user-turn/session identity,
|
||||
and only speaks strict content suffix growth, preventing StateFlow/history
|
||||
reconciliation or a pending-new-chat session switch from replaying old or
|
||||
rewritten text. Each newly observed assistant bubble also inserts a speech
|
||||
boundary, so an interim fragment without punctuation cannot run into the final
|
||||
answer.
|
||||
|
||||
### Alternatives explicitly rejected
|
||||
|
||||
@@ -2120,6 +2129,9 @@ stable identity independent of endpoint URLs.
|
||||
- **Routing is automatic.** Chat prefers Dashboard/Gateway and falls back to the
|
||||
API server only when configured and usable. Users choose a transport only in
|
||||
advanced diagnostics or compatibility settings, not during normal setup.
|
||||
Endpoint discovery may advertise a conventional API route, but does not enable
|
||||
that optional fallback unless the connection has persisted API configuration;
|
||||
cold-start state remains unconfigured until that persisted value is hydrated.
|
||||
|
||||
**Product flow.** Normal onboarding asks for one Hermes address, discovers the
|
||||
Dashboard/Gateway, authenticates through its supported provider, and finishes
|
||||
@@ -2149,3 +2161,41 @@ An API endpoint or Relay can be added later without recreating the connection.
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt`
|
||||
- `docs/upstream-surface-matrix.md`
|
||||
|
||||
---
|
||||
|
||||
## ADR 39 — Android dashboard redirect auth uses native PKCE
|
||||
|
||||
**Status:** Accepted (2026-07-25).
|
||||
|
||||
**Context.** Android originally completed redirect-provider dashboard sign-in
|
||||
inside a WebView and imported cookies. Current upstream Gateway can advertise a
|
||||
native authorization-code flow with PKCE, bearer refresh, and WebSocket ticket
|
||||
support. That contract allows the provider to use the user's browser session
|
||||
without exposing browser cookies to the app.
|
||||
|
||||
**Decision.** When `/api/status.auth_flows` contains `native_pkce`, Android uses
|
||||
an AndroidX Custom Tab and a lifecycle-owned callback bound to literal
|
||||
`127.0.0.1` on an OS-assigned port. The selected provider, S256 challenge,
|
||||
redirect URI, and CSRF state are sent to upstream. Verifier/state remain only
|
||||
in the sign-in coroutine; callback input is bounded and state-validated before
|
||||
errors or codes are accepted. Tokens are encrypted per connection and attached
|
||||
only to the exact trusted dashboard base. Native bearer exchange requires
|
||||
HTTPS, except literal loopback development. Missing capability selects the
|
||||
legacy cookie/WebView flow; native failures do not silently downgrade.
|
||||
|
||||
All dashboard consumers share the same authenticated client policy: Gateway
|
||||
chat and tickets, Manage and cold prewarm, standard voice, and voice config.
|
||||
Local sign-out clears cookies and native tokens and closes the cached Gateway
|
||||
socket.
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- Redirect-provider sign-in remains in the app task while using the system
|
||||
browser's provider session and security posture.
|
||||
- Process death or cancellation discards the ephemeral authorization and simply
|
||||
requires a new attempt.
|
||||
- Plain-LAN HTTP dashboards must be upgraded to HTTPS before native bearer auth
|
||||
is offered.
|
||||
- Older upstream versions remain usable through the explicitly identified
|
||||
WebView compatibility path.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "8d2bfde6d800b7838e1a31415c9b05cac80a65872417e3db8bb6d18941cd7f1e",
|
||||
"main": "0c143c2f1440b5b4f5684d3a5db7481d304f61e93654aad5ff140b191dd8a82d",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
+24
-2
@@ -170,6 +170,17 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
|
||||
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
|
||||
in a Custom Tab and owns a single ephemeral callback on
|
||||
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
|
||||
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
|
||||
per connection and are attached only to the exact trusted dashboard base for
|
||||
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
|
||||
for HTTPS dashboard addresses (plus literal loopback for development). A
|
||||
gateway without the capability uses the legacy cookie/WebView flow; a failed
|
||||
native attempt never silently downgrades.
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
As of **v3 (ADR 24)**, the QR can also carry an ordered list of **endpoint candidates** (`lan` / `tailscale` / `public` / operator-defined roles). A single pairing covers every network the phone might be on — the phone picks the highest-priority reachable candidate at connect time and re-probes on network change. The single-URL top-level fields still appear in v3 QRs for backward compatibility; old phones ignore `endpoints` via `ignoreUnknownKeys = true`, new phones prefer `endpoints` and fall back to the top-level URL when the array is absent. See [`docs/remote-access.md`](remote-access.md) for the operator-facing setup per mode.
|
||||
@@ -378,6 +389,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Empty state** — Logo + "Start a conversation" + suggestion chips that populate input
|
||||
- **Agent sheet — Profile section (v0.6.0, updated 2026-05-18)** — upstream Hermes profiles auto-discovered by the relay at `~/.hermes/profiles/*/`. Selecting one routes chat/session calls to that profile's advertised `api_server_url` when present, giving proper Hermes isolation for sessions, memory, tools, provider auth, and SOUL/default model. If no profile API route is advertised, the app falls back to overlaying `model` + `SOUL.md` (as `system_message`) on the active Connection's API server. Selection is persisted per Connection/profile context. Hidden when the server advertises no profiles. See `docs/decisions.md` §21.
|
||||
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
|
||||
- **Agent sheet — Approval controls** — gateway contract v3 exposes the profile-persisted `approvals.mode` policy (`manual` / `smart` / `off`) separately from YOLO. The launch/default profile gets the three-way control; multiplexed non-launch profiles reconcile `session.info.approval_mode` read-only until upstream config RPCs honor profile scope. The existing YOLO switch remains an explicit per-session override and never silently writes profile configuration. Older gateways keep chat and YOLO available while the profile control explains that an upstream update is required.
|
||||
- **Streaming dots** — animated pulsing 3-dot indicator replaces static "streaming..." text
|
||||
- Displays: streaming delta text, tool progress cards (auto-expand while running, auto-collapse on complete), thinking/reasoning blocks (collapsible), per-message token counts + cost
|
||||
|
||||
@@ -551,11 +563,21 @@ MEDIA:hermes-relay://<url-safe-16-byte-token>
|
||||
2. `ChatViewModel` inserts a LOADING `Attachment` with `relayToken` set immediately (message updates via `ChatHandler.mutateMessage`).
|
||||
3. On Wi-Fi, or on cellular when `autoFetchOnCellular` is true: `RelayHttpClient.fetchMedia(token)` issues `GET /media/{token}` with the bearer header. URL is derived by swapping `ws://`→`http://`, `wss://`→`https://` on the stored relay URL.
|
||||
4. Bytes are checked against `maxInboundSizeMb`. If oversize → FAILED placeholder. Otherwise `MediaCacheWriter` writes them to `context.cacheDir/hermes-media/<sha1>.<ext>` with LRU eviction by mtime (capped at `cachedMediaCapMb`) and returns a `content://` URI via `FileProvider.getUriForFile(context, "${applicationId}.fileprovider", file)`.
|
||||
5. The Attachment is flipped to LOADED with `cachedUri` set. `InboundAttachmentCard` dispatches by `(state × renderMode)`: `IMAGE` renders inline via `BitmapFactory.decodeByteArray` + `asImageBitmap`; `VIDEO`/`AUDIO`/`PDF`/`TEXT`/`GENERIC` render as tap-to-open file cards firing `ACTION_VIEW` with `FLAG_GRANT_READ_URI_PERMISSION` on the cached URI.
|
||||
5. The Attachment is flipped to LOADED with `cachedUri` set. `InboundAttachmentCard` dispatches by `(state × renderMode)`: `IMAGE` renders inline via `BitmapFactory.decodeByteArray` + `asImageBitmap`; `VIDEO`/`AUDIO`/`PDF`/`TEXT`/`GENERIC` render as tap-to-open file cards firing `ACTION_VIEW` with `FLAG_GRANT_READ_URI_PERMISSION` on the cached URI. Every message attachment group, including galleries and LOADING/FAILED cards, sits behind a compact collapse/expand header keyed by the message's stable UI identity. Collapsing changes presentation only: the header keeps the attachment count, first name/type, and restore affordance visible while existing retry, fetch, viewer, share, and save behavior remains mounted again after expansion.
|
||||
6. On cellular with `autoFetchOnCellular` off: the attachment stays in LOADING state with `errorMessage = "Tap to download"`, and `manualFetchAttachment()` re-runs the fetch ignoring the cellular gate.
|
||||
|
||||
**Fallback when relay isn't running:** the tool's `register_media()` call fails (connection refused / timeout / non-200) → tool logs a warning and returns the legacy bare-path form (`MEDIA:/tmp/...`). The phone's `onUnavailableMediaMarker` handler inserts a FAILED Attachment with `errorMessage = "Image unavailable — relay offline"`. Matches current behavior; placeholder is tidier than raw marker text.
|
||||
|
||||
Persisted USER history may also contain upstream-owned `@image:<absolute-path>`
|
||||
directive lines. Android recognizes only bounded, full-line image directives
|
||||
(including upstream's backtick/single-quote/double-quote path wrapping), removes
|
||||
recognized host paths from visible text, and reconstructs at most eight
|
||||
attachments. A paired Relay may resolve those paths through its authenticated
|
||||
media route; a vanilla or unavailable route renders a path-free failed
|
||||
attachment. Inline, relative, malformed, non-image, and unknown directives stay
|
||||
as text and never trigger a fetch. Client-local outbound attachments win during
|
||||
the immediate post-send reload, preventing a duplicate fetch/gallery entry.
|
||||
|
||||
**Known gap — session replay across relay restarts:** the `MediaRegistry` is in-memory. Restarting the relay invalidates all tokens. A user scrolling back into a session from yesterday sees FAILED placeholders for any now-stale token. Phone-side persistent cache (indexed by token or content hash) is the planned fix; filed as a DEVLOG follow-up.
|
||||
|
||||
**Known gap — auto-fetch threshold slider isn't enforced today.** The Settings → Inbound media → auto-fetch threshold knob is persisted but the fetch path currently only checks the cellular toggle + the hard max cap. Forward-compatibility placeholder; real enforcement needs a HEAD preflight or post-hoc byte rejection.
|
||||
@@ -836,7 +858,7 @@ utilities.
|
||||
- `VoiceRecorder` (`AudioRecord` / WAV / 16 kHz mono PCM) exposes both a STT upload file and raw PCM bytes for the realtime websocket input events.
|
||||
- `VoicePlayer` (Media3 ExoPlayer + Visualizer) remains the fallback `/voice/synthesize` playback surface.
|
||||
- `RealtimePcmPlayer` streams `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*` PCM deltas directly to `AudioTrack`.
|
||||
- `VoiceViewModel` state machine (`Idle / Listening / Transcribing / Thinking / Speaking / Error`). Assistant text is sanitized (markdown / tool-annotations / URLs / emoji-set stripped) on each delta before a coalescing chunker (`MIN_COALESCE_LEN=40`, `MAX_BUFFER_LEN=400` secondary-break escape, 800 ms timer flush) emits sentence-scale chunks. The default queue calls `/voice/output/*` for exact renderer PCM playback; failed output turns fall back to the existing `/voice/synthesize` synth/play workers. The same stream observer watches Hermes-owned `ToolCall` state and speaks bounded status lines for running tools; execution, approval, and tool results remain in the Hermes chat/relay loop.
|
||||
- `VoiceViewModel` state machine (`Idle / Listening / Transcribing / Thinking / Speaking / Error`). Assistant text is sanitized (markdown / tool-annotations / URLs / emoji-set stripped) on each delta before a coalescing chunker (`MIN_COALESCE_LEN=40`, `MAX_BUFFER_LEN=400` secondary-break escape, 800 ms timer flush) emits sentence-scale chunks. The observer aggregates every assistant bubble created by one Hermes run, including interim tool handoffs and the final answer, and finishes speech only when the run-level stream ends. Stable bubble identity and submitted-turn/session fences prevent StateFlow/history reconciliation or a pending-new-chat session switch from speaking stale or duplicate text. Bubble boundaries flush incomplete prior text so adjacent narration cannot run together. The default queue calls `/voice/output/*` for exact renderer PCM playback; failed output turns fall back to the existing `/voice/synthesize` synth/play workers. The same stream observer watches Hermes-owned `ToolCall` state and speaks bounded status lines for running tools; execution, approval, and tool results remain in the Hermes chat/relay loop.
|
||||
- Server-side, `/voice/synthesize` runs a matching sanitizer (`plugin/relay/tts_sanitizer.py`) before handing text to the upstream `text_to_speech_tool` — defense-in-depth for any client that doesn't pre-sanitize.
|
||||
- **Barge-in** (opt-in, default off). While in `Speaking`, a `BargeInListener` runs a duplex `AudioRecord` (16 kHz mono PCM, `VOICE_COMMUNICATION` source) feeding 32 ms frames through a Silero VAD (`com.github.gkonovalov:android-vad:silero`). `AcousticEchoCanceler` + `NoiseSuppressor` attach to the ExoPlayer audio session so TTS output doesn't retrigger VAD. A single raw speech frame → `VoicePlayer.duck()` (volume 0.3f) with a 500 ms un-duck watchdog. `N` consecutive frames (2–3, sensitivity-tuned) → `interruptSpeaking()` (same cancellation path V4 wired for user taps). A 600 ms watchdog on `VoiceRecorder.amplitude` then decides: if the user keeps talking, new turn proceeds normally; if silence wins AND `resumeAfterInterruption=true`, `VoiceViewModel` re-enqueues the unplayed chunks from `spokenChunks[lastInterruptedAtChunkIndex+1..]` and flips back to `Speaking`. Settings UI exposes `BargeInPreferences` (enabled / sensitivity ∈ `Off/Low/Default/High` / resume) with an `AcousticEchoCanceler.isAvailable()`-driven compatibility badge.
|
||||
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
|
||||
|
||||
@@ -15,6 +15,7 @@ security-crypto = "1.1.0"
|
||||
tink-android = "1.23.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
browser = "1.9.0"
|
||||
appcompat = "1.7.1"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
@@ -54,6 +55,7 @@ lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-view
|
||||
|
||||
# Activity
|
||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||
browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
|
||||
# Core
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run fast Android checks before pushing a PR update.
|
||||
|
||||
The command checks the primary Play debug variant and focused CI unit tests in
|
||||
one Gradle invocation. Hosted CI remains the exhaustive all-variant gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
FOCUSED_TESTS = (
|
||||
"com.hermesandroid.relay.network.ArchitectureBoundaryTest",
|
||||
"com.hermesandroid.relay.network.relay.RelayUrlDeriverTest",
|
||||
"com.hermesandroid.relay.viewmodel.ConnectionSwitchTest",
|
||||
"com.hermesandroid.relay.util.ServerAddressTest",
|
||||
"com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest",
|
||||
"com.hermesandroid.relay.data.AppLanguageTest",
|
||||
"com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest",
|
||||
"com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest",
|
||||
"com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest",
|
||||
"com.hermesandroid.relay.voice.VoiceCommandInterpreterTest",
|
||||
"com.hermesandroid.relay.data.VoiceModePresetTest",
|
||||
"com.hermesandroid.relay.ui.components.BackgroundTaskCardTest",
|
||||
"com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest",
|
||||
"com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest",
|
||||
"com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest",
|
||||
"com.hermesandroid.relay.ui.screens.ChatUnreadStateTest",
|
||||
)
|
||||
REPOSITORY_CHECKS = (
|
||||
"check-android-locales.py",
|
||||
"check-user-docs-locales.py",
|
||||
"check-android-collection-apis.py",
|
||||
"check-version-tracks.py",
|
||||
)
|
||||
|
||||
|
||||
def run(label: str, command: list[str], env: dict[str, str]) -> None:
|
||||
print(f"\n==> {label}", flush=True)
|
||||
completed = subprocess.run(command, cwd=REPO_ROOT, env=env, check=False)
|
||||
if completed.returncode:
|
||||
raise SystemExit(f"{label} failed with exit code {completed.returncode}")
|
||||
|
||||
|
||||
def android_environment() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
if env.get("ANDROID_HOME") or env.get("ANDROID_SDK_ROOT"):
|
||||
return env
|
||||
|
||||
if sys.platform == "win32":
|
||||
local_app_data = env.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
sdk = pathlib.Path(local_app_data) / "Android" / "Sdk"
|
||||
if sdk.is_dir():
|
||||
env["ANDROID_HOME"] = str(sdk)
|
||||
env["ANDROID_SDK_ROOT"] = str(sdk)
|
||||
print(f"Using Android SDK at {sdk}")
|
||||
return env
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--skip-lint", action="store_true", help="Skip Android lint")
|
||||
parser.add_argument("--skip-tests", action="store_true", help="Skip the focused unit-test shard")
|
||||
args = parser.parse_args()
|
||||
|
||||
env = android_environment()
|
||||
for script in REPOSITORY_CHECKS:
|
||||
run(script, [sys.executable, str(REPO_ROOT / "scripts" / script)], env)
|
||||
|
||||
tasks: list[str] = []
|
||||
if not args.skip_lint:
|
||||
tasks.append(":app:lintGooglePlayDebug")
|
||||
if not args.skip_tests:
|
||||
tasks.append(":app:testSideloadDebugUnitTest")
|
||||
if not tasks:
|
||||
print("\nAndroid repository checks passed.")
|
||||
return 0
|
||||
|
||||
wrapper = REPO_ROOT / ("gradlew.bat" if sys.platform == "win32" else "gradlew")
|
||||
gradle = [
|
||||
str(wrapper),
|
||||
"--console=plain",
|
||||
"--configuration-cache",
|
||||
"-Dorg.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8",
|
||||
*tasks,
|
||||
]
|
||||
if not args.skip_tests:
|
||||
for test_name in FOCUSED_TESTS:
|
||||
gradle.extend(("--tests", test_name))
|
||||
run("Google Play lint and focused tests", gradle, env)
|
||||
print("\nAndroid pre-push checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,6 +12,7 @@ if "%1"=="install" goto install
|
||||
if "%1"=="run" goto run
|
||||
if "%1"=="test" goto test
|
||||
if "%1"=="lint" goto lint
|
||||
if "%1"=="prepush" goto prepush
|
||||
if "%1"=="clean" goto clean
|
||||
if "%1"=="devices" goto devices
|
||||
if "%1"=="version" goto version
|
||||
@@ -77,6 +78,11 @@ echo Running lint...
|
||||
call gradlew.bat lint
|
||||
goto end
|
||||
|
||||
:prepush
|
||||
echo Running Android pre-push checks...
|
||||
python scripts\android-prepush.py
|
||||
goto end
|
||||
|
||||
:clean
|
||||
echo Cleaning build...
|
||||
call gradlew.bat clean
|
||||
@@ -126,6 +132,7 @@ echo install Build + install to connected device
|
||||
echo run Build + install + launch + logcat
|
||||
echo test Run unit tests
|
||||
echo lint Run lint checks
|
||||
echo prepush Run Android repository checks, lint, and focused CI tests
|
||||
echo clean Clean build outputs
|
||||
echo devices List connected devices
|
||||
echo version Show current version from libs.versions.toml
|
||||
|
||||
@@ -37,6 +37,10 @@ case "${1:-help}" in
|
||||
echo "Running lint..."
|
||||
./gradlew lint
|
||||
;;
|
||||
prepush)
|
||||
echo "Running Android pre-push checks..."
|
||||
python3 scripts/android-prepush.py
|
||||
;;
|
||||
clean)
|
||||
echo "Cleaning build..."
|
||||
./gradlew clean
|
||||
@@ -78,6 +82,7 @@ case "${1:-help}" in
|
||||
echo " run Build + install + launch + logcat"
|
||||
echo " test Run unit tests"
|
||||
echo " lint Run lint checks"
|
||||
echo " prepush Run Android repository checks, lint, and focused CI tests"
|
||||
echo " clean Clean build outputs"
|
||||
echo " devices List connected devices"
|
||||
echo " wireless Pair for wireless debugging"
|
||||
|
||||
Reference in New Issue
Block a user