Compare commits

..
Author SHA1 Message Date
Bailey Dixon 258e6f5390 fix(android): keep clarify custom answers reachable 2026-08-28 22:21:53 -04:00
28 changed files with 366 additions and 539 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection.
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation.
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
### Removed
@@ -38,8 +38,6 @@ data class ProactiveInboxEntry(
val connectionId: String? = null,
/** Relay proved this row came from its bounded offline queue. */
val arrivedWhileAway: Boolean = false,
/** Exact Android notification slot, when recorded by the receiving build. */
val notificationId: Int? = null,
)
private val Context.proactiveInboxStore: DataStore<Preferences> by
@@ -60,19 +58,15 @@ private const val MAX_ENTRIES = 100
* bounded store also backs the provisional Thread until the user's first reply
* promotes it to a real `source=phone` session.
*/
class ProactiveInboxRepository internal constructor(
private val store: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.proactiveInboxStore)
class ProactiveInboxRepository(private val context: Context) {
private val json = Json { ignoreUnknownKeys = true }
val entries: Flow<List<ProactiveInboxEntry>> =
store.data.map { prefs -> decode(prefs[INBOX_JSON]) }
context.proactiveInboxStore.data.map { prefs -> decode(prefs[INBOX_JSON]) }
suspend fun add(entry: ProactiveInboxEntry) {
store.edit { prefs ->
context.proactiveInboxStore.edit { prefs ->
val current = decode(prefs[INBOX_JSON]).toMutableList()
current.removeAll { it.id == entry.id }
current.add(0, entry)
@@ -82,40 +76,7 @@ class ProactiveInboxRepository internal constructor(
}
suspend fun clear() {
store.edit { it.remove(INBOX_JSON) }
}
/**
* Remove one provisional Thread owned by one saved connection.
*
* This only edits the bounded local inbox. A promoted Thread is server
* history and is deliberately outside this repository, so this operation
* can never delete it. Legacy entries without a connection owner are
* removed with the active row because they are rendered in that row; rows
* explicitly owned by another connection remain isolated.
*/
suspend fun removeThread(
chatId: String,
connectionId: String,
): List<ProactiveInboxEntry> {
val normalizedChatId = chatId.ifBlank { "phone" }
var removed = emptyList<ProactiveInboxEntry>()
store.edit { prefs ->
val current = decode(prefs[INBOX_JSON])
removed = current.filter {
(it.connectionId == null || it.connectionId == connectionId) &&
(it.chatId ?: "phone") == normalizedChatId
}
if (removed.isNotEmpty()) {
val retained = current.filterNot { it in removed }
if (retained.isEmpty()) {
prefs.remove(INBOX_JSON)
} else {
prefs[INBOX_JSON] = json.encodeToString(retained)
}
}
}
return removed
context.proactiveInboxStore.edit { it.remove(INBOX_JSON) }
}
private fun decode(raw: String?): List<ProactiveInboxEntry> {
@@ -102,18 +102,22 @@ class ProactiveMessageHandler(
/** Route a parsed message: into the open Thread if it belongs there, else
* the durable inbox log + the surface its hint selects. */
private fun dispatch(msg: ProactiveMessage) {
// Persist first even when the currently open Thread consumes the live
// message. Agent-initiated outbound sends do not create a gateway
// session until the phone replies, so this cache is the provisional
// Thread transcript during that gap.
toInbox?.invoke(msg)
// The surfacing hint selects the additional surface. Thread injection
// is best-effort presentation of the persisted row, not itself a reason
// to suppress an explicitly requested notification.
val notificationId = when (msg.surfacing?.lowercase()) {
when (msg.surfacing?.lowercase()) {
"inbox" -> {
injectIntoThread?.invoke(msg)
null
}
"session" -> {
val delivered = injectIntoThread?.invoke(msg) == true ||
toSession?.invoke(msg) == true
if (delivered) null else notify(msg)
if (!delivered) notify(msg)
}
// null / "default" / "notification" / anything unrecognized.
else -> {
@@ -121,13 +125,9 @@ class ProactiveMessageHandler(
notify(msg)
}
}
// Every message remains in the bounded local cache. Persist the exact
// posted notification slot as part of that row so a later local Thread
// removal can cancel only its own notification.
toInbox?.invoke(msg.copy(notificationId = notificationId))
}
private fun notify(msg: ProactiveMessage): Int? =
private fun notify(msg: ProactiveMessage) {
ProactiveMessageNotifier.notify(
context = context,
title = msg.title,
@@ -135,6 +135,7 @@ class ProactiveMessageHandler(
messageId = msg.messageId,
chatId = msg.chatId,
)
}
private fun parse(payload: JsonObject): ProactiveMessage? {
val text = payload["text"]?.jsonPrimitive?.contentOrNull
@@ -171,6 +172,4 @@ data class ProactiveMessage(
val replyTo: String? = null,
/** True only when Relay explicitly marked this as a reconnect queue flush. */
val arrivedWhileAway: Boolean = false,
/** Exact Android notification slot when this delivery posted one. */
val notificationId: Int? = null,
)
@@ -74,13 +74,13 @@ object ProactiveMessageNotifier {
text: String,
messageId: String?,
chatId: String?,
): Int? {
) {
ensureChannel(context)
if (!hasPostNotificationsPermission(context)) {
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping proactive notification")
return null
return
}
if (text.isBlank()) return null
if (text.isBlank()) return
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -89,7 +89,7 @@ object ProactiveMessageNotifier {
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
// Distinct requestCode per slot so each notification gets its own
// PendingIntent rather than all sharing slot 0's intent.
val notificationId = slotFor(messageId, chatId)
val notificationId = slotFor(messageId)
val tapPending =
PendingIntent.getActivity(context, notificationId, tapIntent, pendingFlags)
@@ -108,15 +108,9 @@ object ProactiveMessageNotifier {
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
return runCatching {
runCatching {
NotificationManagerCompat.from(context).notify(notificationId, builder.build())
notificationId
}.onFailure { Log.w(TAG, "notify failed", it) }.getOrNull()
}
/** Cancel one exact slot previously returned by [notificationIdFor]. */
fun cancel(context: Context, notificationId: Int) {
NotificationManagerCompat.from(context).cancel(notificationId)
}.onFailure { Log.w(TAG, "notify failed", it) }
}
/**
@@ -212,12 +206,8 @@ object ProactiveMessageNotifier {
}
/** Derive a stable notification slot from the message id. */
internal fun notificationIdFor(messageId: String?, chatId: String?): Int =
slotFor(messageId, chatId)
private fun slotFor(messageId: String?, chatId: String?): Int {
val key = messageId?.takeIf { it.isNotBlank() }
?: "chat:${chatId?.takeIf { it.isNotBlank() } ?: "phone"}"
private fun slotFor(messageId: String?): Int {
val key = messageId?.takeIf { it.isNotBlank() } ?: return ID_BASE
// Keep within a small positive window above the base so re-delivery of
// the same id collapses to one slot and distinct ids spread out.
return ID_BASE + (key.hashCode() and 0xFFFF)
@@ -2217,12 +2217,9 @@ fun RelayApp() {
(it.connectionId == null || it.connectionId == activeConnectionId) &&
(it.chatId ?: "phone") == chatId
}
if (entries.isNotEmpty()) {
chatViewModel.openProactiveThread(chatId, entries)
}
if (entries.isEmpty()) return@LaunchedEffect
chatViewModel.openProactiveThread(chatId, entries)
}
// Consume the request even when deletion removed its
// local row before a stale notification tap arrived.
backStackEntry.arguments?.putString(
Screen.Chat.ARG_PROACTIVE_CHAT_ID,
null,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -25,6 +24,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -71,6 +71,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -172,26 +173,26 @@ fun HermesCardBubble(
),
shape = appearanceRoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
) {
Box(modifier = Modifier.fillMaxWidth()) {
// Accent stripe — runs full card height so tall cards keep the
// color tie. Using the SAME tertiary accent strategy as the
// voice/phone-action bubble marker in MessageBubble.kt so the
// visual language stays consistent.
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(start = 15.dp, top = 12.dp, end = 12.dp, bottom = 12.dp),
) {
// Header
Row(
@@ -426,6 +427,19 @@ private fun CardInputSlot(
input.kind != HermesCardInput.Kinds.CONFIRM)
)
val submitFreeText = {
val customAnswer = answerText.trim()
if (customAnswer.isNotEmpty()) {
onSubmit(
if (isMultiSelect) {
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
} else {
customAnswer
},
)
}
}
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
@@ -515,11 +529,16 @@ private fun CardInputSlot(
InlineAnswerField(
value = answerText,
onValueChange = { answerText = it },
placeholder = stringResource(
if (input.choices.isNotEmpty()) R.string.card_other_answer_placeholder
else R.string.card_answer_placeholder,
),
onSubmit = submitFreeText,
modifier = Modifier.weight(1f),
)
if (!isMultiSelect) {
IconButton(
onClick = { onSubmit(answerText.trim()) },
onClick = submitFreeText,
enabled = answerText.isNotBlank(),
) {
Icon(
@@ -593,6 +612,8 @@ private fun CardInputSlot(
private fun InlineAnswerField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = appearanceRoundedCornerShape(16.dp)
@@ -605,7 +626,7 @@ private fun InlineAnswerField(
) {
if (value.isEmpty()) {
Text(
text = stringResource(R.string.card_answer_placeholder),
text = placeholder,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
@@ -617,8 +638,12 @@ private fun InlineAnswerField(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = placeholder },
)
}
}
@@ -237,8 +237,6 @@ fun SessionDrawerContent(
onNewThread: ((String) -> Unit)? = null,
provisionalThreads: List<ProvisionalThreadRow> = emptyList(),
onSelectProvisionalThread: ((String) -> Unit)? = null,
/** Deletes only the local provisional inbox row; never a server session. */
onDeleteProvisionalThread: ((String) -> Unit)? = null,
/** Gateway sources currently hidden from the drawer (default: cron+webhook). */
hiddenSources: Set<String> = emptySet(),
/** Toggle a source's visibility (persisted). Null hides the source filter. */
@@ -791,17 +789,13 @@ fun SessionDrawerContent(
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
nowMillis = drawerNowMillis,
actionsEnabled = if (provisional) {
onDeleteProvisionalThread != null &&
supervisedSessionActions?.delete != false
} else {
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
supervisedSessionActions.rename ||
supervisedSessionActions.delete ||
(supervisedSessionActions.archive && archiveSupported)
},
provisional = provisional,
),
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled && isOpen,
@@ -940,38 +934,15 @@ fun SessionDrawerContent(
// Delete confirmation dialog
deleteDialogTarget?.let { (row, allProfiles) ->
val session = row.session
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
AlertDialog(
onDismissRequest = { deleteDialogTarget = null },
title = {
Text(
stringResource(
if (provisional) {
R.string.drawer_remove_provisional_thread_title
} else {
R.string.drawer_delete_session_title
},
),
)
},
title = { Text(stringResource(R.string.drawer_delete_session_title)) },
text = {
val title = session.title ?: stringResource(R.string.drawer_untitled)
Text(
if (provisional) {
stringResource(R.string.drawer_remove_provisional_thread_message, title)
} else {
stringResource(R.string.drawer_delete_session_prefix) + title +
stringResource(R.string.drawer_delete_session_suffix)
},
)
Text(stringResource(R.string.drawer_delete_session_prefix) + (session.title ?: stringResource(R.string.drawer_untitled)) + stringResource(R.string.drawer_delete_session_suffix))
},
confirmButton = {
TextButton(onClick = {
if (session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)) {
onDeleteProvisionalThread?.invoke(
session.sessionId.removePrefix(PROVISIONAL_THREAD_PREFIX),
)
} else if (allProfiles) {
if (allProfiles) {
onDeleteProfileSession?.invoke(row.profile, session.sessionId)
} else {
onDeleteSession(session.sessionId)
@@ -1387,7 +1358,6 @@ private fun SessionItem(
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
provisional: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
animationEnabled: Boolean,
@@ -1558,7 +1528,7 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
if (!provisional && supervisedSessionActions?.pin != false) DropdownMenuItem(
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
text = {
Text(
if (pinned) {
@@ -1584,7 +1554,7 @@ private fun SessionItem(
onTogglePinned()
},
)
if (!provisional && supervisedSessionActions == null) DropdownMenuItem(
if (supervisedSessionActions == null) DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -1594,7 +1564,7 @@ private fun SessionItem(
onCopySessionId()
},
)
if (!provisional && supervisedSessionActions?.rename != false) DropdownMenuItem(
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -1604,7 +1574,7 @@ private fun SessionItem(
onRename()
},
)
if (!provisional && archiveSupported && supervisedSessionActions?.archive != false) {
if (archiveSupported && supervisedSessionActions?.archive != false) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
@@ -2431,26 +2431,6 @@ fun ChatScreen(
activeConnectionId = activeConnection?.id,
realThreadChatIds = phoneThreadChatIds.values,
)
val realPhoneSessionIds = remember(sessions) {
sessions.asSequence()
.filter { it.source.equals("phone", ignoreCase = true) }
.map { it.sessionId }
.toSet()
}
val provisionalThreadChatIds = provisionalThreadEntries.keys
LaunchedEffect(
activeConnection?.id,
realPhoneSessionIds,
provisionalThreadChatIds,
) {
// A reply promotes the local provisional row to a real Gateway
// source=phone session. Refresh the relay-owned chat_id index at
// that boundary so the local duplicate disappears immediately,
// without guessing a chat_id from the opaque session id.
if (realPhoneSessionIds.isNotEmpty() && provisionalThreadChatIds.isNotEmpty()) {
connectionViewModel.refreshPhoneThreadChatIds()
}
}
val provisionalThreads = provisionalThreadEntries.map { (chatId, entries) ->
val latest = entries.maxBy { it.receivedAt }
ProvisionalThreadRow(
@@ -2569,11 +2549,6 @@ fun ChatScreen(
)
scope.launch { drawerState.close() }
},
onDeleteProvisionalThread = { chatId ->
activeConnection?.id?.let { connectionId ->
connectionViewModel.removeProvisionalThread(chatId, connectionId)
}
},
hiddenSources = hiddenSources,
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
@@ -729,7 +729,6 @@ class ChatViewModel : ViewModel() {
* including one this app didn't create, or any Thread after a restart.
*/
fun seedThreadChatIds(map: Map<String, String>) {
threadChatIds.clear()
threadChatIds.putAll(map)
}
@@ -2510,6 +2509,30 @@ class ChatViewModel : ViewModel() {
}
is GatewayBackgroundInteractionEvent.Expired -> {
if (key != null) {
val checkpoint = backgroundTurnCheckpoints[key]
val checkpointAsk = checkpoint?.pendingAsk
if (checkpoint != null && checkpointAsk != null &&
checkpointAsk.kind == event.ask.kind.name &&
(event.ask.kind == GatewayAsk.Kind.APPROVAL ||
checkpointAsk.requestId == event.ask.requestId)
) {
val updated = checkpoint.copy(
pendingAsk = null,
updatedAt = System.currentTimeMillis(),
)
// Recovery consults memory before disk, so retire the
// stale card synchronously before a navigation can
// reclaim this turn. Persist the same exact owner so
// process restart cannot resurrect it either.
backgroundTurnCheckpoints[key] = updated
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
runCatching { store.write(updated) }
}
}
}
}
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
if (current.ask.kind == event.ask.kind &&
@@ -116,7 +116,6 @@ import com.hermesandroid.relay.accessibility.BridgeStatusReporter
import com.hermesandroid.relay.accessibility.ScreenCapture
import com.hermesandroid.relay.network.relay.BridgeCommandHandler
import com.hermesandroid.relay.network.relay.ProactiveMessageHandler
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import com.hermesandroid.relay.network.relay.models.Envelope
// === END PHASE3-accessibility ===
import com.hermesandroid.relay.util.AppForegroundTracker
@@ -165,33 +164,6 @@ internal data class RelayUiInputs(
val configured: Boolean,
)
internal data class PhoneThreadChatIdIndex(
val connectionId: String? = null,
val values: Map<String, String> = emptyMap(),
)
internal fun visiblePhoneThreadChatIds(
activeConnectionId: String?,
index: PhoneThreadChatIdIndex,
): Map<String, String> =
index.values.takeIf { index.connectionId == activeConnectionId }.orEmpty()
internal fun reconcilePhoneThreadChatIdIndex(
current: PhoneThreadChatIdIndex,
requestedConnectionId: String,
activeConnectionId: String?,
fetched: Result<Map<String, String>>,
): PhoneThreadChatIdIndex = fetched.fold(
onSuccess = { values ->
if (requestedConnectionId == activeConnectionId) {
PhoneThreadChatIdIndex(requestedConnectionId, values)
} else {
current
}
},
onFailure = { current },
)
data class HostResourcePressureStatus(
val memoryPressure: String? = null,
val memoryAvailableMb: Int? = null,
@@ -2596,18 +2568,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val inboxMessages: StateFlow<List<ProactiveInboxEntry>> =
proactiveInbox.entries.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Delete one connection-scoped provisional Thread from local storage only. */
fun removeProvisionalThread(chatId: String, connectionId: String) {
viewModelScope.launch {
proactiveInbox.removeThread(chatId = chatId, connectionId = connectionId)
.mapNotNull(ProactiveInboxEntry::notificationId)
.distinct()
.forEach { notificationId ->
ProactiveMessageNotifier.cancel(getApplication(), notificationId)
}
}
}
// The handler centralizes surfacing (notification / inbox / session). The
// inbox sink persists messages here; the session sink lands in Phase 2b.
val proactiveMessageHandler = ProactiveMessageHandler(
@@ -2623,7 +2583,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
chatId = msg.chatId,
connectionId = connectionStore.activeConnectionId.value,
arrivedWhileAway = msg.arrivedWhileAway,
notificationId = msg.notificationId,
),
)
}
@@ -2661,30 +2620,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// composer's reply routing so a Thread the app didn't create — or any Thread
// after restart — routes to the right conversation. Fail-soft: empty on an
// older relay / fetch error, and the client's learned map still applies.
private val _phoneThreadChatIdIndex = MutableStateFlow(PhoneThreadChatIdIndex())
private val phoneThreadChatIdRefreshMutex = Mutex()
val phoneThreadChatIds: StateFlow<Map<String, String>> = combine(
connectionStore.activeConnectionId,
_phoneThreadChatIdIndex,
) { activeConnectionId, index ->
visiblePhoneThreadChatIds(activeConnectionId, index)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
private val _phoneThreadChatIds = MutableStateFlow<Map<String, String>>(emptyMap())
val phoneThreadChatIds: StateFlow<Map<String, String>> = _phoneThreadChatIds.asStateFlow()
fun refreshPhoneThreadChatIds() {
val connectionId = connectionStore.activeConnectionId.value ?: return
viewModelScope.launch {
phoneThreadChatIdRefreshMutex.withLock {
val fetched = relayHttpClient.fetchPhoneThreads().map { threads ->
threads
.filter { it.sessionId.isNotBlank() && it.chatId.isNotBlank() }
.associate { it.sessionId to it.chatId }
}
_phoneThreadChatIdIndex.value = reconcilePhoneThreadChatIdIndex(
current = _phoneThreadChatIdIndex.value,
requestedConnectionId = connectionId,
activeConnectionId = connectionStore.activeConnectionId.value,
fetched = fetched,
)
relayHttpClient.fetchPhoneThreads().onSuccess { threads ->
val map = threads
.filter { it.sessionId.isNotBlank() && it.chatId.isNotBlank() }
.associate { it.sessionId to it.chatId }
if (map.isNotEmpty()) _phoneThreadChatIds.value = map
}
}
}
+1 -2
View File
@@ -916,8 +916,6 @@
<string name="drawer_delete_session_title">Excluir sessão?</string>
<string name="drawer_delete_session_prefix">Isso excluirá permanentemente \"</string>
<string name="drawer_delete_session_suffix">\" e o histórico de mensagens.</string>
<string name="drawer_remove_provisional_thread_title">Remover Thread?</string>
<string name="drawer_remove_provisional_thread_message">Isso remove \"%1$s\" deste dispositivo. O histórico promovido ou armazenado no servidor não será excluído.</string>
<string name="drawer_untitled">Sem título</string>
<string name="drawer_delete">Excluir</string>
<string name="drawer_thread">Thread</string>
@@ -3265,6 +3263,7 @@
<string name="card_send_answer_a11y">Enviar resposta</string>
<string name="card_submit">Enviar</string>
<string name="card_answer_placeholder">Digite uma resposta…</string>
<string name="card_other_answer_placeholder">Outra (digite sua resposta)…</string>
<string name="session_ttl_title">Manter este pareamento por…</string>
<string name="session_ttl_intro">Seu celular se reconectará automaticamente durante este período. Você pode revogar o pareamento a qualquer momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá pareado até você revogá-lo.</string>
@@ -960,8 +960,6 @@
<string name="drawer_delete_session_title">删除会话?</string>
<string name="drawer_delete_session_prefix">这将永久删除\"</string>
<string name="drawer_delete_session_suffix">\"及其消息历史。</string>
<string name="drawer_remove_provisional_thread_title">移除话题?</string>
<string name="drawer_remove_provisional_thread_message">这会从此设备移除“%1$s”,不会删除已提升或服务器端的历史记录。</string>
<string name="drawer_untitled">未命名</string>
<string name="drawer_delete">删除</string>
<string name="drawer_thread">话题</string>
@@ -3365,6 +3363,7 @@
<string name="card_send_answer_a11y">发送回答</string>
<string name="card_submit">提交</string>
<string name="card_answer_placeholder">输入回答…</string>
<string name="card_other_answer_placeholder">其他(输入自定义回答)…</string>
<string name="session_ttl_title">保持配对的时长…</string>
<string name="session_ttl_intro">在此时间内,手机会自动重新连接。您可以随时撤销配对。</string>
<string name="session_ttl_stays_paired">此设备会保持配对,直到您主动撤销。</string>
+1 -2
View File
@@ -965,8 +965,6 @@
<string name="drawer_delete_session_title">Sitzung löschen?</string>
<string name="drawer_delete_session_prefix">Dadurch werden \"</string>
<string name="drawer_delete_session_suffix">\" und der Nachrichtenverlauf dauerhaft gelöscht.</string>
<string name="drawer_remove_provisional_thread_title">Thread entfernen?</string>
<string name="drawer_remove_provisional_thread_message">Dadurch wird „%1$s“ von diesem Gerät entfernt. Hochgestufte oder serverseitige Verläufe werden nicht gelöscht.</string>
<string name="drawer_untitled">Ohne Titel</string>
<string name="drawer_delete">Löschen</string>
<string name="drawer_thread">Thread</string>
@@ -3433,6 +3431,7 @@
<string name="card_send_answer_a11y">Antwort senden</string>
<string name="card_submit">Absenden</string>
<string name="card_answer_placeholder">Antwort eingeben…</string>
<string name="card_other_answer_placeholder">Andere (eigene Antwort eingeben)…</string>
<string name="session_ttl_title">Diese Kopplung behalten für…</string>
<string name="session_ttl_intro">Dein Smartphone verbindet sich in diesem Zeitraum automatisch erneut. Du kannst die Kopplung jederzeit widerrufen.</string>
<string name="session_ttl_stays_paired">Dieses Gerät bleibt gekoppelt, bis du es widerrufst.</string>
+1 -2
View File
@@ -880,8 +880,6 @@
<string name="drawer_delete_session_title">¿Eliminar sesión?</string>
<string name="drawer_delete_session_prefix">Esto eliminará permanentemente \"</string>
<string name="drawer_delete_session_suffix">\" y su historial de mensajes.</string>
<string name="drawer_remove_provisional_thread_title">¿Quitar hilo?</string>
<string name="drawer_remove_provisional_thread_message">Esto quita «%1$s» de este dispositivo. No elimina el historial promocionado ni el del servidor.</string>
<string name="drawer_untitled">Intitulado</string>
<string name="drawer_delete">Borrar</string>
<string name="drawer_thread">Hilo</string>
@@ -3096,6 +3094,7 @@
<string name="card_send_answer_a11y">Enviar respuesta</string>
<string name="card_submit">Entregar</string>
<string name="card_answer_placeholder">Escribe una respuesta...</string>
<string name="card_other_answer_placeholder">Otra (escribe tu respuesta)…</string>
<string name="session_ttl_title">Mantenga este emparejamiento para...</string>
<string name="session_ttl_intro">Su teléfono se volverá a conectar automáticamente durante esta ventana. Puedes revocar el emparejamiento en cualquier momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá emparejado hasta que lo revoques.</string>
+1 -2
View File
@@ -976,8 +976,6 @@
<string name="drawer_delete_session_title">セッションを削除しますか?</string>
<string name="drawer_delete_session_prefix">「</string>
<string name="drawer_delete_session_suffix">」とそのメッセージ履歴を完全に削除します。</string>
<string name="drawer_remove_provisional_thread_title">スレッドを削除しますか?</string>
<string name="drawer_remove_provisional_thread_message">「%1$s」をこのデバイスから削除します。昇格済みまたはサーバー上の履歴は削除されません。</string>
<string name="drawer_untitled">無題</string>
<string name="drawer_delete">消去</string>
<string name="drawer_thread">糸</string>
@@ -3441,6 +3439,7 @@
<string name="card_send_answer_a11y">回答を送信する</string>
<string name="card_submit">提出する</string>
<string name="card_answer_placeholder">答えを入力してください…</string>
<string name="card_other_answer_placeholder">その他(自由回答を入力)…</string>
<string name="session_ttl_title">このペアリングをしばらく保持してください…</string>
<string name="session_ttl_intro">この期間中に電話は自動的に再接続します。ペアリングはいつでも取り消すことができます。</string>
<string name="session_ttl_stays_paired">このデバイスは、取り消すまでペアリングされたままになります。</string>
+1 -2
View File
@@ -992,8 +992,6 @@
<string name="drawer_delete_session_title">Удалить сессию?</string>
<string name="drawer_delete_session_prefix">Это навсегда удалит &quot;</string>
<string name="drawer_delete_session_suffix">&quot; и историю сообщений.</string>
<string name="drawer_remove_provisional_thread_title">Удалить поток?</string>
<string name="drawer_remove_provisional_thread_message">Это удалит «%1$s» с этого устройства. Повышенная или серверная история не будет удалена.</string>
<string name="drawer_untitled">Без названия</string>
<string name="drawer_delete">Удалить</string>
<string name="drawer_thread">Ветка</string>
@@ -3300,6 +3298,7 @@
<string name="card_send_answer_a11y">Отправить ответ</string>
<string name="card_submit">Отправить</string>
<string name="card_answer_placeholder">Введите ответ…</string>
<string name="card_other_answer_placeholder">Другое (введите свой ответ)…</string>
<string name="session_ttl_title">Сохранить это сопряжение на…</string>
<string name="session_ttl_intro">Ваш телефон будет автоматически переподключаться в течение этого периода. Вы можете отозвать сопряжение в любой момент.</string>
<string name="session_ttl_stays_paired">Это устройство останется сопряженным, пока вы не отзовёте его.</string>
+1 -2
View File
@@ -1090,8 +1090,6 @@
<string name="drawer_delete_session_title">Delete Session?</string>
<string name="drawer_delete_session_prefix">This will permanently delete \"</string>
<string name="drawer_delete_session_suffix">\" and its message history.</string>
<string name="drawer_remove_provisional_thread_title">Remove Thread?</string>
<string name="drawer_remove_provisional_thread_message">This removes \"%1$s\" from this device. It does not delete promoted or server history.</string>
<string name="drawer_untitled">Untitled</string>
<string name="drawer_delete">Delete</string>
<string name="drawer_thread">Thread</string>
@@ -3756,6 +3754,7 @@
<string name="card_send_answer_a11y">Send answer</string>
<string name="card_submit">Submit</string>
<string name="card_answer_placeholder">Type an answer…</string>
<string name="card_other_answer_placeholder">Other (type your answer)…</string>
<string name="session_ttl_title">Keep this pairing for…</string>
<string name="session_ttl_intro">Your phone will reconnect automatically during this window. You can revoke the pairing at any time.</string>
<string name="session_ttl_stays_paired">This device will stay paired until you revoke it.</string>
@@ -1,73 +0,0 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class ProactiveInboxStoreTest {
@Test
fun `remove thread matches the rendered connection row and preserves other rows`() = runBlocking {
val repository = ProactiveInboxRepository(InMemoryPreferencesDataStore())
repository.add(entry("owned", "reminders", "connection-a", notificationId = 42))
repository.add(entry("legacy", "reminders", null))
repository.add(entry("other-connection", "reminders", "connection-b"))
repository.add(entry("other-thread", "updates", "connection-a"))
val removed = repository.removeThread("reminders", "connection-a")
assertEquals(setOf("owned", "legacy"), removed.map { it.id }.toSet())
assertEquals(listOf(42), removed.mapNotNull { it.notificationId })
assertEquals(
setOf("other-connection", "other-thread"),
repository.entries.first().map { it.id }.toSet(),
)
}
@Test
fun `blank chat id removes only the local phone fallback row`() = runBlocking {
val repository = ProactiveInboxRepository(InMemoryPreferencesDataStore())
repository.add(entry("default", null, "connection-a"))
repository.add(entry("named", "reminders", "connection-a"))
repository.removeThread("phone", "connection-a")
assertEquals(listOf("named"), repository.entries.first().map { it.id })
}
private fun entry(
id: String,
chatId: String?,
connectionId: String?,
notificationId: Int? = null,
) =
ProactiveInboxEntry(
id = id,
title = "Hermes",
text = id,
receivedAt = 1L,
chatId = chatId,
connectionId = connectionId,
notificationId = notificationId,
)
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences {
val next = transform(state.value)
state.value = next
return next
}
}
}
@@ -3,7 +3,9 @@ package com.hermesandroid.relay.network.relay
import android.content.Context
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
@@ -24,7 +26,7 @@ class ProactiveMessageHandlerTest {
mockkObject(ProactiveMessageNotifier)
every {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
} returns 42
} just Runs
}
@After
@@ -42,7 +44,6 @@ class ProactiveMessageHandlerTest {
handler.onMessage(messageEnvelope(surfacing = "notification"))
assertEquals(1, persisted.size)
assertEquals(42, persisted.single().notificationId)
verify(exactly = 1) {
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
}
@@ -50,8 +51,7 @@ class ProactiveMessageHandlerTest {
@Test
fun `inbox surfacing persists silently`() {
val persisted = mutableListOf<ProactiveMessage>()
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
val handler = ProactiveMessageHandler(context, toInbox = {}).apply {
injectIntoThread = { true }
}
@@ -60,7 +60,6 @@ class ProactiveMessageHandlerTest {
verify(exactly = 0) {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
}
assertEquals(null, persisted.single().notificationId)
}
@Test
@@ -1,74 +0,0 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
class ProactiveMessageNotifierTest {
private lateinit var context: Context
private lateinit var manager: NotificationManager
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
manager = context.getSystemService(NotificationManager::class.java)
manager.cancelAll()
shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(
Manifest.permission.POST_NOTIFICATIONS,
)
}
@After
fun tearDown() {
manager.cancelAll()
}
@Test
fun `notification identity is stable per wire message id`() {
assertEquals(
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
ProactiveMessageNotifier.notificationIdFor("message-1", "updates"),
)
assertNotEquals(
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
ProactiveMessageNotifier.notificationIdFor("message-2", "reminders"),
)
}
@Test
fun `blank message ids keep independent thread slots`() {
assertNotEquals(
ProactiveMessageNotifier.notificationIdFor(null, "reminders"),
ProactiveMessageNotifier.notificationIdFor(null, "updates"),
)
}
@Test
fun `cancel removes only the persisted notification slot`() {
ProactiveMessageNotifier.notify(context, "Hermes", "first", "message-1", "reminders")
ProactiveMessageNotifier.notify(context, "Hermes", "second", "message-2", "updates")
ProactiveMessageNotifier.cancel(
context,
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
)
assertEquals(
listOf(ProactiveMessageNotifier.notificationIdFor("message-2", "updates")),
manager.activeNotifications.map { it.id },
)
}
}
@@ -0,0 +1,103 @@
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.onNodeWithText
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.HermesCard
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.ui.components.HermesCardBubble
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 ClarifyCardScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLongOptions() = capture("01-compact-phone-long-options.png", 1f)
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLargeText() = capture("02-compact-phone-font-1_5.png", 1.5f)
@Test
@Config(qualifiers = "w720dp-h360dp-xhdpi")
fun landscape() = capture("03-landscape-720x360.png", 1f)
@Test
@Config(qualifiers = "w330dp-h720dp-xhdpi")
fun foldablePaneWidth() = capture("04-foldable-narrow-pane.png", 1.3f)
private fun capture(fileName: String, fontScale: Float) {
compose.setContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale),
) {
HermesRelayTheme(themePreference = "dark") {
LazyColumn(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp),
) {
item {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-render",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, _ -> },
)
}
}
}
}
}
compose.onNodeWithText("Other (type your answer)…").assertExists()
repeat(4) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
val evidenceDir = File("build/ui-evidence/clarify-card")
evidenceDir.mkdirs()
compose.onRoot().captureRoboImage(File(evidenceDir, fileName).path)
}
private fun clarifyCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Hermes needs clarification",
body = "Which deployment approach should I use for the migration?",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf(
"Migrate everything immediately and accept a short maintenance window",
"Keep both systems running while traffic moves in measured stages",
"Pause until every downstream consumer has been verified",
"Use a reversible canary rollout with automatic rollback thresholds",
),
allowFreeText = true,
),
)
}
@@ -13,7 +13,10 @@ import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.click
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.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
@@ -147,6 +150,74 @@ class HermesApprovalCardInteractionTest {
compose.runOnIdle { assertEquals(listOf("[\"prod\",\"dev\"]"), answers) }
}
@Test
fun `custom clarify answer uses IME send and trims exactly once`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" use a hybrid rollout ")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("use a hybrid rollout"), answers) }
}
@Test
fun `blank custom clarify answer cannot submit from icon or IME`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-blank",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" ")
compose.onNodeWithContentDescription("Send answer").assertIsNotEnabled()
field.performImeAction()
compose.runOnIdle { assertEquals(emptyList<String>(), answers) }
}
@Test
fun `multi select IME submission combines choices and custom answer`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(multiSelect = true),
cardKey = "clarify-multi-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
compose.onNodeWithText("stage").performTouchInput { click() }
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput("canary first")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("[\"stage\",\"canary first\"]"), answers) }
}
private fun approvalCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_APPROVAL,
title = "Approval requested",
@@ -173,6 +244,17 @@ class HermesApprovalCardInteractionTest {
id = CARD_KEY,
)
private fun clarifyCard(multiSelect: Boolean = false) = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Choose environments",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf("dev", "stage", "prod"),
multiSelect = multiSelect,
allowFreeText = true,
),
)
private companion object {
const val CARD_KEY = "approval-session-a"
}
@@ -18,7 +18,6 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -45,72 +44,6 @@ class SessionDrawerTest {
assertEquals(0.45f, UNPINNED_STAR_ALPHA, 0.0f)
}
@Test
fun `provisional thread exposes local delete only`() {
var deletedProvisional: String? = null
var deletedServerSession: String? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
threadsCapabilityActive = true,
provisionalThreads = listOf(
ProvisionalThreadRow(
chatId = "reminders",
title = "Reminder",
messageCount = 1,
lastActivityAt = 1L,
),
),
onDeleteProvisionalThread = { deletedProvisional = it },
onNewChat = {},
onSelectSession = {},
onDeleteSession = { deletedServerSession = it },
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithContentDescription("Session actions").performClick()
compose.onNodeWithText("Pin session").assertDoesNotExist()
compose.onNodeWithText("Rename").assertDoesNotExist()
compose.onNodeWithText("Delete").performClick()
compose.onNodeWithText("Remove Thread?").assertIsDisplayed()
compose.onNodeWithText(
"This removes \"Reminder\" from this device. It does not delete promoted or server history.",
).assertIsDisplayed()
compose.onNodeWithText("Delete").performClick()
compose.runOnIdle {
assertEquals("reminders", deletedProvisional)
assertEquals(null, deletedServerSession)
}
}
@Test
fun `provisional thread hides actions when supervised deletion is disabled`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
supervisedSessionActions = SupervisedSessionActions(delete = false),
provisionalThreads = listOf(
ProvisionalThreadRow("reminders", "Reminder", 1, 1L),
),
onDeleteProvisionalThread = {},
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithContentDescription("Session actions").assertDoesNotExist()
}
@Test
fun `archive filter resets when connection cannot restore archived sessions`() {
assertEquals(
@@ -36,21 +36,6 @@ class ProvisionalThreadRowsTest {
assertTrue("phone" in rows)
}
@Test
fun promotedChatIdSuppressesOnlyItsProvisionalRow() {
val rows = buildProvisionalThreadRows(
entries = listOf(
entry("promoted", connectionId = "connection-a", chatId = "reminders"),
entry("still-local", connectionId = "connection-a", chatId = "updates"),
),
activeConnectionId = "connection-a",
realThreadChatIds = listOf("reminders"),
)
assertFalse("reminders" in rows)
assertEquals(listOf("still-local"), rows.getValue("updates").map { it.id })
}
private fun entry(id: String, connectionId: String?, chatId: String?) =
ProactiveInboxEntry(
id = id,
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
@@ -1049,6 +1050,65 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun detachedClarifyExpiryCannotRestoreAStaleCardAfterNavigation() {
val secondSession = "stored-session-b"
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
val checkpointStore = MemoryCheckpointStore()
gatewayHarness.resumeLiveSessionIds[secondSession] = "live-b"
viewModel.setChatTurnCheckpointStore(checkpointStore)
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
viewModel.sendMessage("Ask before continuing")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"clarify.request",
buildJsonObject {
put("request_id", "clarify-detached")
put("question", "Which rollout?")
put("choices", buildJsonArray {
add(JsonPrimitive("canary"))
add(JsonPrimitive("all at once"))
})
},
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value?.ask?.requestId == "clarify-detached" }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession }
awaitCondition { checkpointStore.checkpoint?.pendingAsk?.requestId == "clarify-detached" }
serverWs.send(
gatewayHarness.eventFrame(
"clarify.expire",
buildJsonObject { put("request_id", "clarify-detached") },
"live-resumed",
),
)
awaitCondition {
checkpointStore.checkpoint != null && checkpointStore.checkpoint?.pendingAsk == null
}
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Waiting for rollout choice"
viewModel.switchSession(STORED_SESSION_ID)
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.currentSessionId.value == STORED_SESSION_ID && handler.isStreaming.value
}
assertNull(viewModel.pendingAsk.value)
assertTrue(
handler.messages.value.flatMap { it.cards }
.none { it.type == HermesCard.BuiltInTypes.ASK_CLARIFY },
)
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
}
@Test
fun stopOnUnsolicitedTurnInterruptsTheGatewaySession() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
@@ -1,37 +0,0 @@
package com.hermesandroid.relay.viewmodel
import org.junit.Assert.assertEquals
import org.junit.Test
class PhoneThreadChatIdIndexTest {
@Test
fun `index is visible only to its owning connection`() {
val index = PhoneThreadChatIdIndex(
connectionId = "connection-a",
values = mapOf("session-a" to "reminders"),
)
assertEquals(index.values, visiblePhoneThreadChatIds("connection-a", index))
assertEquals(emptyMap<String, String>(), visiblePhoneThreadChatIds("connection-b", index))
assertEquals(emptyMap<String, String>(), visiblePhoneThreadChatIds(null, index))
}
@Test
fun `a later failed refresh preserves the last successful index`() {
val successful = reconcilePhoneThreadChatIdIndex(
current = PhoneThreadChatIdIndex(),
requestedConnectionId = "connection-a",
activeConnectionId = "connection-a",
fetched = Result.success(mapOf("session-a" to "reminders")),
)
val afterFailure = reconcilePhoneThreadChatIdIndex(
current = successful,
requestedConnectionId = "connection-a",
activeConnectionId = "connection-a",
fetched = Result.failure(IllegalStateException("offline")),
)
assertEquals(successful, afterFailure)
}
}
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
-29
View File
@@ -28,7 +28,6 @@ import json
import unittest
from typing import Any
from plugin.phone_platform import _normalize_reply
from plugin.relay.channels.proactive import ProactiveChannel, ProactiveError
@@ -337,34 +336,6 @@ class ProactiveChannelTests(unittest.TestCase):
_run(run())
def test_custom_chat_id_survives_relay_drain_and_adapter_normalization(self) -> None:
async def run() -> None:
ch = ProactiveChannel()
ws = _FakeWs()
await ch.handle(
ws,
{
"type": "proactive.reply",
"payload": {
"text": "continue this thread",
"chat_id": "thread-project-461",
"reply_to": "prompt-1",
"message_id": "reply-1",
},
},
)
replies = await ch.take_replies(timeout=0.1)
self.assertEqual(len(replies), 1)
normalized = _normalize_reply(replies[0], "configured-home")
self.assertIsNotNone(normalized)
assert normalized is not None
self.assertEqual(normalized["chat_id"], "thread-project-461")
self.assertEqual(normalized["reply_to"], "prompt-1")
self.assertEqual(normalized["message_id"], "reply-1")
_run(run())
def test_reply_empty_text_dropped(self) -> None:
async def run() -> None:
ch = ProactiveChannel()