fix(android): support upstream batch Clarify requests

This commit is contained in:
Bailey Dixon
2026-09-12 19:59:06 -04:00
parent b71fea701f
commit 061512d330
42 changed files with 1442 additions and 65 deletions
+1
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- Android answers upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress preserved across reconnects. (#474)
- Android shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
## [Android 1.16.1] - 2026-09-12
@@ -0,0 +1,116 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.ui.components.MessageBubble
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Production socket, ViewModel, transcript, Compose and IME actions on a virtual device. */
class ClarifyBatchInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var scope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var viewModel: ChatViewModel
private lateinit var handler: ChatHandler
private lateinit var socket: WebSocket
@Before fun setUp() {
fixture = AndroidGatewayContractFixture()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), http),
okHttpClient = http, scope = scope,
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) },
)
handler = ChatHandler().also { it.setSessionId("20260821_120000_fixture") }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoader { Result.success(emptyList()) }
it.updateGatewayClient(gateway)
}
compose.setContent {
val messages by handler.messages.collectAsStateWithLifecycle()
MaterialTheme {
LazyColumn(Modifier.fillMaxSize()) {
items(messages.size, key = { messages[it].id }) { index ->
MessageBubble(messages[index], showTimestamps = false,
onCardInput = viewModel::answerAsk, animationEnabled = false)
}
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait("20260821_120000_fixture") })
socket = fixture.awaitServerSocket()
}
@After fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
scope.cancel()
fixture.shutdown()
}
@Test fun confirmedProgressSurvivesLifecycleAndCustomAnswerUsesIme() {
compose.runOnIdle { viewModel.sendMessage("Ask two questions") }
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("clarify.request", Json.parseToJsonElement("""
{"request_id":"batch-device","questions":[
{"qid":"route/a","question":"Which route?","choices":["Canary","Immediate"]},
{"qid":"notes:b","question":"Anything else?","choices":null}
]}
""") as JsonObject, "fixture-live-1"))
compose.waitUntil(10_000) { viewModel.pendingAsk.value != null }
compose.onNodeWithText("Canary").performClick()
compose.waitUntil(10_000) { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
assertEquals(JsonPrimitive("route/a"), fixture.awaitRpc("clarify.respond")["question_id"])
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithText("Question 2 of 2").assertIsDisplayed()
compose.onNodeWithContentDescription("Type an answer…").apply {
performClick()
performTextInput(" Keep rollback ready ")
performImeAction()
}
compose.waitUntil(10_000) { viewModel.pendingAsk.value == null }
compose.onNodeWithText("All questions answered").assertIsDisplayed()
assertEquals(2, fixture.rpcCount("clarify.respond"))
assertEquals(1, fixture.rpcCount("prompt.submit"))
}
}
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@@ -135,6 +136,9 @@ data class ChatTurnAskCheckpoint(
val text: String,
val choices: List<String>? = null,
val multiSelect: Boolean = false,
val questions: List<GatewayClarifyQuestion> = emptyList(),
val answers: Map<String, String> = emptyMap(),
val ownerId: String? = null,
val smartDenied: Boolean = false,
val envVar: String? = null,
val timeoutSeconds: Int,
@@ -71,6 +71,8 @@ data class HermesCard(
* actions.
*/
val input: HermesCardInput? = null,
/** Local Gateway batch; never an ordinary chat-message answer protocol. */
val clarifyBatch: HermesCardClarifyBatch? = null,
) {
object BuiltInTypes {
const val SKILL_RESULT = "skill_result"
@@ -96,6 +98,25 @@ data class HermesCard(
}
}
@Serializable
data class HermesCardClarifyBatch(
val questions: List<HermesCardClarifyQuestion>,
val expiresAtMillis: Long? = null,
)
@Serializable
data class HermesCardClarifyQuestion(
val key: String,
val question: String,
val input: HermesCardInput,
val answer: String? = null,
val submitting: Boolean = false,
)
/** Local callback identity. The RPC always uses the original qid, never this UI key. */
fun clarifyQuestionCardKey(cardKey: String, qid: String): String =
Json.encodeToString(listOf(cardKey, qid))
/**
* Interactive input slot on a [HermesCard]. The flags compose rather than
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
@@ -518,6 +518,16 @@ class ChatHandler {
}
}
/** Refresh only an existing local ask, preserving its dispatches and transcript position. */
fun updateAskCardMessage(messageId: String, card: HermesCard) {
_messages.update { list ->
list.map { message ->
if (message.clientOnly && message.matchesIdentity(messageId)) message.copy(cards = listOf(card))
else message
}
}
}
/**
* Edit-and-regenerate local truncation: drop [messageId] and everything
* after it. The gateway performs the authoritative truncation via
@@ -1586,6 +1586,7 @@ class GatewayChatClient(
claimedBackground?.pendingAsk?.let { ask ->
boundTurn.restoreInteraction(ask)
}
boundTurn.restorePendingClarify(response)
boundTurn.armWatchdog()
} else if (queued != null) {
synchronized(recoveryEventLock) { recoveryEvents = null }
@@ -1746,17 +1747,39 @@ class GatewayChatClient(
)
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> {
suspend fun respondClarify(
requestId: String,
answer: String,
questionId: String? = null,
): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
if (questionId != null) {
val ask = respondingTurn?.pendingInteraction
// A lost acknowledgement is ambiguous until activation replays server progress.
// Never overwrite an accepted answer while reconnect is still reconciling it.
if (rejoinInProgress || ask?.kind != GatewayAsk.Kind.CLARIFY || ask.requestId != requestId ||
ask.questions.none { it.qid == questionId } || ask.ownershipToken.retired.get() ||
questionId in ask.ownershipToken.answers.get()
) return Result.failure(GatewayRpcException("Clarification is not ready for this question"))
}
val generation = respondingTurn?.interactionGeneration
val ownershipToken = respondingTurn?.pendingInteraction?.ownershipToken
return rpc(
"clarify.respond",
buildJsonObject {
put("request_id", requestId)
put("answer", answer)
questionId?.let { put("question_id", it) }
},
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
if (generation != null) {
respondingTurn.acknowledgeClarify(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, generation)
}
val currentTurn = activeTurn
if (ownershipToken != null && currentTurn !== respondingTurn) {
currentTurn?.acknowledgeClarifyOwner(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, ownershipToken)
}
}
}
}
@@ -3854,7 +3877,10 @@ class GatewayChatClient(
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
if (interactionRequest != null) {
val previous = backgroundTurn.pendingAsk
backgroundTurn.pendingAsk = interactionRequest
backgroundTurn.pendingAsk = if (previous?.kind == interactionRequest.kind &&
previous.requestId == interactionRequest.requestId && interactionRequest.questions.isNotEmpty()
) interactionRequest.withAnswers(previous.answers + interactionRequest.answers, previous)
else interactionRequest
if (previous?.kind != interactionRequest.kind ||
previous.requestId != interactionRequest.requestId
) {
@@ -3881,6 +3907,7 @@ class GatewayChatClient(
// be replayed or buffered. Only an authoritative expiry retires a
// detached ask; an explicit response is retired by its foreground VM.
if (explicitlyExpired) {
pendingAsk.ownershipToken.retired.set(true)
backgroundTurn.pendingAsk = null
callbackDispatcher {
backgroundInteractionListener?.invoke(
@@ -3998,6 +4025,9 @@ class GatewayChatClient(
}
return
}
if (type == "session.info" && eventSessionId != null && eventSessionId == liveSessionId) {
payload?.let(turn::restorePendingClarify)
}
turn.onEvent(type, payload)
if (turn.ended) {
if (activeTurn === turn) activeTurn = null
@@ -4204,6 +4234,7 @@ class GatewayChatClient(
activated.isSuccess -> {
activated.getOrNull()?.let { result ->
applySessionResultInfo(result)
turn.restorePendingClarify(result)
turn.settleFromAuthoritativeSessionState(
running = result.booleanField("running"),
source = "session.activate",
@@ -4506,6 +4537,19 @@ class GatewayChatClient(
fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
mapper.acknowledgeInteraction(expiry)
}
val interactionGeneration: Long get() = mapper.interactionGeneration
fun acknowledgeClarify(requestId: String, questionId: String?, answer: String, expired: Boolean, generation: Long) {
mapper.acknowledgeClarify(requestId, questionId, answer, expired, generation)
}
fun acknowledgeClarifyOwner(requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership) {
mapper.acknowledgeClarifyOwner(requestId, questionId, answer, expired, owner)
}
fun restorePendingClarify(snapshot: JsonObject) {
val payload = snapshot["pending_clarify"] as? JsonObject
?: (snapshot["info"] as? JsonObject)?.get("pending_clarify") as? JsonObject
?: return
GatewayEventMapper.interactionRequest("clarify.request", payload)?.let(mapper::restoreInteraction)
}
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
@@ -32,19 +32,66 @@ class GatewayEventMapper(
var turnEnded: Boolean = false
private set
@get:Synchronized
internal val currentInteraction: GatewayAsk?
get() = pendingInteraction
@Volatile internal var interactionGeneration: Long = 0
private set
internal fun restoreInteraction(ask: GatewayAsk) {
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
pendingInteraction = ask
if (!duplicate) callbacks.onInteractionRequest(ask)
@Synchronized internal fun restoreInteraction(ask: GatewayAsk) {
val previous = pendingInteraction
val duplicate = previous?.sameRequestAs(ask) == true
if (!duplicate) interactionGeneration++
val merged = if (duplicate && ask.questions.isNotEmpty()) {
ask.withAnswers(previous.answers + ask.answers, previous)
} else if (ask.questions.isNotEmpty()) ask.withAnswers(emptyMap()) else ask
if (merged.ownershipToken.retired.get() && !merged.clarifyComplete) {
if (duplicate) pendingInteraction = null
callbacks.onInteractionExpired(GatewayAskExpiry(merged.kind, merged.requestId))
if (pendingInteraction == null) drainDeferredTerminalEvent()
return
}
pendingInteraction = merged
if (!duplicate || previous != merged) callbacks.onInteractionRequest(merged)
if (merged.clarifyComplete) {
pendingInteraction = null
drainDeferredTerminalEvent()
}
}
@Synchronized internal fun acknowledgeClarify(
requestId: String,
questionId: String?,
answer: String,
expired: Boolean,
generation: Long = interactionGeneration,
) {
if (generation != interactionGeneration) return
val pending = pendingInteraction ?: return
if (pending.kind != GatewayAsk.Kind.CLARIFY || pending.requestId != requestId) return
if (!expired && questionId != null && pending.questions.none { it.qid == questionId }) return
if (!expired && questionId != null && pending.questions.any { it.qid == questionId }) {
val updated = pending.withAnswers(pending.answers + (questionId to answer))
if (updated.questions.any { it.qid !in updated.answers }) {
pendingInteraction = updated
return
}
}
acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
}
@Synchronized internal fun acknowledgeClarifyOwner(
requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership,
) {
if (pendingInteraction?.ownershipToken !== owner) return
acknowledgeClarify(requestId, questionId, answer, expired)
}
/** Retire only the ask whose explicit respond RPC reached server truth. */
internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
@Synchronized internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
val pending = pendingInteraction ?: return
if (pending.matches(expiry)) {
pending.ownershipToken.retired.set(true)
pendingInteraction = null
drainDeferredTerminalEvent()
}
@@ -77,7 +124,7 @@ class GatewayEventMapper(
*/
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
fun onEvent(type: String, payload: JsonObject?) {
@Synchronized fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return
interactionRequest(type, payload)?.let { ask ->
@@ -88,6 +135,7 @@ class GatewayEventMapper(
interactionExpiry(type, payload)?.let { expiry ->
val pending = pendingInteraction
if (pending != null && pending.matches(expiry)) {
pending.ownershipToken.retired.set(true)
pendingInteraction = null
}
callbacks.onInteractionExpired(expiry)
@@ -503,26 +551,7 @@ class GatewayEventMapper(
}
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> {
val choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
?.filter { it.isNotEmpty() }
?.distinct()
?.take(MAX_CLARIFY_CHOICES)
?.takeIf { it.isNotEmpty() }
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = choices,
multiSelect = payload.boolean("multi_select") == true && choices != null,
// Current upstream owns expiry through clarify.expire and
// does not advertise its configurable deadline. Never
// invent a local deadline; consume future additive
// metadata only when it is present and positive.
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
)
}
"clarify.request" -> clarifyRequest(payload)
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
@@ -555,6 +584,41 @@ class GatewayEventMapper(
else -> null
}
private fun clarifyRequest(payload: JsonObject?): GatewayAsk? {
val rawQuestions = payload?.get("questions")
if (rawQuestions != null && rawQuestions !is JsonArray) return null
val questions = rawQuestions?.map { value ->
val row = value as? JsonObject ?: return null
val qid = (row["qid"] as? JsonPrimitive)?.takeIf { it.isString }
?.contentOrNull?.takeIf(String::isNotBlank) ?: return null
val text = row.string("question")?.takeIf(String::isNotBlank) ?: return null
val choices = (row["choices"] as? JsonArray)?.mapNotNull {
(it as? JsonPrimitive)?.takeIf { option -> option.isString }
?.contentOrNull?.takeIf(String::isNotBlank)
}.orEmpty().take(MAX_CLARIFY_CHOICES)
GatewayClarifyQuestion(qid, text, choices, row.boolean("multi_select") == true && choices.isNotEmpty())
}.orEmpty()
if (questions.size > MAX_CLARIFY_QUESTIONS || questions.map { it.qid }.distinct().size != questions.size) return null
val choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
?.filter(String::isNotEmpty)?.distinct()?.take(MAX_CLARIFY_CHOICES)
?.takeIf { it.isNotEmpty() }
return GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = choices,
multiSelect = payload.boolean("multi_select") == true && choices != null,
// Upstream owns its configurable deadline; only consume advertised metadata.
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
questions = questions,
answers = (payload?.get("answers") as? JsonObject)?.mapNotNull { (qid, value) ->
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
?.takeIf { questions.any { q -> q.qid == qid } }?.let { qid to it }
}?.toMap().orEmpty(),
)
}
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
"clarify.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
@@ -634,6 +698,7 @@ class GatewayEventMapper(
// Upstream clarify tool accepts at most four choices. Sudo/secret retain fixed
// `_block()` timeouts; clarify is configurable and expires authoritatively.
private const val MAX_CLARIFY_CHOICES = 4
private const val MAX_CLARIFY_QUESTIONS = 5
private const val SUDO_TIMEOUT_SECONDS = 120
private const val SECRET_TIMEOUT_SECONDS = 300
@@ -2,11 +2,14 @@ package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
@@ -230,10 +233,35 @@ data class GatewayAsk(
* authoritative `*.expire` event still retires the interaction.
*/
val timeoutSeconds: Int,
val questions: List<GatewayClarifyQuestion> = emptyList(),
val answers: Map<String, String> = emptyMap(),
) {
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
val clarifyComplete: Boolean get() = questions.isNotEmpty() && questions.all { it.qid in answers }
/** In-memory request incarnation shared when a live ask moves between turn mappers. */
internal var ownershipToken = GatewayAskOwnership(answers)
private set
internal fun withAnswers(answers: Map<String, String>, owner: GatewayAsk = this): GatewayAsk =
copy(answers = owner.ownershipToken.answers.updateAndGet { it + answers })
.also { it.ownershipToken = owner.ownershipToken }
}
/** A detached/reclaimed mapper shares confirmed progress with an RPC still owned by its predecessor. */
internal class GatewayAskOwnership(answers: Map<String, String>) {
val answers = AtomicReference(answers.toMap())
val retired = AtomicBoolean(false)
}
@Serializable
data class GatewayClarifyQuestion(
val qid: String,
val question: String,
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
/**
* Server-side expiry of one blocking gateway interaction. Sudo/secret asks
* correlate by [requestId]; approvals remain session-scoped and therefore
@@ -0,0 +1,103 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.HermesCardClarifyBatch
import kotlinx.serialization.json.Json
/** One mobile question surface; confirmed qids advance without resubmitting earlier answers. */
@Composable
internal fun ClarifyBatchContent(
batch: HermesCardClarifyBatch,
expired: Boolean,
onInputSubmit: (String, String) -> Unit,
) {
val answered = batch.questions.filter { it.answer != null }
val activeIndex = batch.questions.indexOfFirst { it.answer == null }
val active = batch.questions.getOrNull(activeIndex)
var showAnswers by rememberSaveable { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
LaunchedEffect(active?.key, expired) { focusManager.clearFocus() }
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = when {
expired -> stringResource(R.string.clarify_batch_expired)
active == null -> stringResource(R.string.clarify_batch_complete)
else -> stringResource(R.string.clarify_batch_progress, activeIndex + 1, batch.questions.size)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
if (batch.questions.size > 1) {
LinearProgressIndicator(
progress = { answered.size.toFloat() / batch.questions.size },
modifier = Modifier.fillMaxWidth(),
)
}
if (active != null && !expired) {
key(active.key) {
Text(
active.question,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.semantics { heading() },
)
CardInputSlot(
input = active.input,
onSubmit = { if (!active.submitting) onInputSubmit(active.key, it) },
enabled = !active.submitting,
stackedChoices = true,
)
if (active.submitting) {
Text(stringResource(R.string.clarify_batch_sending), style = MaterialTheme.typography.labelMedium)
}
}
}
if (answered.isNotEmpty()) {
if (active == null || expired) {
Text(stringResource(R.string.clarify_batch_answered, answered.size), style = MaterialTheme.typography.labelLarge)
} else {
TextButton(onClick = { showAnswers = !showAnswers }) {
Text(stringResource(R.string.clarify_batch_answered, answered.size))
}
}
if (showAnswers || active == null || expired) {
answered.forEach { question ->
Text(question.question, style = MaterialTheme.typography.labelLarge)
val answer = if (question.input.multiSelect) {
runCatching { Json.decodeFromString<List<String>>(question.answer.orEmpty()).joinToString(", ") }
.getOrDefault(question.answer.orEmpty())
} else question.answer.orEmpty()
Text(answer, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
}
}
}
}
}
@@ -59,6 +59,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -145,7 +146,7 @@ fun HermesCardBubble(
// Expiry clock for timed asks. Ticks once a second while the deadline
// is ahead; freezes after. Keyed on the deadline so a re-used card id
// with a fresh expiry restarts the loop.
val expiresAt = card.input?.expiresAtMillis
val expiresAt = card.input?.expiresAtMillis ?: card.clarifyBatch?.expiresAtMillis
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(expiresAt) {
if (expiresAt == null) return@LaunchedEffect
@@ -252,6 +253,14 @@ fun HermesCardBubble(
// action button all collapse the same way.
val input = card.input
when {
card.clarifyBatch != null -> {
Spacer(Modifier.height(10.dp))
ClarifyBatchContent(
batch = card.clarifyBatch,
expired = expired || alreadyChosen?.actionValue == HermesCardDispatch.EXPIRED_STAMP,
onInputSubmit = onInputSubmit,
)
}
alreadyChosen != null -> {
Spacer(Modifier.height(10.dp))
val chosenAction = card.actions.firstOrNull {
@@ -408,15 +417,18 @@ private fun ChoseRow(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CardInputSlot(
internal fun CardInputSlot(
input: HermesCardInput,
onSubmit: (String) -> Unit,
enabled: Boolean = true,
stackedChoices: Boolean = false,
) {
// Deliberately remember, not rememberSaveable — a typed secret must
// never be written into the saved-instance-state Bundle.
var answerText by remember { mutableStateOf("") }
// Batch drafts survive lazy-item disposal. Secrets never enter saved state.
var answerText by if (stackedChoices && !input.masked) rememberSaveable { mutableStateOf("") }
else remember { mutableStateOf("") }
var reveal by remember { mutableStateOf(false) }
var selectedChoices by remember(input.choices) { mutableStateOf(emptyList<String>()) }
var selectedChoices by if (stackedChoices) rememberSaveable(input.choices) { mutableStateOf(emptyList<String>()) }
else remember(input.choices) { mutableStateOf(emptyList<String>()) }
val isMultiSelect = input.multiSelect && input.choices.isNotEmpty()
val showFreeText = !input.masked && (
@@ -429,7 +441,7 @@ private fun CardInputSlot(
val submitFreeText = {
val customAnswer = answerText.trim()
if (customAnswer.isNotEmpty()) {
if (enabled && customAnswer.isNotEmpty()) {
onSubmit(
if (isMultiSelect) {
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
@@ -443,14 +455,13 @@ private fun CardInputSlot(
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val choices: @Composable () -> Unit = {
input.choices.forEach { choice ->
if (isMultiSelect) {
val selected = choice in selectedChoices
FilterChip(
enabled = enabled,
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
selected = selected,
onClick = {
selectedChoices = if (selected) {
@@ -459,7 +470,11 @@ private fun CardInputSlot(
selectedChoices + choice
}
},
label = { Text(choice, style = MaterialTheme.typography.labelMedium) },
label = {
Text(choice,
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
},
leadingIcon = if (selected) {
{
Icon(
@@ -477,9 +492,13 @@ private fun CardInputSlot(
)
} else {
AssistChip(
enabled = enabled,
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
onClick = { onSubmit(choice) },
label = {
Text(choice, style = MaterialTheme.typography.labelMedium)
Text(choice,
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -489,6 +508,11 @@ private fun CardInputSlot(
}
}
}
if (stackedChoices) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { choices() }
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { choices() }
}
}
// Masked secret field
@@ -534,12 +558,13 @@ private fun CardInputSlot(
else R.string.card_answer_placeholder,
),
onSubmit = submitFreeText,
enabled = enabled,
modifier = Modifier.weight(1f),
)
if (!isMultiSelect) {
IconButton(
onClick = submitFreeText,
enabled = answerText.isNotBlank(),
enabled = enabled && answerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
@@ -559,7 +584,7 @@ private fun CardInputSlot(
Spacer(Modifier.height(10.dp))
Button(
onClick = { onSubmit(encodeClarifyMultiSelectAnswer(answers)) },
enabled = answers.isNotEmpty(),
enabled = enabled && answers.isNotEmpty(),
) {
Text(
stringResource(R.string.card_submit),
@@ -615,6 +640,7 @@ private fun InlineAnswerField(
placeholder: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val shape = appearanceRoundedCornerShape(16.dp)
Box(
@@ -632,6 +658,7 @@ private fun InlineAnswerField(
)
}
BasicTextField(
enabled = enabled,
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
@@ -639,7 +666,7 @@ private fun InlineAnswerField(
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
keyboardActions = KeyboardActions(onSend = { onSubmit() }, onDone = { onSubmit() }),
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
@@ -72,6 +72,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.data.HermesCardClarifyBatch
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
import com.hermesandroid.relay.data.clarifyQuestionCardKey
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
@@ -3652,7 +3655,7 @@ class ChatViewModel : ViewModel() {
private val backgroundPendingInteractions =
ConcurrentHashMap<TurnCheckpointKey, BackgroundPendingInteraction>()
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
/** Request-incarnation/card keys with a respond RPC in flight. */
private val answeredAskIds = mutableSetOf<String>()
/**
@@ -6774,7 +6777,18 @@ class ChatViewModel : ViewModel() {
existing.ask.kind == ask.kind &&
existing.ask.requestId == ask.requestId
) {
sessionId?.let { maybeNotifyInteraction(it, existing.ask) }
if (ask.questions.isNotEmpty()) {
val updated = existing.copy(ask = ask.copy(answers = existing.ask.answers + ask.answers))
_pendingAsk.value = updated
updateClarifyBatchCard(handler, updated)
if (updated.ask.clarifyComplete) {
_pendingAsk.value = null
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
}
scheduleCheckpointWrite(immediate = true)
}
_pendingAsk.value?.let { pending -> sessionId?.let { maybeNotifyInteraction(it, pending.ask) } }
return
}
existing?.let { pending ->
@@ -6787,8 +6801,11 @@ class ChatViewModel : ViewModel() {
publishBackgroundSessionActivity()
}
val now = restored?.receivedAt ?: System.currentTimeMillis()
val cardKey = restored?.cardKey ?: ask.requestId
val proposedCardKey = restored?.cardKey ?: ask.requestId
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
val cardKey = if (restored == null && handler.messages.value.any { message ->
message.cards.any { it.id == proposedCardKey }
}) "$proposedCardKey-${java.util.UUID.randomUUID()}" else proposedCardKey
val expiresAt = ask.timeoutSeconds.takeIf { it > 0 }?.let { now + it * 1_000L }
val card = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> HermesCard(
@@ -6887,12 +6904,52 @@ class ChatViewModel : ViewModel() {
contextKey = contextKey,
sessionId = sessionId,
receivedAt = now,
ownerId = restored?.ownerId ?: java.util.UUID.randomUUID().toString(),
)
if (ask.questions.isNotEmpty()) updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
if (ask.clarifyComplete) {
_pendingAsk.value = null
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
scheduleCheckpointWrite(immediate = true)
return
}
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
scheduleCheckpointWrite(immediate = true)
sessionId?.let { maybeNotifyInteraction(it, ask) }
}
private fun updateClarifyBatchCard(handler: ChatHandler, pending: PendingAsk) {
val ask = pending.ask
if (ask.questions.isEmpty()) return
handler.updateAskCardMessage(
pending.messageId,
HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = appContext?.getString(R.string.chat_approval_clarify_title) ?: "Hermes needs clarification",
accent = HermesCard.Accents.INFO,
id = pending.cardKey,
clarifyBatch = HermesCardClarifyBatch(
questions = ask.questions.map { question ->
val key = clarifyQuestionCardKey(pending.cardKey, question.qid)
HermesCardClarifyQuestion(
key = key,
question = question.question,
input = HermesCardInput(
kind = if (question.choices.isEmpty()) HermesCardInput.Kinds.TEXT else HermesCardInput.Kinds.CHOICE,
choices = question.choices,
multiSelect = question.multiSelect,
allowFreeText = true,
),
answer = ask.answers[question.qid],
submitting = "${pending.ownerId}:$key" in answeredAskIds,
)
},
expiresAtMillis = ask.timeoutSeconds.takeIf { it > 0 }?.let { pending.receivedAt + it * 1_000L },
),
),
)
}
/**
* Supervised Chat never exposes approval, clarification, sudo, or secret
* inputs. Settle the upstream interaction immediately with its safest
@@ -6966,21 +7023,35 @@ class ChatViewModel : ViewModel() {
fun answerAsk(messageId: String, cardKey: String, value: String) {
val handler = chatHandler ?: return
val pending = _pendingAsk.value
val question = pending?.ask?.questions?.firstOrNull {
clarifyQuestionCardKey(pending.cardKey, it.qid) == cardKey
}
if (pending == null ||
pending.cardKey != cardKey ||
pending.messageId != messageId ||
(if (pending.ask.questions.isNotEmpty()) question == null else pending.cardKey != cardKey) ||
pending.contextKey != activeProfileContextKey ||
pending.sessionId != handler.currentSessionId.value
) {
handler.addSystemNotice("This request is no longer active.")
return
}
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && value.isBlank()) return
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && pending.ask.timeoutSeconds > 0 &&
System.currentTimeMillis() >= pending.receivedAt + pending.ask.timeoutSeconds * 1_000L
) {
expirePendingAsk(GatewayAskExpiry(pending.ask.kind, pending.ask.requestId))
return
}
if (question != null && question.qid in pending.ask.answers) return
val gateway = gatewayClient
if (gateway == null) {
emitError(Exception("Gateway is not connected"), context = "send_message")
return
}
// In-flight guard: one respond RPC per card at a time.
if (!answeredAskIds.add(cardKey)) return
// Include the request incarnation so a late completion cannot unlock a reused id.
val flightKey = "${pending.ownerId}:$cardKey"
if (!answeredAskIds.add(flightKey)) return
updateClarifyBatchCard(handler, pending)
val ask = pending.ask
val stampValue = when (ask.kind) {
// Empty sudo password = decline — stamp matches the Deny action
@@ -6992,11 +7063,19 @@ class ChatViewModel : ViewModel() {
else -> value
}
viewModelScope.launch {
fun ownsResponse(): Boolean = chatHandler === handler && gatewayClient === gateway &&
activeProfileContextKey == pending.contextKey &&
handler.currentSessionId.value == pending.sessionId &&
_pendingAsk.value?.ownerId == pending.ownerId
if (!ownsResponse()) {
answeredAskIds.remove(flightKey)
return@launch
}
val requestId = ask.requestId
val result = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = value)
GatewayAsk.Kind.CLARIFY ->
requestId?.let { gateway.respondClarify(it, value) }
requestId?.let { gateway.respondClarify(it, value.trim(), question?.qid) }
?: Result.failure(GatewayRpcException("ask has no request id"))
GatewayAsk.Kind.SUDO ->
requestId?.let { gateway.respondSudo(it, value) }
@@ -7007,6 +7086,8 @@ class ChatViewModel : ViewModel() {
}
result.fold(
onSuccess = { response ->
answeredAskIds.remove(flightKey)
if (!ownsResponse()) return@fold
if (response == GatewayAskResponse.EXPIRED) {
expirePendingAsk(
GatewayAskExpiry(
@@ -7016,10 +7097,24 @@ class ChatViewModel : ViewModel() {
)
return@fold
}
if (question != null) {
val current = requireNotNull(_pendingAsk.value)
val updated = current.copy(ask = current.ask.copy(answers = current.ask.answers + (question.qid to value.trim())))
updateClarifyBatchCard(handler, updated)
if (updated.ask.questions.all { it.qid in updated.ask.answers }) {
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
_pendingAsk.value = null
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
} else {
_pendingAsk.value = updated
}
scheduleCheckpointWrite(immediate = true)
return@fold
}
// Collapse only after the server confirms — a failed RPC
// must leave the card answerable for a retry.
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
if (_pendingAsk.value === pending) {
if (ownsResponse()) {
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
@@ -7029,7 +7124,9 @@ class ChatViewModel : ViewModel() {
}
},
onFailure = { e ->
answeredAskIds.remove(cardKey)
answeredAskIds.remove(flightKey)
if (!ownsResponse()) return@fold
updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
emitError(e, context = "send_message")
},
)
@@ -7054,7 +7151,6 @@ class ChatViewModel : ViewModel() {
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
answeredAskIds.remove(pending.cardKey)
scheduleCheckpointWrite(immediate = true)
chatHandler?.recordCardDispatch(
pending.messageId,
@@ -7077,7 +7173,9 @@ class ChatViewModel : ViewModel() {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
scheduleCheckpointWrite(immediate = true)
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY) {
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, HermesCardDispatch.EXPIRED_STAMP)
} else if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, "deny")
}
}
@@ -7685,6 +7783,9 @@ class ChatViewModel : ViewModel() {
text = ask.ask.text,
choices = ask.ask.choices,
multiSelect = ask.ask.multiSelect,
questions = ask.ask.questions,
answers = ask.ask.answers,
ownerId = ask.ownerId,
smartDenied = ask.ask.smartDenied,
envVar = ask.ask.envVar,
timeoutSeconds = ask.ask.timeoutSeconds,
@@ -7893,6 +7994,8 @@ class ChatViewModel : ViewModel() {
text = saved.text,
choices = saved.choices,
multiSelect = saved.multiSelect,
questions = saved.questions,
answers = saved.answers,
smartDenied = saved.smartDenied,
envVar = saved.envVar,
timeoutSeconds = saved.timeoutSeconds,
@@ -11843,6 +11946,7 @@ data class PendingAsk(
/** Stored session id within [contextKey]; approvals are session-scoped upstream. */
val sessionId: String?,
val receivedAt: Long = System.currentTimeMillis(),
val ownerId: String = java.util.UUID.randomUUID().toString(),
)
/**
@@ -1,5 +1,10 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="clarify_batch_progress">Pergunta %1$d de %2$d</string>
<string name="clarify_batch_complete">Todas as perguntas respondidas</string>
<string name="clarify_batch_expired">Esta solicitação terminou</string>
<string name="clarify_batch_sending">Enviando resposta…</string>
<string name="clarify_batch_answered">Perguntas respondidas (%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">interface do agente</string>
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clarify_batch_progress">问题 %1$d / %2$d</string>
<string name="clarify_batch_complete">所有问题均已回答</string>
<string name="clarify_batch_expired">此请求已结束</string>
<string name="clarify_batch_sending">正在发送回答…</string>
<string name="clarify_batch_answered">已回答的问题(%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">代理界面</string>
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clarify_batch_progress">Frage %1$d von %2$d</string>
<string name="clarify_batch_complete">Alle Fragen beantwortet</string>
<string name="clarify_batch_expired">Diese Anfrage ist beendet</string>
<string name="clarify_batch_sending">Antwort wird gesendet…</string>
<string name="clarify_batch_answered">Beantwortete Fragen (%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">Agentenoberfläche</string>
+5
View File
@@ -1,5 +1,10 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="clarify_batch_progress">Pregunta %1$d de %2$d</string>
<string name="clarify_batch_complete">Todas las preguntas respondidas</string>
<string name="clarify_batch_expired">Esta solicitud ha finalizado</string>
<string name="clarify_batch_sending">Enviando respuesta…</string>
<string name="clarify_batch_answered">Preguntas respondidas (%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">interfaz del agente</string>
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clarify_batch_progress">質問 %1$d / %2$d</string>
<string name="clarify_batch_complete">すべての質問に回答しました</string>
<string name="clarify_batch_expired">このリクエストは終了しました</string>
<string name="clarify_batch_sending">回答を送信中…</string>
<string name="clarify_batch_answered">回答済みの質問(%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">エージェントインターフェース</string>
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clarify_batch_progress">Вопрос %1$d из %2$d</string>
<string name="clarify_batch_complete">Все вопросы отвечены</string>
<string name="clarify_batch_expired">Этот запрос завершён</string>
<string name="clarify_batch_sending">Отправка ответа…</string>
<string name="clarify_batch_answered">Отвеченные вопросы (%1$d)</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">интерфейс агента</string>
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="clarify_batch_progress">Question %1$d of %2$d</string>
<string name="clarify_batch_complete">All questions answered</string>
<string name="clarify_batch_expired">This request has ended</string>
<string name="clarify_batch_sending">Sending answer…</string>
<string name="clarify_batch_answered">Answered questions (%1$d)</string>
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
@@ -56,6 +56,21 @@ class ChatTurnCheckpointStoreTest {
assertEquals(checkpoint, store.read())
}
@Test
fun partialClarifyBatch_roundTripsWithExactQuestionOwnership() = runTest {
val checkpoint = sampleCheckpoint().copy(pendingAsk = ChatTurnAskCheckpoint(
kind = "CLARIFY", requestId = "batch", text = "", timeoutSeconds = 0,
messageId = "ask-batch", cardKey = "batch", receivedAt = now,
questions = listOf(
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("route/a", "Which route?", listOf("Canary")),
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("environment:b", "Which environments?", listOf("Stage", "Production"), true),
), answers = mapOf("route/a" to "Canary"),
))
store.write(checkpoint)
assertEquals(checkpoint, store.read())
assertEquals(mapOf("route/a" to "Canary"), store.read()?.pendingAsk?.answers)
}
@Test
fun corruptJson_isDiscarded() = runTest {
dataStore.edit { preferences ->
@@ -74,6 +74,8 @@ class GatewayClientHarness(
@Volatile
var recoveryRunning = false
@Volatile
var recoveryClarify: JsonObject? = null
@Volatile
var recoveryAssistant = ""
@@ -586,6 +588,7 @@ class GatewayClientHarness(
private val autoRespondEnabled = autoRespond
private fun recoveryPayload(sessionId: String, requestedProfile: String? = null): JsonObject = buildJsonObject {
recoveryClarify?.let { put("pending_clarify", it) }
put("session_id", sessionId)
put("running", recoveryRunning)
put("status", if (recoveryRunning) "streaming" else "idle")
@@ -3165,6 +3168,75 @@ class GatewayChatClientTest {
// --- Ask responders ---
@Test
fun `batch clarify reconnect adopts server answered qids without resubmitting`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.recoveryRunning = true
harness.recoveryClarify = harness.json.parseToJsonElement("""{
"request_id":"batch","questions":[{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}],
"answers":{"q0":"accepted before disconnect"}}
""") as JsonObject
socket.close(1001, "fixture reconnect")
val replacement = harness.awaitServerSocket()
harness.awaitRpc("session.activate")
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (r.interactions.isEmpty() && System.nanoTime() < deadline) Thread.sleep(10)
assertEquals(mapOf("q0" to "accepted before disconnect"), r.interactions.last().answers)
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
assertTrue(runBlocking { client.respondClarify("batch", "second", "q1") }.isSuccess)
replacement.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"))
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
}
@Test
fun `batch clarify responds with exact question id and JSON array answer`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
socket.send(harness.eventFrame("clarify.request", harness.json.parseToJsonElement("""
{"request_id":"batch","questions":[{"qid":"env:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}]}
""") as JsonObject, "live-1"))
awaitCondition { r.interactions.isNotEmpty() }
val answer = "[\"Stage\",\"Production\"]"
assertTrue(runBlocking { client.respondClarify("batch", answer, "env:b") }.isSuccess)
val respond = harness.awaitRpc("clarify.respond")
assertEquals("batch", (respond["request_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("env:b", (respond["question_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals(answer, (respond["answer"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `batch answer waits for reconnect replay and refuses an already answered qid`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val request = harness.json.parseToJsonElement("""{"request_id":"batch","questions":[
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}""") as JsonObject
socket.send(harness.eventFrame("clarify.request", request, "live-1"))
awaitCondition { r.interactions.isNotEmpty() }
harness.recoveryRunning = true
harness.suppressAckMethods += "session.activate"
socket.close(1001, "fixture lost acknowledgement")
harness.awaitServerSocket()
val activation = harness.awaitPendingAck()
assertEquals("session.activate", activation.method)
assertTrue(runBlocking { client.respondClarify("batch", "retry", "q0") }.isFailure)
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
harness.releaseAck(activation, buildJsonObject {
put("session_id", "live-1")
put("running", true)
put("pending_clarify", JsonObject(request + ("answers" to buildJsonObject { put("q0", "accepted") })))
})
awaitCondition { r.interactions.last().answers["q0"] == "accepted" }
assertTrue(runBlocking { client.respondClarify("batch", "overwrite", "q0") }.isFailure)
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
}
@Test
fun `clarify respond carries request id and answer`() {
val r = Recorder()
@@ -16,6 +16,103 @@ import org.junit.Test
*/
class GatewayEventMapperTest {
@Test
fun `acknowledgement before reclaim remains in the shared request snapshot`() {
val original = mapperWith(Recorder())
original.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}"""))
val detachedSnapshot = requireNotNull(original.currentInteraction)
original.acknowledgeClarify("batch", "q0", "first", false)
val recovered = mapperWith(Recorder())
recovered.restoreInteraction(detachedSnapshot)
assertEquals(mapOf("q0" to "first"), recovered.currentInteraction?.answers)
recovered.onEvent("message.complete", obj("""{"text":"done"}"""))
recovered.acknowledgeClarify("batch", "q1", "second", false)
assertTrue(recovered.turnEnded)
}
@Test
fun `expired detached snapshot cannot become pending again`() {
val original = mapperWith(Recorder())
original.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[{"qid":"q0","question":"First?"}]}"""))
val snapshot = requireNotNull(original.currentInteraction)
original.acknowledgeClarify("batch", "q0", "ignored", true)
val r = Recorder()
val recovered = mapperWith(r)
recovered.restoreInteraction(snapshot)
assertNull(recovered.currentInteraction)
assertTrue(r.interactions.isEmpty())
assertEquals("batch", r.interactionExpiries.single().requestId)
}
@Test
fun `forwarded acknowledgement cannot touch a newer request incarnation`() {
val mapper = mapperWith(Recorder())
val request = obj("""{"request_id":"batch","questions":[{"qid":"q0","question":"First?"}]}""")
mapper.onEvent("clarify.request", request)
val oldOwner = requireNotNull(mapper.currentInteraction).ownershipToken
mapper.onEvent("clarify.expire", obj("""{"request_id":"batch"}"""))
mapper.onEvent("clarify.request", request)
mapper.acknowledgeClarifyOwner("batch", "q0", "stale", true, oldOwner)
assertTrue(requireNotNull(mapper.currentInteraction).answers.isEmpty())
assertFalse(mapper.turnEnded)
}
@Test
fun `partial acknowledgement holds terminal and merges replay without resurrecting an answer`() {
val r = Recorder()
val mapper = mapperWith(r)
val request = obj("""{"request_id":"batch","questions":[
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}""")
mapper.onEvent("clarify.request", request)
mapper.onEvent("message.complete", obj("""{"text":"done"}"""))
mapper.acknowledgeClarify("batch", "q0", "first", false)
assertFalse(mapper.turnEnded)
assertEquals(mapOf("q0" to "first"), mapper.currentInteraction?.answers)
mapper.onEvent("clarify.request", request)
assertEquals(mapOf("q0" to "first"), mapper.currentInteraction?.answers)
mapper.acknowledgeClarify("old-batch", "q1", "stale", true)
assertFalse(mapper.turnEnded)
mapper.acknowledgeClarify("batch", "q1", "second", false)
assertNull(mapper.currentInteraction)
assertTrue(mapper.turnEnded)
}
@Test
fun `batch expiry retires partial progress only for exact request`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}],"answers":{"q0":"done"}}"""))
mapper.onEvent("clarify.expire", obj("""{"request_id":"other"}"""))
assertEquals("done", mapper.currentInteraction?.answers?.get("q0"))
mapper.onEvent("clarify.expire", obj("""{"request_id":"batch"}"""))
assertNull(mapper.currentInteraction)
}
@Test
fun `batch clarify preserves exact qids question order choices and replayed answers`() {
val ask = GatewayEventMapper.interactionRequest("clarify.request", obj("""
{"request_id":"batch","questions":[
{"qid":"choice/a","question":"Which deployment?","choices":["Canary","Immediate"],"multi_select":false},
{"qid":"env:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}
],"answers":{"choice/a":"Canary","foreign":"ignored"}}
"""))!!
assertEquals(listOf("choice/a", "env:b"), ask.questions.map { it.qid })
assertEquals("Which deployment?", ask.questions[0].question)
assertEquals(listOf("Stage", "Production"), ask.questions[1].choices)
assertTrue(ask.questions[1].multiSelect)
assertEquals(mapOf("choice/a" to "Canary"), ask.answers)
}
@Test
fun `one entry normalized clarify keeps its qid`() {
val ask = GatewayEventMapper.interactionRequest("clarify.request", obj("""
{"request_id":"one","questions":[{"qid":"q0","question":"Which file?","choices":null}]}
"""))!!
assertEquals(listOf("q0"), ask.questions.map { it.qid })
}
private class Recorder {
val textDeltas = mutableListOf<String>()
val interimMessages = mutableListOf<Pair<String, Boolean>>()
@@ -0,0 +1,105 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardClarifyBatch
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import java.io.File
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)
class ClarifyBatchScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactDark() = capture("compact-dark")
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactLightLargeText() = capture("compact-light-font-1_5", theme = "light", scale = 1.5f)
@Test @Config(qualifiers = "w720dp-h360dp-xhdpi")
fun landscape() = capture("landscape", width = 540)
@Test @Config(qualifiers = "w330dp-h720dp-xhdpi")
fun narrowFoldable() = capture("foldable-pane", scale = 1.5f)
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
fun partialProgress() = capture("partial", answered = 1)
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
fun sending() = capture("sending", answered = 1, submitting = true)
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
fun completed() = capture("completed", answered = 2)
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
fun expiredPartial() = capture("expired-partial", answered = 1, expired = true)
private fun capture(
name: String, theme: String = "dark", scale: Float = 1f, width: Int = 300,
answered: Int = 0, submitting: Boolean = false, expired: Boolean = false,
) {
val options = listOf(
"Keep both systems running while traffic moves in measured stages (Recommended)",
"Migrate everything immediately and accept a short maintenance window",
"Pause until every downstream consumer has been verified",
"Use a reversible canary rollout with automatic rollback thresholds",
)
val questions = listOf(
HermesCardClarifyQuestion("q0", "Which deployment approach should I use for the migration, given the existing clients and the rollback requirements?",
HermesCardInput(HermesCardInput.Kinds.CHOICE, options, allowFreeText = true),
answer = options[0].takeIf { answered > 0 }),
HermesCardClarifyQuestion("q1", "Which environments should receive this change?",
HermesCardInput(HermesCardInput.Kinds.CHOICE, listOf("Staging", "Production"), multiSelect = true, allowFreeText = true),
answer = "[\"Staging\",\"Production\"]".takeIf { answered > 1 }, submitting = submitting),
)
val card = HermesCard(type = HermesCard.BuiltInTypes.ASK_CLARIFY, title = "Hermes needs clarification",
id = "batch", clarifyBatch = HermesCardClarifyBatch(questions, expiresAtMillis = if (expired) 1L else null))
compose.setContent {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, scale)) {
HermesRelayTheme(themePreference = theme) {
LazyColumn(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(10.dp)) {
item {
MessageBubble(
message = ChatMessage(id = "batch-message", role = MessageRole.ASSISTANT, content = "",
timestamp = 0L, cards = listOf(card), clientOnly = true),
maxBubbleWidth = width.dp, showTimestamps = false, animationEnabled = false,
)
}
}
}
}
}
val directory = File("build/ui-evidence/clarify-batch").apply { mkdirs() }
compose.onRoot().captureRoboImage(File(directory, "$name-top.png").path)
repeat(4) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
compose.onRoot().captureRoboImage(File(directory, "$name-bottom.png").path)
}
}
@@ -0,0 +1,120 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
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.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardClarifyBatch
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
import com.hermesandroid.relay.data.HermesCardInput
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 ClarifyBatchInteractionTest {
@get:Rule val compose = createComposeRule()
@Test fun draftsAndSelectionSurviveSavedStateAndFocusFollowsQuestionOrder() {
val restoration = StateRestorationTester(compose)
lateinit var inputMode: InputModeManager
restoration.setContent {
inputMode = LocalInputModeManager.current
MaterialTheme {
HermesCardBubble(card().copy(clarifyBatch = HermesCardClarifyBatch(listOf(question))),
"batch", emptyList(), { _, _ -> }, { _, _ -> })
}
}
compose.onNodeWithText("Stage").performClick()
compose.onNodeWithContentDescription("Other (type your answer)…").performTextInput("Keep rollback")
restoration.emulateSavedInstanceStateRestore()
compose.onNodeWithText("Stage").assertIsSelected()
compose.onNodeWithText("Keep rollback").assertExists()
compose.runOnIdle { inputMode.requestInputMode(InputMode.Keyboard) }
compose.onNodeWithText("Stage").performSemanticsAction(SemanticsActions.RequestFocus) { it() }
compose.onNodeWithText("Stage").assertIsFocused()
compose.onNodeWithText("Stage").performKeyInput { pressKey(Key.Tab) }
compose.onNodeWithText("Production").assertIsFocused()
}
@Test fun multiSelectImeRetryKeepsChoicesAndDraftAndDisablesDuplicateActions() {
var submitting by mutableStateOf(false)
val answers = mutableListOf<Pair<String, String>>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = card().copy(clarifyBatch = HermesCardClarifyBatch(listOf(question.copy(submitting = submitting)))),
cardKey = "batch", dispatches = emptyList(), onActionTap = { _, _ -> },
onInputSubmit = { key, value -> answers += key to value; submitting = true },
)
}
}
compose.onNodeWithText("Submit").assertIsNotEnabled()
compose.onNodeWithContentDescription("Other (type your answer)…").apply {
performTextInput(" ")
performImeAction()
}
compose.runOnIdle { assertEquals(0, answers.size) }
compose.onNodeWithText("Stage").performClick().assertIsSelected()
compose.onNodeWithContentDescription("Other (type your answer)…").apply {
performTextInput("custom")
performImeAction()
}
compose.onNodeWithText("Stage").assertIsNotEnabled()
compose.onNodeWithText("Submit").assertIsNotEnabled()
compose.runOnIdle {
assertEquals(listOf("qid-key" to "[\"Stage\",\"custom\"]"), answers)
submitting = false
}
compose.onNodeWithText("Stage").assertIsSelected()
compose.onNodeWithText("Submit").performClick()
compose.runOnIdle { assertEquals(answers[0], answers[1]) }
}
@Test fun confirmedQuestionAdvancesAndExpiredBatchRetainsAnswersWithoutInputs() {
var batch by mutableStateOf(HermesCardClarifyBatch(listOf(question,
question.copy(key = "second", question = "Anything else?", input = HermesCardInput(HermesCardInput.Kinds.TEXT, allowFreeText = true)))))
compose.setContent {
MaterialTheme {
HermesCardBubble(card().copy(clarifyBatch = batch), "batch", emptyList(), { _, _ -> }, { _, _ -> })
}
}
compose.runOnIdle { batch = batch.copy(questions = batch.questions.map { if (it.key == "qid-key") it.copy(answer = "[\"Stage\"]") else it }) }
compose.onNodeWithText("Question 2 of 2").assertExists()
compose.onNodeWithText("Anything else?").assertExists()
compose.onNodeWithText("Stage").assertDoesNotExist()
compose.runOnIdle { batch = batch.copy(expiresAtMillis = 1L) }
compose.onNodeWithText("This request has ended").assertExists()
compose.onNodeWithText("Stage").assertExists()
compose.onNodeWithContentDescription("Type an answer…").assertDoesNotExist()
}
private val question = HermesCardClarifyQuestion("qid-key", "Which environments?",
HermesCardInput(HermesCardInput.Kinds.CHOICE, listOf("Stage", "Production"), multiSelect = true, allowFreeText = true))
private fun card() = HermesCard(HermesCard.BuiltInTypes.ASK_CLARIFY, title = "Hermes needs clarification")
}
@@ -2729,6 +2729,121 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { viewModel.pendingAsk.value == null }
}
private fun presentBatchClarify(requestId: String = "batch") {
serverWs.send(gatewayHarness.eventFrame("clarify.request",
gatewayHarness.json.parseToJsonElement("""{"request_id":"$requestId","questions":[
{"qid":"route/a","question":"Which deployment?","choices":["Canary","Immediate"]},
{"qid":"environment:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}
]}""") as kotlinx.serialization.json.JsonObject, "live-resumed"))
awaitCondition { viewModel.pendingAsk.value?.ask?.requestId == requestId }
}
@Test
fun batchClarifyInFlightAnswerSurvivesSessionRoundTripWithoutDuplicateRpc() {
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
val store = MemoryCheckpointStore()
viewModel.setChatTurnCheckpointStore(store)
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
viewModel.sendMessage("Ask before continuing")
gatewayHarness.awaitRpc("prompt.submit")
presentBatchClarify()
val pending = requireNotNull(viewModel.pendingAsk.value)
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
gatewayHarness.suppressAckMethods += "clarify.respond"
viewModel.answerAsk(pending.messageId, key, "Canary")
shadowOf(Looper.getMainLooper()).idle()
val ack = gatewayHarness.awaitPendingAck()
viewModel.switchSession("other-session")
awaitCondition { handler.currentSessionId.value == "other-session" && store.checkpoint?.pendingAsk != null }
gatewayHarness.recoveryRunning = true
viewModel.switchSession(STORED_SESSION_ID)
awaitCondition { viewModel.pendingAsk.value != null && handler.currentSessionId.value == STORED_SESSION_ID }
val restored = requireNotNull(viewModel.pendingAsk.value)
assertEquals(pending.ownerId, restored.ownerId)
viewModel.answerAsk(restored.messageId, key, "Canary")
gatewayHarness.releaseAck(ack)
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
gatewayHarness.suppressAckMethods -= "clarify.respond"
viewModel.answerAsk(restored.messageId,
com.hermesandroid.relay.data.clarifyQuestionCardKey(restored.cardKey, "environment:b"), "[\"Stage\"]")
awaitCondition { viewModel.pendingAsk.value == null }
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject { put("text", "All answers received") }, "live-resumed"))
awaitCondition { !handler.isStreaming.value && !gatewayClient.hasActiveTurn() }
assertEquals(2, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
}
@Test
fun batchClarifyKeepsPartialProgressRejectsDuplicateAndNeverSendsChatAnswers() {
viewModel.sendMessage("Ask a batch")
gatewayHarness.awaitRpc("prompt.submit")
presentBatchClarify()
val pending = requireNotNull(viewModel.pendingAsk.value)
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
gatewayHarness.suppressAckMethods += "clarify.respond"
viewModel.answerAsk(pending.messageId, key, "Canary")
viewModel.answerAsk(pending.messageId, key, "Canary")
shadowOf(Looper.getMainLooper()).idle()
val ack = gatewayHarness.awaitPendingAck()
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
gatewayHarness.releaseAck(ack)
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
presentBatchClarify()
viewModel.answerAsk(pending.messageId, key, "Immediate")
assertEquals("Canary", viewModel.pendingAsk.value?.ask?.answers?.get("route/a"))
gatewayHarness.suppressAckMethods -= "clarify.respond"
val secondKey = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "environment:b")
viewModel.answerAsk(pending.messageId, secondKey, "[\"Stage\",\"Production\"]")
awaitCondition { viewModel.pendingAsk.value == null }
val responses = gatewayHarness.rpcLog.filter { it.first == "clarify.respond" }.map { it.second }
assertEquals(listOf(JsonPrimitive("route/a"), JsonPrimitive("environment:b")), responses.map { it["question_id"] })
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "prompt.submit" })
val card = handler.messages.value.single { it.id == pending.messageId }.cards.single()
assertEquals(listOf("Canary", "[\"Stage\",\"Production\"]"), card.clarifyBatch?.questions?.map { it.answer })
}
@Test
fun batchClarifyFailedRpcCanRetryAndWhitespaceCannotSubmit() {
viewModel.sendMessage("Ask a batch")
gatewayHarness.awaitRpc("prompt.submit")
presentBatchClarify()
val pending = requireNotNull(viewModel.pendingAsk.value)
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
viewModel.answerAsk(pending.messageId, key, " ")
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
gatewayHarness.rpcErrors["clarify.respond"] = 5030 to "Try again"
viewModel.answerAsk(pending.messageId, key, "Canary")
awaitCondition { gatewayHarness.rpcLog.any { it.first == "clarify.respond" } &&
handler.messages.value.single { it.id == pending.messageId }.cards.single().clarifyBatch?.questions?.first()?.submitting == false }
assertTrue(requireNotNull(viewModel.pendingAsk.value).ask.answers.isEmpty())
gatewayHarness.rpcErrors.remove("clarify.respond")
viewModel.answerAsk(pending.messageId, key, " custom route ")
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "custom route" }
assertEquals(2, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
}
@Test
fun staleExpiredBatchResponseCannotRetireReusedRequestId() {
viewModel.sendMessage("Ask a batch")
gatewayHarness.awaitRpc("prompt.submit")
presentBatchClarify()
val pending = requireNotNull(viewModel.pendingAsk.value)
gatewayHarness.suppressAckMethods += "clarify.respond"
viewModel.answerAsk(pending.messageId, com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a"), "Canary")
shadowOf(Looper.getMainLooper()).idle()
val ack = gatewayHarness.awaitPendingAck()
serverWs.send(gatewayHarness.eventFrame("clarify.expire", buildJsonObject { put("request_id", "batch") }, "live-resumed"))
awaitCondition { viewModel.pendingAsk.value == null }
presentBatchClarify()
val newOwner = requireNotNull(viewModel.pendingAsk.value).ownerId
gatewayHarness.releaseAck(ack, buildJsonObject { put("status", "expired") })
gatewayHarness.suppressAckMethods -= "clarify.respond"
val current = requireNotNull(viewModel.pendingAsk.value)
viewModel.answerAsk(current.messageId, com.hermesandroid.relay.data.clarifyQuestionCardKey(current.cardKey, "route/a"), "Immediate")
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Immediate" }
assertEquals(newOwner, viewModel.pendingAsk.value?.ownerId)
}
@Test
fun authoritativeClarifyExpiryCollapsesCardAndRejectsLateAction() {
viewModel.sendMessage("Ask a question")
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+3
View File
@@ -68,6 +68,9 @@ the upstream contract identifiers it depends on.
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `clarify_legacy` | Top-level single question and unkeyed `clarify.respond` |
| `clarify_normalized_single` | One normalized `questions[]` entry still requires its exact `qid` |
| `clarify_batch` | Independent qid responses, partial acknowledgement, and answered-question replay on reconnect |
| `session_initialization_failure` | Exact-session initialization error arrives before a lazy create acknowledgement; Android must fail the pending send without waiting for the readiness deadline |
| `subagent_child_preview` | Child activity continues after the parent terminal, followed by child completion and a separate completion wake; preview ownership remains on the same profile/session |
| `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery |
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
"main": "8f0fb1a30592b0100876570cbf88ad2ec5cee67818f9221035184d63838ab26f",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1 -1
View File
@@ -638,7 +638,7 @@ Bottom navigation bar with 4 tabs:
- **Agent Passport — profile inspection/configuration** — upstream Hermes profiles are selected from the Profile Shelf or the Passport's shared full switcher. Passport retains identity customization, model/personality/reasoning/safety configuration, inspection, and session analytics; it is not a second profile-picker implementation. See `docs/decisions.md` §21 and ADR 48.
- **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.
- **Interactive clarify cards** — ordinary upstream choices retain one-tap submission; `multi_select:true` choices toggle independently and require explicit submission as one JSON-array answer. Open text remains available for an Other answer. Android never invents a clarify deadline when upstream omits timeout metadata: the correlated `clarify.expire` event or an expired response retires the card authoritatively.
- **Interactive clarify cards** — ordinary upstream choices retain one-tap submission; `multi_select:true` choices toggle independently and require explicit submission as one JSON-array answer. Open text remains available for an Other answer. Upstream `questions[]` batches use one progressive card, retain exact qids, and answer each question through `clarify.respond` with `question_id`. Confirmed answers survive checkpoint recovery and merge with upstream replay without resubmission. Legacy top-level questions keep their original wire shape. Android never invents a clarify deadline when upstream omits timeout metadata: the correlated `clarify.expire` event or an expired response retires the card authoritatively.
- **Streaming dots** — animated pulsing 3-dot indicator replaces static "streaming..." text
- Displays: streaming delta text; quiet thinking/reasoning disclosures that open while live and collapse when settled; consecutive routine tool activity summarized as one live ticker or settled disclosure; standalone lifecycle surfaces for approvals, failures, generated media, file edits, and delegated work; per-message token counts + cost
@@ -36,7 +36,9 @@ SESSION_EXCLUSIVE_SUBMIT = "gateway.session_exclusive_submit"
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
SESSION_INITIALIZATION = "gateway.session_initialization"
API_BOUNDARY = "api.fallback_boundary"
CLARIFY = "gateway.clarify"
ALL_CONTRACTS = (
CLARIFY,
GATEWAY_TERMINAL,
GATEWAY_SETTLED_INFO,
SESSION_ACTIVATE,
@@ -541,6 +543,27 @@ def load_requirements(manifest: Path | None) -> tuple[str, ...]:
return tuple(contract for contract in ALL_CONTRACTS if contract in requested)
def _check_clarify(server: SourceFile) -> CheckResult:
bridge = server.function("_clarify_block")
respond = server.function("_respond")
replay = server.function("_pending_clarify_request_payload")
block = server.function("_block")
required = (
{"questions", "qid", "question", "choices", "multi_select"} <= _string_constants(bridge)
and {"question_id", "request_id", "answers", "remaining", "expired"} <= _string_constants(respond)
and {"answers", "clarify.request"} <= _string_constants(replay)
and {"answers", "timed_out"} <= _string_constants(block)
)
return CheckResult(
CLARIFY, required,
tuple(server.evidence(node, label) for node, label in (
(bridge, "legacy and qid batch wire"), (respond, "per-question response"),
(replay, "answered-qid replay"), (block, "partial timeout"),
)),
None if required else "Clarify wire, response, replay, or partial-timeout contract changed",
)
def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
server = SourceFile(root, SERVER)
methods = SourceFile(root, SESSION_METHODS)
@@ -558,6 +581,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits))
checks = {
CLARIFY: lambda: _check_clarify(server),
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(
SourceFile(root, "tui_gateway/prompt_turn.py")
if (root / "tui_gateway/prompt_turn.py").is_file() else server
@@ -17,6 +17,19 @@ SPEC.loader.exec_module(module)
SERVER_SOURCE = '''
def _clarify_block(sid, q, c, multi_select=False, questions=None):
return {"questions": [{"qid": "q0", "question": q, "choices": c, "multi_select": multi_select}]}
def _respond(rid, params, key):
return {"question_id": params.get("question_id"), "request_id": params.get("request_id"),
"answers": {}, "remaining": [], "status": "expired"}
def _pending_clarify_request_payload(sid):
return {"answers": {}, "event": "clarify.request"}
def _block(event, sid, payload):
return {"answers": {}, "timed_out": True}
def _session_info(agent, session=None):
return {"running": bool((session or {}).get("running"))}
@@ -164,6 +177,13 @@ async def _handle_runs(request):
class GatewayScenarioConformanceTest(unittest.TestCase):
def test_clarify_conformance_requires_question_ownership_and_replay(self) -> None:
results = module.audit_sources(self.root, [module.CLARIFY])
self.assertTrue(results[0].passed)
source = self.root / module.SERVER
source.write_text(SERVER_SOURCE.replace('"remaining"', '"other"'), encoding="utf-8")
self.assertFalse(module.audit_sources(self.root, [module.CLARIFY])[0].passed)
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
+2 -1
View File
@@ -47,7 +47,7 @@ distribution, and trust installation are deliberately outside this fixture.
- `POST /api/auth/ws-ticket` mints a fresh, single-use 30-second ticket.
- `GET /api/ws?ticket=...` upgrades to WebSocket and sends `gateway.ready`.
- JSON-RPC methods: `session.create`, `session.resume`, `session.activate`,
`session.active_list`, `prompt.submit`, and `session.interrupt` when the
`session.active_list`, `prompt.submit`, `clarify.respond`, and `session.interrupt` when the
selected scenario enables them.
- `GET /api/sessions/{stored-id}/messages` returns persisted, paginated history
and accepts the upstream `profile`, `limit`, `offset`, and `order` query shape.
@@ -66,6 +66,7 @@ ordered `steps` list using these operations:
| Operation | Purpose |
|---|---|
| `event` | Send a Gateway event with `exact`, `foreign`, or `unscoped` identity. |
| `clarify` | Emit the supplied Clarify `payload` and wait for its legacy answer or every batch qid; activation replays confirmed answers. |
| `persist` | Append authoritative Dashboard history rows. |
| `sleep` | Create a bounded deterministic ordering window (maximum 5 seconds). |
| `set_running` | Change the authoritative session running state. |
@@ -18,6 +18,58 @@ from vanilla_gateway.evidence import EvidenceLog # noqa: E402
class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
async def test_clarify_batch_requires_each_qid_and_replays_partial_progress(self) -> None:
fixture, base_url = await self.start("clarify_batch")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit")
frames = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "clarify.request")
request = frames[-1]["params"]["payload"]
rid = request["request_id"]
await self.rpc(ws, 2, "clarify.respond", {"request_id": rid, "question_id": "foreign", "answer": "x"})
frames = await self.frames_until(ws, lambda f: f.get("id") == 2)
self.assertEqual(4002, frames[-1]["error"]["code"])
await self.rpc(ws, 3, "clarify.respond", {"request_id": rid, "question_id": "route/a", "answer": "Canary"})
frames = await self.frames_until(ws, lambda f: f.get("id") == 3)
self.assertEqual(["environment:b"], frames[-1]["result"]["remaining"])
self.assertTrue(fixture.running)
await ws.close()
ws, _ = await self.connect(base_url)
await self.rpc(ws, 4, "session.activate", {"session_id": fixture.scenario.live_session_id})
frames = await self.frames_until(ws, lambda f: f.get("id") == 4)
self.assertEqual({"route/a": "Canary"}, frames[-1]["result"]["pending_clarify"]["answers"])
await self.rpc(ws, 5, "clarify.respond", {
"request_id": rid, "question_id": "environment:b", "answer": '["Stage","Production"]',
})
frames = await self.frames_until(ws, lambda f: f.get("id") == 5)
self.assertEqual([], frames[-1]["result"]["remaining"])
self.assertEqual('["Stage","Production"]', fixture._clarify_answers["environment:b"])
frames = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "message.complete")
self.assertEqual("message.complete", frames[-1]["params"]["type"])
async def test_clarify_legacy_keeps_unkeyed_response(self) -> None:
_, base_url = await self.start("clarify_legacy")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit")
frames = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "clarify.request")
payload = frames[-1]["params"]["payload"]
self.assertNotIn("questions", payload)
await self.rpc(ws, 2, "clarify.respond", {"request_id": payload["request_id"], "answer": "Canary"})
frames = await self.frames_until(ws, lambda f: f.get("id") == 2)
self.assertEqual("ok", frames[-1]["result"]["status"])
async def test_one_normalized_question_is_still_qid_owned(self) -> None:
_, base_url = await self.start("clarify_normalized_single")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit")
frames = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "clarify.request")
payload = frames[-1]["params"]["payload"]
self.assertEqual(1, len(payload["questions"]))
await self.rpc(ws, 2, "clarify.respond", {
"request_id": payload["request_id"], "question_id": payload["questions"][0]["qid"], "answer": "Canary",
})
frames = await self.frames_until(ws, lambda f: f.get("id") == 2)
self.assertEqual([], frames[-1]["result"]["remaining"])
async def asyncSetUp(self) -> None:
self.session = ClientSession()
self.runner: web.AppRunner | None = None
@@ -14,7 +14,7 @@ class ScenarioError(ValueError):
"""Raised when a scenario does not satisfy the fixture schema."""
_STEP_OPS = {"event", "persist", "sleep", "close", "set_running"}
_STEP_OPS = {"event", "persist", "sleep", "close", "set_running", "clarify"}
_LIVE_STATUSES = {"starting", "working", "waiting", "idle"}
_SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]{1,120}")
@@ -68,6 +68,16 @@ class Scenario:
raise ScenarioError("event scope must be exact, foreign, or unscoped")
if step["op"] == "persist" and not isinstance(step.get("messages"), list):
raise ScenarioError("persist step requires a messages list")
if step["op"] == "clarify":
payload = step.get("payload")
if not isinstance(payload, dict) or not isinstance(payload.get("request_id"), str):
raise ScenarioError("clarify step requires a request_id payload")
questions = payload.get("questions", [])
if not isinstance(questions, list) or any(
not isinstance(q, dict) or not isinstance(q.get("qid"), str) or not q["qid"]
for q in questions
):
raise ScenarioError("clarify questions require exact qids")
if step["op"] == "set_running" and not isinstance(step.get("value"), bool):
raise ScenarioError("set_running step requires a boolean value")
if step["op"] == "sleep":
@@ -0,0 +1,52 @@
{
"name": "clarify_batch",
"live_session_id": "fixture-live-clarify",
"stored_session_id": "fixture-stored-clarify",
"profile": "default",
"contract_requirements": [
"gateway.clarify"
],
"turns": [
{
"steps": [
{
"op": "event",
"type": "message.start"
},
{
"op": "clarify",
"payload": {
"request_id": "clarify-batch",
"questions": [
{
"qid": "route/a",
"question": "Which deployment?",
"choices": [
"Canary",
"Immediate"
],
"multi_select": false
},
{
"qid": "environment:b",
"question": "Which environments?",
"choices": [
"Stage",
"Production"
],
"multi_select": true
}
]
}
},
{
"op": "event",
"type": "message.complete",
"payload": {
"text": "Clarification complete."
}
}
]
}
]
}
@@ -0,0 +1,38 @@
{
"name": "clarify_legacy",
"live_session_id": "fixture-live-clarify",
"stored_session_id": "fixture-stored-clarify",
"profile": "default",
"contract_requirements": [
"gateway.clarify"
],
"turns": [
{
"steps": [
{
"op": "event",
"type": "message.start"
},
{
"op": "clarify",
"payload": {
"request_id": "clarify-legacy",
"question": "Which deployment?",
"choices": [
"Canary",
"Immediate"
],
"multi_select": false
}
},
{
"op": "event",
"type": "message.complete",
"payload": {
"text": "Clarification complete."
}
}
]
}
]
}
@@ -0,0 +1,43 @@
{
"name": "clarify_normalized_single",
"live_session_id": "fixture-live-clarify",
"stored_session_id": "fixture-stored-clarify",
"profile": "default",
"contract_requirements": [
"gateway.clarify"
],
"turns": [
{
"steps": [
{
"op": "event",
"type": "message.start"
},
{
"op": "clarify",
"payload": {
"request_id": "clarify-one",
"questions": [
{
"qid": "route/a",
"question": "Which deployment?",
"choices": [
"Canary",
"Immediate"
],
"multi_select": false
}
]
}
},
{
"op": "event",
"type": "message.complete",
"payload": {
"text": "Clarification complete."
}
}
]
}
]
}
@@ -55,6 +55,10 @@ class GatewayFixture:
self._connection_sequence = 0
self._tasks: set[asyncio.Task[None]] = set()
self._sockets: set[web.WebSocketResponse] = set()
self._clarify: dict[str, Any] | None = None
self._clarify_answers: dict[str, str] = {}
self._clarify_done = asyncio.Event()
self._clarify_owner: tuple[web.WebSocketResponse, int] | None = None
@property
def running(self) -> bool:
@@ -135,16 +139,40 @@ class GatewayFixture:
await self._rpc_error(socket, request_id, 4040, "Stored session not found")
return
result = self._session_snapshot(include_stored=True)
if self._clarify is not None:
self._clarify_owner = socket, connection
elif method == "session.activate":
requested = params.get("session_id")
if requested != self.scenario.live_session_id:
await self._rpc_error(socket, request_id, 4041, "Live session not found")
return
result = self._session_snapshot(include_stored=True)
if self._clarify is not None:
self._clarify_owner = socket, connection
elif method == "prompt.submit":
await self._rpc_result(socket, request_id, {"ok": True})
await self._submit(socket, connection)
return
elif method == "clarify.respond":
pending = self._clarify
if pending is None or params.get("request_id") != pending["request_id"]:
result = {"status": "expired"}
else:
qids = [q["qid"] for q in pending.get("questions", [])]
qid = params.get("question_id")
if qids and qid:
if qid not in qids:
await self._rpc_error(socket, request_id, 4002, "unknown question_id")
return
self._clarify_answers[qid] = params.get("answer", "")
remaining = [q for q in qids if q not in self._clarify_answers]
result = {"status": "ok", "remaining": remaining}
if not remaining:
self._clarify_done.set()
else:
# Upstream's no-qid batch response cancels the whole request.
self._clarify_done.set()
result = {"status": "ok"}
elif method == "session.interrupt":
was_active = self._turn_active
tasks = tuple(self._tasks)
@@ -179,6 +207,9 @@ class GatewayFixture:
}
if include_stored:
snapshot["stored_session_id"] = self.scenario.stored_session_id
if self._clarify is not None:
snapshot["pending_clarify"] = dict(self._clarify, answers=dict(self._clarify_answers))
snapshot["info"]["pending_clarify"] = snapshot["pending_clarify"]
if self._running:
snapshot["inflight"] = {"user": "fixture turn", "assistant": "", "streaming": True}
return snapshot
@@ -218,6 +249,18 @@ class GatewayFixture:
operation = step["op"]
if operation == "sleep":
await asyncio.sleep(step["milliseconds"] / 1_000)
elif operation == "clarify":
self._clarify = dict(step["payload"])
self._clarify_answers = {}
self._clarify_owner = socket, connection
self._clarify_done.clear()
await self._send_event(
socket, connection, "clarify.request", self._clarify,
self.scenario.live_session_id,
)
await self._clarify_done.wait()
socket, connection = self._clarify_owner
self._clarify = None
elif operation == "set_running":
self._running = bool(step["value"])
self.evidence.add(
@@ -245,6 +288,8 @@ class GatewayFixture:
)
self.evidence.add("fault", connection=connection, outcome="socket_gap")
finally:
self._clarify = None
self._clarify_owner = None
self._turn_active = False
if self._running:
self._running = False
+7
View File
@@ -133,6 +133,13 @@ Sensitive prompts (sudo, secrets) are masked and hold-to-confirm. See
[Markdown Rendering → Rich Cards](/features/markdown#rich-cards) for the full
visual vocabulary.
When Hermes asks several questions together, the card shows one question at a
time and tracks your progress. Choose an option or type an **Other** answer;
questions that allow several choices have a **Submit** button. Each confirmed
answer stays recorded as you continue, including after a reconnect. If sending
fails, your selections remain available to retry. When a request ends, the card
keeps confirmed answers visible and stops accepting new ones.
## Context meter
A thin strip under the chat header tracks how full the conversation's context