Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
524e319f95 | ||
|
|
647d1f9aea |
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
|
||||
|
||||
## [Android 1.13.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
+8
-19
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.0
|
||||
# Hermes-Relay Android v1.13.1
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,28 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
|
||||
|
||||
## Added
|
||||
|
||||
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
|
||||
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
|
||||
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
|
||||
|
||||
## Changed
|
||||
|
||||
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
|
||||
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
|
||||
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
|
||||
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.0** (versionCode **49**).
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Certify Android session activity across lifecycle and profile boundaries
|
||||
|
||||
The contract fixture now covers every upstream live status, complete-snapshot
|
||||
disappearance, client-side ownership of duplicate durable ids across profiles,
|
||||
and older Gateways without `session.active_list`. Before calling the status
|
||||
model device-certified:
|
||||
|
||||
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
|
||||
completion, Stop, reconnect, app restart, and process recreation against
|
||||
current vanilla upstream.
|
||||
- Verify All Profiles with duplicate session ids across two profiles and two
|
||||
saved connections; no late snapshot or old socket generation may mark the
|
||||
wrong row live.
|
||||
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
|
||||
remains Checking, ambiguous or partially
|
||||
resolved process-wide snapshots infer no absence, a complete empty snapshot
|
||||
settles every unambiguously owned scope, and REST `is_active=true` never
|
||||
renders as Working.
|
||||
- Run a background process that outlives its parent turn and verify Background
|
||||
work remains separate from the conversation's Idle state.
|
||||
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
|
||||
activity route with explicit profile ownership so multi-profile clients do
|
||||
not need to resolve process-wide rows from durable keys.
|
||||
|
||||
---
|
||||
|
||||
## Bot Mode follow-ups after multi-gateway aggregation
|
||||
|
||||
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Follow live Hermes state",
|
||||
"bullets": [
|
||||
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
|
||||
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
|
||||
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
|
||||
* Include bounded visible text and an available screenshot in compatible Assistant turns.
|
||||
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
|
||||
@@ -451,7 +451,8 @@ data class ChatSession(
|
||||
val outputTokens: Int = 0,
|
||||
val actualCostUsd: Double? = null,
|
||||
val estimatedCostUsd: Double? = null,
|
||||
val isActive: Boolean = false,
|
||||
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
|
||||
val recentlyActive: Boolean = false,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L,
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityOwner private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
val storedSessionId: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String, storedSessionId: String) =
|
||||
SessionActivityOwner(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
storedSessionId = storedSessionId.trim(),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityScope private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String) = SessionActivityScope(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionActivityPhase {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Idle,
|
||||
}
|
||||
|
||||
enum class SessionActivityFreshness {
|
||||
Confirmed,
|
||||
Revalidating,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
enum class SessionActivityEvidenceSource {
|
||||
Directory,
|
||||
LocalSend,
|
||||
ActiveList,
|
||||
SessionEvent,
|
||||
PendingInput,
|
||||
Terminal,
|
||||
Checkpoint,
|
||||
Process,
|
||||
}
|
||||
|
||||
data class SessionActivityEvidence(
|
||||
val source: SessionActivityEvidenceSource,
|
||||
val generation: Long,
|
||||
val observedAtMillis: Long,
|
||||
)
|
||||
|
||||
data class SessionActivityRecord(
|
||||
val owner: SessionActivityOwner,
|
||||
/** Authoritative turn state before exact pending-input and background-process overlays. */
|
||||
val turnPhase: SessionActivityPhase,
|
||||
val freshness: SessionActivityFreshness,
|
||||
val evidence: SessionActivityEvidence,
|
||||
val runtimeId: String? = null,
|
||||
val pendingInputs: Map<String, Long?> = emptyMap(),
|
||||
val backgroundProcessIds: Set<String> = emptySet(),
|
||||
) {
|
||||
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
|
||||
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
|
||||
return when {
|
||||
hasPendingInput -> SessionActivityPhase.NeedsInput
|
||||
turnPhase != SessionActivityPhase.Idle -> turnPhase
|
||||
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
|
||||
else -> SessionActivityPhase.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels uncertain or background activity as Working. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
|
||||
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
|
||||
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
|
||||
SessionActivityPhase.Idle -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionLiveStatus {
|
||||
Starting,
|
||||
Working,
|
||||
Waiting,
|
||||
Idle,
|
||||
}
|
||||
|
||||
data class SessionLiveRuntime(
|
||||
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
|
||||
val owner: SessionActivityOwner?,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
)
|
||||
|
||||
sealed interface SessionActivityUpdate {
|
||||
val generation: Long
|
||||
val observedAtMillis: Long
|
||||
|
||||
data class BeginGeneration(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ObserveOwner(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LocalSend(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LiveState(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val status: SessionLiveStatus,
|
||||
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RuntimeState(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ActiveList(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
/** True only when every upstream row was safely attributable for this scope. */
|
||||
val isCompleteForScope: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class StatusUnavailable(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Terminal(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String? = null,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RestoreCheckpoint(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val phase: SessionActivityPhase,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputOpened(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val expiresAtMillis: Long? = null,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputClosed(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ProcessState(
|
||||
val owner: SessionActivityOwner,
|
||||
val processId: String,
|
||||
val running: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Tick(
|
||||
val nowMillis: Long,
|
||||
override val generation: Long = Long.MAX_VALUE,
|
||||
override val observedAtMillis: Long = nowMillis,
|
||||
) : SessionActivityUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
|
||||
* An unsuccessful/unsupported refresh never manufactures an idle result.
|
||||
*/
|
||||
data class SessionActivityRegistry(
|
||||
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
|
||||
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
|
||||
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
|
||||
) {
|
||||
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
|
||||
|
||||
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
|
||||
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
|
||||
|
||||
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
|
||||
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
|
||||
|
||||
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
|
||||
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
|
||||
val scope = update.scope()
|
||||
val currentGeneration = generations[scope]
|
||||
if (currentGeneration != null && update.generation < currentGeneration) return this
|
||||
|
||||
var state = this
|
||||
if (currentGeneration == null || update.generation > currentGeneration) {
|
||||
state = state.beginGeneration(scope, update.generation)
|
||||
}
|
||||
|
||||
return when (update) {
|
||||
is SessionActivityUpdate.BeginGeneration -> state
|
||||
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
|
||||
is SessionActivityUpdate.LocalSend -> state.putTurn(
|
||||
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
|
||||
is SessionActivityUpdate.RuntimeState -> {
|
||||
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
|
||||
state.putTurn(
|
||||
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
|
||||
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
|
||||
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
|
||||
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
|
||||
update.owner, update.requestId, update.expiresAtMillis, true,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
|
||||
update.owner, update.requestId, null, false,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
|
||||
is SessionActivityUpdate.Tick -> state
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
|
||||
val refreshedRecords = records.mapValues { (owner, record) ->
|
||||
if (owner.scope() == scope) {
|
||||
record.copy(freshness = SessionActivityFreshness.Revalidating)
|
||||
} else record
|
||||
}
|
||||
return copy(
|
||||
records = refreshedRecords,
|
||||
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
|
||||
generations = generations + (scope to generation),
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
val observed = SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Revalidating,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
return copy(records = records + (update.owner to observed))
|
||||
}
|
||||
|
||||
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
|
||||
owner = update.owner,
|
||||
runtimeId = update.runtimeId,
|
||||
phase = update.status.phase(),
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = update.source,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
|
||||
private fun putTurn(
|
||||
owner: SessionActivityOwner,
|
||||
runtimeId: String?,
|
||||
phase: SessionActivityPhase,
|
||||
freshness: SessionActivityFreshness,
|
||||
source: SessionActivityEvidenceSource,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner]
|
||||
val record = SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = phase,
|
||||
freshness = freshness,
|
||||
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
|
||||
runtimeId = runtimeId ?: previous?.runtimeId,
|
||||
pendingInputs = previous?.pendingInputs.orEmpty(),
|
||||
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
|
||||
)
|
||||
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return copy(
|
||||
records = records + (owner to record),
|
||||
runtimeAliases = if (alias == null) runtimeAliases else {
|
||||
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
|
||||
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
|
||||
"Active-list rows must belong to the snapshot scope"
|
||||
}
|
||||
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
|
||||
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
|
||||
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
|
||||
resolvedRuntimes.forEach { runtime ->
|
||||
val resolvedOwner = requireNotNull(runtime.owner)
|
||||
state = state.putTurn(
|
||||
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
|
||||
)
|
||||
if (runtime.status == SessionLiveStatus.Idle) {
|
||||
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
|
||||
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
|
||||
}
|
||||
}
|
||||
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
|
||||
if (!snapshotCanSettle) return state
|
||||
val settled = state.records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == update.scope && owner !in observedOwners &&
|
||||
record.shouldSettleWhenAbsent()
|
||||
) {
|
||||
record.copy(
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
} else record
|
||||
}
|
||||
return state.copy(records = settled)
|
||||
}
|
||||
|
||||
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == scope && record.evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
)
|
||||
) {
|
||||
record.copy(freshness = SessionActivityFreshness.Unavailable)
|
||||
} else record
|
||||
},
|
||||
)
|
||||
|
||||
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
|
||||
val settled = putTurn(
|
||||
update.owner,
|
||||
runtimeId = null,
|
||||
phase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = SessionActivityEvidenceSource.Terminal,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
val record = requireNotNull(settled.records[update.owner]).copy(
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
)
|
||||
return settled.copy(
|
||||
records = settled.records + (update.owner to record),
|
||||
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
|
||||
alias.scope == update.owner.scope() && owner == update.owner &&
|
||||
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
return putTurn(
|
||||
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
|
||||
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePendingInput(
|
||||
owner: SessionActivityOwner,
|
||||
requestId: String,
|
||||
expiresAtMillis: Long?,
|
||||
opened: Boolean,
|
||||
confirmed: Boolean,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner] ?: SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = if (confirmed) {
|
||||
SessionActivityFreshness.Confirmed
|
||||
} else {
|
||||
SessionActivityFreshness.Revalidating
|
||||
},
|
||||
evidence = SessionActivityEvidence(
|
||||
if (confirmed) {
|
||||
SessionActivityEvidenceSource.PendingInput
|
||||
} else {
|
||||
SessionActivityEvidenceSource.Checkpoint
|
||||
},
|
||||
generation,
|
||||
observedAtMillis,
|
||||
),
|
||||
)
|
||||
val pending = if (opened) {
|
||||
previous.pendingInputs + (requestId to expiresAtMillis)
|
||||
} else {
|
||||
previous.pendingInputs - requestId
|
||||
}
|
||||
return copy(records = records + (owner to previous.copy(
|
||||
pendingInputs = pending,
|
||||
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
|
||||
evidence = if (confirmed) {
|
||||
SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.PendingInput,
|
||||
generation,
|
||||
observedAtMillis,
|
||||
)
|
||||
} else previous.evidence,
|
||||
)))
|
||||
}
|
||||
|
||||
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
|
||||
val previous = records[update.owner] ?: SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
val processes = if (update.running) {
|
||||
previous.backgroundProcessIds + update.processId
|
||||
} else {
|
||||
previous.backgroundProcessIds - update.processId
|
||||
}
|
||||
return copy(records = records + (update.owner to previous.copy(
|
||||
backgroundProcessIds = processes,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)))
|
||||
}
|
||||
|
||||
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (_, record) ->
|
||||
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
|
||||
},
|
||||
)
|
||||
|
||||
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
|
||||
runtimeId != null || evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
)
|
||||
|
||||
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
|
||||
is SessionActivityUpdate.BeginGeneration -> scope
|
||||
is SessionActivityUpdate.ObserveOwner -> owner.scope()
|
||||
is SessionActivityUpdate.RuntimeState -> scope
|
||||
is SessionActivityUpdate.ActiveList -> scope
|
||||
is SessionActivityUpdate.StatusUnavailable -> scope
|
||||
is SessionActivityUpdate.Terminal -> owner.scope()
|
||||
is SessionActivityUpdate.LocalSend -> owner.scope()
|
||||
is SessionActivityUpdate.LiveState -> owner.scope()
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
|
||||
is SessionActivityUpdate.ProcessState -> owner.scope()
|
||||
is SessionActivityUpdate.Tick -> error("Tick has no scope")
|
||||
}
|
||||
|
||||
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
|
||||
|
||||
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
|
||||
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
|
||||
SessionLiveStatus.Working -> SessionActivityPhase.Working
|
||||
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
|
||||
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
|
||||
}
|
||||
|
||||
data class RuntimeAlias(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
|
||||
|
||||
/** Live activity surfaced beside a session without conflating it with selection. */
|
||||
enum class SessionActivityState {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -2075,7 +2075,7 @@ class ChatHandler {
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
recentlyActive = item.isActive,
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
|
||||
@@ -52,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -278,6 +279,12 @@ class GatewayChatClient(
|
||||
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
|
||||
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
|
||||
|
||||
/** Per-socket capability for upstream's process-wide live-session snapshot. */
|
||||
private val _activeSessionCapability =
|
||||
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
|
||||
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
|
||||
_activeSessionCapability.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active personality the gateway is applying, as a config value ("none" when
|
||||
* the overlay is cleared, otherwise the personality name). Tracks the
|
||||
@@ -806,6 +813,21 @@ class GatewayChatClient(
|
||||
fun currentLiveSessionId(storedId: String): String? =
|
||||
liveSessionId?.takeIf { storedSessionId == storedId }
|
||||
|
||||
/**
|
||||
* Exact durable/profile owner already held by this client for [runtimeId].
|
||||
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
|
||||
* because Android recorded it when the runtime was created/resumed/detached.
|
||||
*/
|
||||
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
|
||||
if (runtimeId == liveSessionId) {
|
||||
val storedId = storedSessionId ?: return null
|
||||
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
|
||||
}
|
||||
return backgroundTurns[runtimeId]?.let { owner ->
|
||||
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
|
||||
* sustained network change). If a turn is in flight, the current socket is
|
||||
@@ -2065,6 +2087,47 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch authoritative in-memory execution states from current upstream
|
||||
* Hermes. `session.active_list` is process-wide: it accepts only an optional
|
||||
* current runtime id and does not profile-filter its rows. Accordingly this
|
||||
* transport returns rows unscoped and never derives activity from REST
|
||||
* `is_active` or stamps the selected profile onto a row.
|
||||
*/
|
||||
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
|
||||
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (error: Exception) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
val result = rpc(
|
||||
"session.active_list",
|
||||
buildJsonObject {
|
||||
liveSessionId?.let { put("current_session_id", it) }
|
||||
},
|
||||
)
|
||||
val error = result.exceptionOrNull()
|
||||
if (error.isMethodNotFound()) {
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
if (error != null) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
|
||||
return try {
|
||||
val payload = result.getOrThrow()
|
||||
val rows = payload["sessions"] as? JsonArray
|
||||
?: throw GatewayRpcException("session.active_list returned no sessions array")
|
||||
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
|
||||
} catch (parseError: Exception) {
|
||||
GatewayActiveSessionsResult.TransientFailure(parseError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one process owned by the current live gateway session. */
|
||||
suspend fun killProcess(processId: String): Result<Unit> {
|
||||
if (processId.isBlank()) {
|
||||
@@ -2503,6 +2566,7 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
@@ -2749,6 +2813,28 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseGatewayActiveSession(
|
||||
element: kotlinx.serialization.json.JsonElement,
|
||||
): GatewayActiveSession {
|
||||
val row = element as? JsonObject
|
||||
?: throw GatewayRpcException("session.active_list returned a non-object row")
|
||||
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no runtime id")
|
||||
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no session key")
|
||||
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
|
||||
?: throw GatewayRpcException("session.active_list row returned an unknown status")
|
||||
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
|
||||
?: throw GatewayRpcException("session.active_list row returned no last_active")
|
||||
return GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = storedId,
|
||||
status = status,
|
||||
lastActiveEpochSeconds = lastActive,
|
||||
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
}
|
||||
|
||||
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
|
||||
if (error.isMethodNotFound()) {
|
||||
_processCapability.value = GatewayProcessCapability.Unsupported
|
||||
@@ -3222,6 +3308,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
@@ -3407,6 +3494,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
|
||||
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Authoritative execution state reported by upstream `session.active_list`. */
|
||||
enum class GatewayActiveSessionStatus(val wireValue: String) {
|
||||
Idle("idle"),
|
||||
Starting("starting"),
|
||||
Working("working"),
|
||||
Waiting("waiting");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
|
||||
"idle" -> Idle
|
||||
"starting" -> Starting
|
||||
"working" -> Working
|
||||
"waiting" -> Waiting
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-memory runtime returned by upstream `session.active_list`.
|
||||
*
|
||||
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
|
||||
* identify their profile, so [profile] stays null unless a future gateway
|
||||
* explicitly sends one. Callers must resolve [storedSessionId] against their
|
||||
* own profile-scoped session registry and fail closed when ownership is
|
||||
* ambiguous; the transport never synthesizes profile attribution.
|
||||
*/
|
||||
data class GatewayActiveSession(
|
||||
/** Per-process runtime id used by live Gateway events and session RPCs. */
|
||||
val runtimeSessionId: String,
|
||||
/** Durable history id (`session_key`) used by the REST/session database. */
|
||||
val storedSessionId: String,
|
||||
val status: GatewayActiveSessionStatus,
|
||||
/** Unix epoch seconds from upstream's in-memory runtime record. */
|
||||
val lastActiveEpochSeconds: Double,
|
||||
/** Future-compatible only; null for the current upstream contract. */
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
/** Exact owner already known by this client for a foreground or detached runtime. */
|
||||
data class GatewayKnownSessionOwner(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
)
|
||||
|
||||
/** Whether the current Gateway socket exposes `session.active_list`. */
|
||||
enum class GatewayActiveSessionCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of one process-wide live-session snapshot request. Unsupported is
|
||||
* intentionally distinct from transport/protocol failure so callers can use
|
||||
* another source only for older gateways, while failures remain Unknown.
|
||||
*/
|
||||
sealed interface GatewayActiveSessionsResult {
|
||||
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
|
||||
data object Unsupported : GatewayActiveSessionsResult
|
||||
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-level background-process events. These are deliberately separate
|
||||
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
|
||||
|
||||
@@ -250,6 +250,7 @@ data class SessionItem(
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
|
||||
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
|
||||
/** REST recency heuristic from upstream; not live Gateway execution state. */
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("has_model_config")
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.SweepGradient
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -116,6 +120,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal enum class SessionDrawerFilter {
|
||||
All,
|
||||
@@ -277,6 +282,11 @@ fun SessionDrawerContent(
|
||||
}
|
||||
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
|
||||
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
|
||||
val scopedActivityStates = scopedSessionActivityStates(
|
||||
rows = sourceRows,
|
||||
activityStates = activityStates,
|
||||
allowBareSessionIds = !showAllProfiles,
|
||||
)
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
@@ -319,8 +329,13 @@ fun SessionDrawerContent(
|
||||
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
|
||||
val drawerNowMillis = rememberDrawerClock(
|
||||
isEnabled = isOpen && (
|
||||
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
|
||||
),
|
||||
)
|
||||
val drawerTitle = if (showAllProfiles) {
|
||||
stringResource(R.string.drawer_all_profiles)
|
||||
} else {
|
||||
@@ -728,6 +743,7 @@ fun SessionDrawerContent(
|
||||
ProjectGroupHeader(
|
||||
label = label,
|
||||
rows = group.rows,
|
||||
nowMillis = drawerNowMillis,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedProjectGroups = if (expanded) {
|
||||
@@ -750,9 +766,7 @@ fun SessionDrawerContent(
|
||||
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
|
||||
val session = row.session
|
||||
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
|
||||
val activityState = activityStates[sessionRowKey(row)]
|
||||
?: activityStates[session.sessionId]
|
||||
?: if (session.isActive) SessionActivityState.Working else null
|
||||
val activityState = scopedActivityStates[sessionRowKey(row)]
|
||||
SessionItem(
|
||||
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
|
||||
session = session,
|
||||
@@ -764,6 +778,7 @@ fun SessionDrawerContent(
|
||||
showUpdated = viewOptions.showUpdated,
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
nowMillis = drawerNowMillis,
|
||||
actionsEnabled = !provisional,
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
@@ -1217,7 +1232,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
|
||||
|
||||
private fun SessionDrawerStatus.label(): String = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
@@ -1242,6 +1261,7 @@ private fun compactMetric(value: Double): String = when {
|
||||
private fun ProjectGroupHeader(
|
||||
label: String,
|
||||
rows: List<ProfileSessionRow>,
|
||||
nowMillis: Long,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
@@ -1290,7 +1310,7 @@ private fun ProjectGroupHeader(
|
||||
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
|
||||
if (latestActivity > 0L) {
|
||||
append(" · ")
|
||||
append(formatTimestamp(latestActivity, locale, context))
|
||||
append(formatTimestamp(latestActivity, locale, context, nowMillis))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1319,6 +1339,7 @@ private fun SessionItem(
|
||||
showUpdated: Boolean,
|
||||
showTokens: Boolean,
|
||||
showCost: Boolean,
|
||||
nowMillis: Long,
|
||||
actionsEnabled: Boolean,
|
||||
isActive: Boolean,
|
||||
activityState: SessionActivityState?,
|
||||
@@ -1337,11 +1358,7 @@ private fun SessionItem(
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val activityLabel = when (activityState) {
|
||||
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
|
||||
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
|
||||
null -> null
|
||||
}
|
||||
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -1441,7 +1458,7 @@ private fun SessionItem(
|
||||
sourceBadge(session.source)?.let { badge ->
|
||||
SourceChip(badge)
|
||||
}
|
||||
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
|
||||
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1583,6 +1600,16 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
|
||||
SessionActivityState.Starting -> R.string.drawer_activity_starting
|
||||
SessionActivityState.Working -> R.string.drawer_activity_working
|
||||
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
|
||||
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
|
||||
SessionActivityState.Checking -> R.string.drawer_activity_checking
|
||||
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
|
||||
val icon: ImageVector = when (badge.kind) {
|
||||
@@ -1687,18 +1714,29 @@ private fun ProfileBadge(
|
||||
@Composable
|
||||
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color),
|
||||
modifier = if (state == SessionActivityState.BackgroundWork) {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.border(1.dp, color, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
@@ -1716,10 +1754,17 @@ private fun Modifier.sessionActivityBorder(
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val shouldRotate = animated && state == SessionActivityState.Working
|
||||
val shouldRotate = animated && (
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
)
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
@@ -1825,20 +1870,45 @@ private fun Modifier.sessionActivityBorder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
@Composable
|
||||
private fun rememberDrawerClock(isEnabled: Boolean): Long {
|
||||
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(isEnabled, lifecycleOwner) {
|
||||
if (!isEnabled) return@LaunchedEffect
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true) {
|
||||
nowMillis = System.currentTimeMillis()
|
||||
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nowMillis
|
||||
}
|
||||
|
||||
internal fun sessionTimestampText(
|
||||
session: ChatSession,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
val hasDistinctActivity =
|
||||
session.lastActivityAt > 0L &&
|
||||
session.startTimestamp > 0L &&
|
||||
session.lastActivityAt != session.startTimestamp
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
private fun formatTimestamp(
|
||||
millis: Long,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String {
|
||||
val diff = nowMillis - millis
|
||||
return when {
|
||||
diff < 60_000 -> context.getString(R.string.drawer_just_now)
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
@@ -1846,3 +1916,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
|
||||
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
|
||||
}
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import java.util.Locale
|
||||
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
|
||||
|
||||
internal enum class SessionDrawerStatus {
|
||||
NeedsInput,
|
||||
Starting,
|
||||
Working,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
Idle,
|
||||
}
|
||||
|
||||
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
|
||||
)
|
||||
|
||||
internal fun sessionRowKey(row: ProfileSessionRow): String =
|
||||
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
|
||||
internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
val raw = (session.gitRepoRoot ?: session.workingDirectory)
|
||||
@@ -69,12 +74,34 @@ internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
internal fun sessionDrawerStatus(
|
||||
row: ProfileSessionRow,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
): SessionDrawerStatus = when (
|
||||
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
|
||||
) {
|
||||
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
|
||||
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
|
||||
SessionActivityState.Starting -> SessionDrawerStatus.Starting
|
||||
SessionActivityState.Working -> SessionDrawerStatus.Working
|
||||
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
|
||||
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
|
||||
SessionActivityState.Checking -> SessionDrawerStatus.Checking
|
||||
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
|
||||
null -> SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes live activity to the drawer's profile-scoped row identity.
|
||||
*
|
||||
* A selected-profile drawer may accept the legacy bare session id because every row belongs
|
||||
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
|
||||
* are only unique inside their owning profile.
|
||||
*/
|
||||
internal fun scopedSessionActivityStates(
|
||||
rows: List<ProfileSessionRow>,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
allowBareSessionIds: Boolean,
|
||||
): Map<String, SessionActivityState> = buildMap {
|
||||
rows.forEach { row ->
|
||||
val rowKey = sessionRowKey(row)
|
||||
val state = activityStates[rowKey]
|
||||
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
|
||||
state?.let { put(rowKey, it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
|
||||
@@ -98,8 +125,12 @@ internal fun filterAndSortSessionRows(
|
||||
.toList()
|
||||
val statusRank = mapOf(
|
||||
SessionDrawerStatus.NeedsInput to 0,
|
||||
SessionDrawerStatus.Working to 1,
|
||||
SessionDrawerStatus.Idle to 2,
|
||||
SessionDrawerStatus.Starting to 1,
|
||||
SessionDrawerStatus.Working to 2,
|
||||
SessionDrawerStatus.BackgroundWork to 3,
|
||||
SessionDrawerStatus.Checking to 4,
|
||||
SessionDrawerStatus.Unavailable to 5,
|
||||
SessionDrawerStatus.Idle to 6,
|
||||
)
|
||||
val comparator = when (options.ordering) {
|
||||
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
|
||||
@@ -149,7 +180,11 @@ internal fun groupSessionRows(
|
||||
private val SessionDrawerStatus.displayLabel: String
|
||||
get() = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -297,21 +296,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
|
||||
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
|
||||
private val CHAT_PET_ROUTES = setOf("chat")
|
||||
|
||||
internal fun resolveSessionActivityStates(
|
||||
background: Map<String, SessionActivityState>,
|
||||
currentSessionId: String?,
|
||||
isStreaming: Boolean,
|
||||
needsInput: Boolean,
|
||||
): Map<String, SessionActivityState> = background.toMutableMap().apply {
|
||||
currentSessionId?.let { sessionId ->
|
||||
when {
|
||||
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
|
||||
isStreaming -> put(sessionId, SessionActivityState.Working)
|
||||
else -> remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
@@ -871,15 +855,9 @@ fun ChatScreen(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
|
||||
val sessionActivityStates = remember(
|
||||
backgroundSessionActivityStates,
|
||||
currentSessionId,
|
||||
isStreaming,
|
||||
pendingAsk,
|
||||
) {
|
||||
resolveSessionActivityStates(
|
||||
background = backgroundSessionActivityStates,
|
||||
currentSessionId = currentSessionId,
|
||||
val sessionActivityStates = backgroundSessionActivityStates
|
||||
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
|
||||
chatViewModel.updateCurrentSessionActivity(
|
||||
isStreaming = isStreaming,
|
||||
needsInput = pendingAsk != null,
|
||||
)
|
||||
@@ -907,6 +885,54 @@ fun ChatScreen(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
|
||||
var allProfileSessionsLoading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
suspend fun refreshAllProfileSessions(showError: Boolean) {
|
||||
if (isProfileLocked || allProfileSessionsLoading) return
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
recentlyActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
chatViewModel.updateSessionActivityDirectory(
|
||||
rows = allProfileSessions.map { it.profile to it.session.sessionId },
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (showError) snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
}
|
||||
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
|
||||
val explicitBindingProfileName = conversationBinding.profileName
|
||||
.takeIf { conversationBinding.hasExplicitOwner }
|
||||
@@ -1306,6 +1332,13 @@ fun ChatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
LaunchedEffect(chatViewModel, drawerState) {
|
||||
chatViewModel.sessionDirectoryRefreshRequests.collect {
|
||||
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
|
||||
refreshAllProfileSessions(showError = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
@@ -1393,7 +1426,6 @@ fun ChatScreen(
|
||||
}
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
@@ -2022,6 +2054,7 @@ fun ChatScreen(
|
||||
// shows up without a manual reload. Cheap dashboard read; the optimistic
|
||||
// row for the active session is preserved by ChatHandler.updateSessions.
|
||||
LaunchedEffect(drawerState.isOpen) {
|
||||
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
|
||||
if (drawerState.isOpen && chatReady) {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
@@ -2431,48 +2464,7 @@ fun ChatScreen(
|
||||
onProfileColorChange = connectionViewModel::setProfileColor,
|
||||
onRefreshAllProfiles = {
|
||||
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
refreshAllProfileSessions(showError = true)
|
||||
}
|
||||
},
|
||||
onSelectProfileSession = { profileName, sessionId ->
|
||||
|
||||
@@ -41,6 +41,14 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SessionActivityFreshness
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionActivityPhase
|
||||
import com.hermesandroid.relay.data.SessionActivityRegistry
|
||||
import com.hermesandroid.relay.data.SessionActivityScope
|
||||
import com.hermesandroid.relay.data.SessionActivityUpdate
|
||||
import com.hermesandroid.relay.data.SessionLiveRuntime
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -57,6 +65,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.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
|
||||
@@ -257,6 +268,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
|
||||
"unauthorized" in message || "forbidden" in message
|
||||
}
|
||||
|
||||
internal data class ResolvedGatewayActiveSessions(
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
val ambiguous: Boolean,
|
||||
val ambiguousForCurrent: Boolean,
|
||||
)
|
||||
|
||||
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
|
||||
internal fun resolveGatewayActiveSessions(
|
||||
sessions: List<GatewayActiveSession>,
|
||||
directory: Set<SessionActivityOwner>,
|
||||
currentOwner: SessionActivityOwner?,
|
||||
currentRuntimeId: String? = null,
|
||||
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
|
||||
): ResolvedGatewayActiveSessions {
|
||||
var ambiguous = false
|
||||
var ambiguousForCurrent = false
|
||||
val runtimes = sessions.map { row ->
|
||||
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
|
||||
?.let(AgentDisplay::profileSessionKey)
|
||||
val candidates = directory.filter { owner ->
|
||||
owner.storedSessionId == row.storedSessionId &&
|
||||
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
|
||||
}
|
||||
val owner = when {
|
||||
knownOwnersByRuntime[row.runtimeSessionId]
|
||||
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
|
||||
knownOwnersByRuntime.getValue(row.runtimeSessionId)
|
||||
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
|
||||
currentRuntimeId == row.runtimeSessionId &&
|
||||
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
|
||||
explicitProfile != null && candidates.size == 1 -> candidates.single()
|
||||
else -> null
|
||||
}
|
||||
if (owner == null) {
|
||||
ambiguous = true
|
||||
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
|
||||
}
|
||||
SessionLiveRuntime(
|
||||
owner = owner,
|
||||
runtimeId = row.runtimeSessionId,
|
||||
status = when (row.status) {
|
||||
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
|
||||
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
|
||||
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
|
||||
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
|
||||
},
|
||||
)
|
||||
}
|
||||
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
|
||||
}
|
||||
|
||||
sealed interface VoiceMessageSubmissionResult {
|
||||
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
|
||||
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
|
||||
@@ -320,27 +382,126 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
|
||||
_backgroundSessionActivityStates.asStateFlow()
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val contextKey = activeProfileContextKey
|
||||
_backgroundSessionActivityStates.value = if (contextKey == null) {
|
||||
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
|
||||
private val sessionActivityGeneration = AtomicLong(0L)
|
||||
private val sessionActivityPollMutex = Mutex()
|
||||
private var sessionActivityPollJob: Job? = null
|
||||
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
|
||||
private var lastProjectedProcessIds: Set<String> = emptySet()
|
||||
private var lastProjectedProcessOwner: SessionActivityOwner? = null
|
||||
private var lastLocalActivityOwner: SessionActivityOwner? = null
|
||||
private var lastLocalStreaming = false
|
||||
private var lastSessionActivityScope: SessionActivityScope? = null
|
||||
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
|
||||
_sessionDirectoryRefreshRequests.asSharedFlow()
|
||||
|
||||
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.lastIndexOf("::")
|
||||
if (separator <= 0 || separator >= raw.lastIndex) return null
|
||||
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
|
||||
}
|
||||
|
||||
private fun activityOwner(
|
||||
sessionId: String?,
|
||||
contextKey: String? = activeProfileContextKey,
|
||||
): SessionActivityOwner? {
|
||||
val scope = activityScope(contextKey) ?: return null
|
||||
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
|
||||
}
|
||||
|
||||
private fun reduceSessionActivity(update: SessionActivityUpdate) {
|
||||
sessionActivityRegistry.update { it.reduce(update) }
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
|
||||
sessionActivityRegistry.update { current ->
|
||||
updates.fold(current) { state, update -> state.reduce(update) }
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun activateSessionActivityScope() {
|
||||
val scope = activityScope() ?: return
|
||||
if (scope == lastSessionActivityScope) return
|
||||
clearProjectedBackgroundProcesses()
|
||||
lastSessionActivityScope = scope
|
||||
val generation = sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.BeginGeneration(
|
||||
scope = scope,
|
||||
generation = generation,
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun publishSessionActivityProjection() {
|
||||
val activeConnectionId = activityScope()?.connectionId
|
||||
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
backgroundTurnCheckpoints.keys
|
||||
.asSequence()
|
||||
.filter { it.contextKey == contextKey }
|
||||
.associate { key ->
|
||||
key.sessionId to if (
|
||||
key in backgroundNeedsInputKeys ||
|
||||
backgroundPendingInteractions.containsKey(key)
|
||||
) {
|
||||
SessionActivityState.NeedsInput
|
||||
} else {
|
||||
SessionActivityState.Working
|
||||
}
|
||||
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
|
||||
.filterKeys { it.connectionId == activeConnectionId }
|
||||
.mapKeys { (owner, _) ->
|
||||
val displayProfile = owner.profile.takeUnless {
|
||||
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
} ?: "default"
|
||||
"$displayProfile:${owner.storedSessionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
|
||||
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = checkpoint.liveSessionId,
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = backgroundPendingInteractions.containsKey(key),
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
} else if (sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
|
||||
|
||||
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
|
||||
@@ -1894,10 +2055,329 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayProcessController.dismiss(processId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the known profile/session directory used to attribute process-wide
|
||||
* `session.active_list` rows. A row is projected only when ownership is exact.
|
||||
*/
|
||||
fun updateSessionActivityDirectory(
|
||||
rows: Collection<Pair<String, String>>,
|
||||
) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
|
||||
runCatching {
|
||||
SessionActivityOwner.of(
|
||||
scope.connectionId,
|
||||
AgentDisplay.profileSessionKey(profile),
|
||||
sessionId,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory.map { owner ->
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation, now)
|
||||
},
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = sessionActivityDirectory
|
||||
.filterNotTo(mutableSetOf()) {
|
||||
it.connectionId == scope.connectionId && it.profile == scope.profile
|
||||
}
|
||||
.apply {
|
||||
sessions.forEach { row ->
|
||||
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
|
||||
}
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory
|
||||
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
|
||||
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
|
||||
)
|
||||
}
|
||||
|
||||
fun setSessionActivityDrawerOpen(open: Boolean) {
|
||||
if (open) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
|
||||
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
|
||||
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
val previousAskId = "current-pending-input"
|
||||
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
|
||||
if (sessionActivityRegistry.value.record(previousOwner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
previousOwner,
|
||||
previousAskId,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
if (needsInput || pendingWasOpen) {
|
||||
reduceSessionActivity(
|
||||
if (needsInput) {
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
} else {
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
|
||||
val alreadyStarting = sessionActivityRegistry.value.record(owner)
|
||||
?.phase(now) == SessionActivityPhase.Starting
|
||||
if ((isStreaming && !alreadyStarting) ||
|
||||
(lastLocalActivityOwner == owner && lastLocalStreaming)
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = null,
|
||||
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
lastLocalActivityOwner = owner
|
||||
lastLocalStreaming = isStreaming
|
||||
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun markSessionActivityStarting(sessionId: String?) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LocalSend(
|
||||
owner = owner,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtimeId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
_sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
fun requestSessionActivityRefresh() {
|
||||
val client = gatewayClient ?: return
|
||||
if (streamingEndpoint != "gateway" || !chatVisible) return
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollSessionActivity(client: GatewayChatClient) {
|
||||
sessionActivityPollMutex.withLock {
|
||||
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val currentScope = activityScope() ?: return
|
||||
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
|
||||
val directory = buildSet {
|
||||
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
|
||||
currentOwner?.let { add(it) }
|
||||
chatHandler?.sessions?.value.orEmpty().forEach { row ->
|
||||
add(SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
currentScope.profile,
|
||||
row.sessionId,
|
||||
))
|
||||
}
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
when (val result = client.listActiveSessions()) {
|
||||
is GatewayActiveSessionsResult.Success -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val resolved = resolveGatewayActiveSessions(
|
||||
sessions = result.sessions,
|
||||
directory = directory,
|
||||
currentOwner = currentOwner,
|
||||
currentRuntimeId = currentOwner?.let {
|
||||
client.currentLiveSessionId(it.storedSessionId)
|
||||
},
|
||||
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
|
||||
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
|
||||
val knownProfile = when {
|
||||
!known.profile.isNullOrBlank() ->
|
||||
AgentDisplay.profileSessionKey(known.profile)
|
||||
currentOwner != null &&
|
||||
row.runtimeSessionId == client.currentLiveSessionId(
|
||||
currentOwner.storedSessionId,
|
||||
) &&
|
||||
known.storedSessionId == currentOwner.storedSessionId ->
|
||||
currentOwner.profile
|
||||
else -> directory.singleOrNull { owner ->
|
||||
owner.storedSessionId == known.storedSessionId &&
|
||||
owner.profile in setOf(
|
||||
"default",
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
}?.profile ?: return@let null
|
||||
}
|
||||
row.runtimeSessionId to SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
knownProfile,
|
||||
known.storedSessionId,
|
||||
)
|
||||
}
|
||||
}.toMap(),
|
||||
)
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}
|
||||
sessionActivityRegistry.value.records.keys
|
||||
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
|
||||
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
|
||||
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
}
|
||||
if (scopes.isEmpty()) scopes += currentScope
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
|
||||
owner.connectionId == scope.connectionId && owner.profile == scope.profile
|
||||
} == true },
|
||||
isCompleteForScope = !resolved.ambiguous,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
GatewayActiveSessionsResult.Unsupported,
|
||||
is GatewayActiveSessionsResult.TransientFailure -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}.apply { add(currentScope) }
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
projectCurrentBackgroundProcesses(generation, now)
|
||||
}
|
||||
|
||||
if (gatewayClient !== client || !chatVisible) return
|
||||
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
|
||||
record.freshness == SessionActivityFreshness.Confirmed &&
|
||||
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
|
||||
}
|
||||
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
delay(delayMs)
|
||||
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
|
||||
val sessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
val previousOwner = lastProjectedProcessOwner
|
||||
if (previousOwner != null && previousOwner != owner) {
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = previousOwner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = emptySet()
|
||||
}
|
||||
lastProjectedProcessOwner = owner
|
||||
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
|
||||
lastProjectedProcessIds = emptySet()
|
||||
return
|
||||
}
|
||||
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
|
||||
reduceSessionActivities(
|
||||
(lastProjectedProcessIds + activeIds).map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = processId in activeIds,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = activeIds
|
||||
}
|
||||
|
||||
private fun clearProjectedBackgroundProcesses() {
|
||||
val owner = lastProjectedProcessOwner
|
||||
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
lastProjectedProcessIds = emptySet()
|
||||
lastProjectedProcessOwner = null
|
||||
}
|
||||
|
||||
fun updateGatewayClient(client: GatewayChatClient?) {
|
||||
val previousClient = gatewayClient
|
||||
val changed = previousClient !== client
|
||||
if (changed) {
|
||||
clearProjectedBackgroundProcesses()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityDirectory = emptySet()
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
lastSessionActivityScope = null
|
||||
gatewayVisibleReattachJob?.cancel()
|
||||
gatewayVisibleReattachJob = null
|
||||
previousClient?.setUnsolicitedTurnProvider(null)
|
||||
@@ -1916,6 +2396,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = chatHandler?.currentSessionId?.value,
|
||||
scopeKey = activeProfileContextKey,
|
||||
)
|
||||
activateSessionActivityScope()
|
||||
}
|
||||
// Bind each gateway session.create/resume to the currently-selected
|
||||
// profile (pulled live) — the upstream gateway builds the agent from it.
|
||||
@@ -2063,6 +2544,7 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Remove the detached sibling's recovery snapshot after server completion. */
|
||||
@@ -2083,7 +2565,20 @@ class ChatViewModel : ViewModel() {
|
||||
backgroundPendingInteractions.remove(key)
|
||||
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
|
||||
}
|
||||
reduceSessionActivities(
|
||||
matching.mapNotNull { key ->
|
||||
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = completion.liveSessionId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
publishBackgroundSessionActivity()
|
||||
requestSessionActivityRefresh()
|
||||
chatTurnCheckpointStore?.let { store ->
|
||||
viewModelScope.launch {
|
||||
checkpointMutex.withLock {
|
||||
@@ -2529,7 +3024,13 @@ class ChatViewModel : ViewModel() {
|
||||
fun setChatVisible(visible: Boolean) {
|
||||
val changed = chatVisible != visible
|
||||
chatVisible = visible
|
||||
if (visible && changed) prewarmGateway()
|
||||
if (visible && changed) {
|
||||
prewarmGateway()
|
||||
requestSessionActivityRefresh()
|
||||
} else if (!visible) {
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
}
|
||||
}
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -3077,6 +3578,16 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
lastSurfacedCredentialWarning = null
|
||||
gatewayStateSyncJob = viewModelScope.launch {
|
||||
launch {
|
||||
backgroundProcesses.collect {
|
||||
if (gatewayClient !== client) return@collect
|
||||
projectCurrentBackgroundProcesses(
|
||||
generation = sessionActivityGeneration.get(),
|
||||
now = System.currentTimeMillis(),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverPersonality.collect { value ->
|
||||
if (gatewayClient !== client || value == null) return@collect
|
||||
@@ -3784,6 +4295,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
activateSessionActivityScope()
|
||||
handler.activeAgentName = currentAgentDisplayName()
|
||||
if (
|
||||
previousBinding.contextKey == contextKey &&
|
||||
@@ -4051,6 +4563,8 @@ class ChatViewModel : ViewModel() {
|
||||
currentSessionProfileName() == profileName
|
||||
) {
|
||||
handler.updateSessions(sessions)
|
||||
updateCurrentProfileActivityDirectory(handler.sessions.value)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -6426,6 +6940,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
val completedOwner = activeQueueOwnerRunId
|
||||
handler.onStreamComplete(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
|
||||
completedQueueOwnerRuns += completedOwner
|
||||
}
|
||||
@@ -6454,6 +6969,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
handler.onStreamComplete(messageId)
|
||||
handler.markError(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
clearTurnCheckpoint()
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
@@ -6496,6 +7012,7 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6999,6 +7516,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = listOf("Realtime Agent"),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
return assistantMessageId
|
||||
}
|
||||
|
||||
@@ -7889,6 +8409,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
val streamDeltas = StreamDeltaCoalescer(
|
||||
scope = viewModelScope,
|
||||
@@ -8206,6 +8729,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
} else if (
|
||||
dispatchedSseEndpoint == "sessions" &&
|
||||
errorSessionId != null &&
|
||||
@@ -8255,6 +8779,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
} else {
|
||||
AppAnalytics.onStreamError()
|
||||
@@ -8295,6 +8820,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
}
|
||||
val onPreflightErrorCb = { error: Throwable ->
|
||||
@@ -8326,6 +8852,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
|
||||
@@ -8604,6 +9131,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
handler.setSessionId(sid)
|
||||
markSessionActivityStarting(sid)
|
||||
updateTurnCheckpointSession(sid)
|
||||
selectBackgroundProcessSession(sid)
|
||||
gatewayProcessController.sessionReady(sid)
|
||||
@@ -8722,6 +9250,7 @@ class ChatViewModel : ViewModel() {
|
||||
// don't resurrect the turn on SSE.
|
||||
intentionallyCancelled = false
|
||||
activeStream = null
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
} else {
|
||||
// Nothing started server-side — rerun this turn on
|
||||
// the SSE fallback. Callbacks land on the main
|
||||
|
||||
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
|
||||
resetForSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/** True only when the published process snapshot belongs to this exact owner. */
|
||||
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
|
||||
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
|
||||
|
||||
/**
|
||||
* Admit process RPCs for [sessionId] after the gateway has created/resumed
|
||||
* that chat's live session. Stale ready callbacks are ignored.
|
||||
|
||||
@@ -876,6 +876,10 @@
|
||||
<string name="drawer_search_sessions">Pesquisar sessões</string>
|
||||
<string name="drawer_activity_working">Em andamento</string>
|
||||
<string name="drawer_activity_needs_input">Precisa de resposta</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
|
||||
<string name="drawer_activity_checking">Verificando</string>
|
||||
<string name="drawer_activity_unavailable">Indisponível</string>
|
||||
<string name="drawer_new_thread">Nova Thread</string>
|
||||
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
|
||||
<string name="drawer_loading_sessions">Carregando sessões…</string>
|
||||
@@ -905,7 +909,7 @@
|
||||
<string name="drawer_filter_pinned">Fixadas</string>
|
||||
<string name="drawer_filter_archive">Arquivo</string>
|
||||
<string name="drawer_filter_sessions">Sessões</string>
|
||||
<string name="drawer_timestamp_active">Ativa</string>
|
||||
<string name="drawer_timestamp_updated">Atualizada</string>
|
||||
<string name="drawer_timestamp_started">Iniciada</string>
|
||||
<string name="drawer_just_now">Agora mesmo</string>
|
||||
<!-- P1: BridgeScreen -->
|
||||
|
||||
@@ -922,6 +922,10 @@
|
||||
<string name="drawer_search_sessions">搜索会话</string>
|
||||
<string name="drawer_activity_working">正在处理</string>
|
||||
<string name="drawer_activity_needs_input">需要输入</string>
|
||||
<string name="drawer_activity_starting">正在启动</string>
|
||||
<string name="drawer_activity_background_work">后台工作</string>
|
||||
<string name="drawer_activity_checking">正在检查</string>
|
||||
<string name="drawer_activity_unavailable">不可用</string>
|
||||
<string name="drawer_new_thread">新话题</string>
|
||||
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
|
||||
<string name="drawer_loading_sessions">正在加载会话…</string>
|
||||
@@ -951,7 +955,7 @@
|
||||
<string name="drawer_filter_pinned">已固定</string>
|
||||
<string name="drawer_filter_archive">归档</string>
|
||||
<string name="drawer_filter_sessions">会话</string>
|
||||
<string name="drawer_timestamp_active">活跃</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">已开始</string>
|
||||
<string name="drawer_just_now">刚刚</string>
|
||||
|
||||
|
||||
@@ -925,6 +925,10 @@
|
||||
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
|
||||
<string name="drawer_activity_working">Wird bearbeitet</string>
|
||||
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
|
||||
<string name="drawer_activity_starting">Wird gestartet</string>
|
||||
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
|
||||
<string name="drawer_activity_checking">Wird geprüft</string>
|
||||
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
|
||||
<string name="drawer_new_thread">Neuer Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
|
||||
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
|
||||
@@ -954,7 +958,7 @@
|
||||
<string name="drawer_filter_pinned">Angeheftet</string>
|
||||
<string name="drawer_filter_archive">Archiv</string>
|
||||
<string name="drawer_filter_sessions">Sitzungen</string>
|
||||
<string name="drawer_timestamp_active">Aktiv</string>
|
||||
<string name="drawer_timestamp_updated">Aktualisiert</string>
|
||||
<string name="drawer_timestamp_started">Gestartet</string>
|
||||
<string name="drawer_just_now">Gerade eben</string>
|
||||
|
||||
|
||||
@@ -840,6 +840,10 @@
|
||||
<string name="drawer_search_sessions">Buscar sesiones</string>
|
||||
<string name="drawer_activity_working">En curso</string>
|
||||
<string name="drawer_activity_needs_input">Requiere intervención</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
|
||||
<string name="drawer_activity_checking">Comprobando</string>
|
||||
<string name="drawer_activity_unavailable">No disponible</string>
|
||||
<string name="drawer_new_thread">Nuevo hilo</string>
|
||||
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
|
||||
<string name="drawer_loading_sessions">Cargando sesiones…</string>
|
||||
@@ -869,7 +873,7 @@
|
||||
<string name="drawer_filter_pinned">Fijado</string>
|
||||
<string name="drawer_filter_archive">Archivo</string>
|
||||
<string name="drawer_filter_sessions">Sesiones</string>
|
||||
<string name="drawer_timestamp_active">Activo</string>
|
||||
<string name="drawer_timestamp_updated">Actualizado</string>
|
||||
<string name="drawer_timestamp_started">Comenzó</string>
|
||||
<string name="drawer_just_now">En este momento</string>
|
||||
<string name="bridge_back">Atrás</string>
|
||||
|
||||
@@ -938,6 +938,10 @@
|
||||
<string name="drawer_search_sessions">セッションを検索</string>
|
||||
<string name="drawer_activity_working">処理中</string>
|
||||
<string name="drawer_activity_needs_input">入力が必要</string>
|
||||
<string name="drawer_activity_starting">開始中</string>
|
||||
<string name="drawer_activity_background_work">バックグラウンド処理</string>
|
||||
<string name="drawer_activity_checking">確認中</string>
|
||||
<string name="drawer_activity_unavailable">利用不可</string>
|
||||
<string name="drawer_new_thread">新しいスレッド</string>
|
||||
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
|
||||
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
|
||||
@@ -967,7 +971,7 @@
|
||||
<string name="drawer_filter_pinned">固定された</string>
|
||||
<string name="drawer_filter_archive">アーカイブ</string>
|
||||
<string name="drawer_filter_sessions">セッション</string>
|
||||
<string name="drawer_timestamp_active">アクティブ</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">開始しました</string>
|
||||
<string name="drawer_just_now">ちょうど今</string>
|
||||
|
||||
|
||||
@@ -948,6 +948,10 @@
|
||||
<string name="drawer_search_sessions">Поиск сессий</string>
|
||||
<string name="drawer_activity_working">Выполняется</string>
|
||||
<string name="drawer_activity_needs_input">Требуется ввод</string>
|
||||
<string name="drawer_activity_starting">Запуск</string>
|
||||
<string name="drawer_activity_background_work">Фоновая работа</string>
|
||||
<string name="drawer_activity_checking">Проверка</string>
|
||||
<string name="drawer_activity_unavailable">Недоступно</string>
|
||||
<string name="drawer_new_thread">Новая ветка</string>
|
||||
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
|
||||
<string name="drawer_loading_sessions">Загрузка сессий…</string>
|
||||
@@ -977,7 +981,7 @@
|
||||
<string name="drawer_filter_pinned">Закрепленные</string>
|
||||
<string name="drawer_filter_archive">Архив</string>
|
||||
<string name="drawer_filter_sessions">Сессии</string>
|
||||
<string name="drawer_timestamp_active">Активен</string>
|
||||
<string name="drawer_timestamp_updated">Обновлено</string>
|
||||
<string name="drawer_timestamp_started">Начат</string>
|
||||
<string name="drawer_just_now">Только что</string>
|
||||
<string name="bridge_back">Назад</string>
|
||||
|
||||
@@ -1040,6 +1040,10 @@
|
||||
<string name="drawer_search_sessions">Search sessions</string>
|
||||
<string name="drawer_activity_working">Working</string>
|
||||
<string name="drawer_activity_needs_input">Needs input</string>
|
||||
<string name="drawer_activity_starting">Starting</string>
|
||||
<string name="drawer_activity_background_work">Background work</string>
|
||||
<string name="drawer_activity_checking">Checking</string>
|
||||
<string name="drawer_activity_unavailable">Unavailable</string>
|
||||
<string name="drawer_new_thread">New Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
|
||||
<string name="drawer_loading_sessions">Loading sessions…</string>
|
||||
@@ -1069,7 +1073,7 @@
|
||||
<string name="drawer_filter_pinned">Pinned</string>
|
||||
<string name="drawer_filter_archive">Archive</string>
|
||||
<string name="drawer_filter_sessions">Sessions</string>
|
||||
<string name="drawer_timestamp_active">Active</string>
|
||||
<string name="drawer_timestamp_updated">Updated</string>
|
||||
<string name="drawer_timestamp_started">Started</string>
|
||||
<string name="drawer_just_now">Just now</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityRegistryTest {
|
||||
private val owner = owner("connection-a", "Default", "session-a")
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
fun `directory owner is checking until status is unavailable or confirms idle`() {
|
||||
val checking = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
|
||||
|
||||
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
|
||||
assertNull(confirmedIdle.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory observation cannot downgrade confirmed live evidence`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `owner normalizes profile without collapsing connection ownership`() {
|
||||
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
|
||||
val otherConnection = owner("connection-b", "default", "session-a")
|
||||
assertEquals(2, setOf(owner, otherConnection).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact pending input outranks live working and answer restores it`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
|
||||
|
||||
state = state.reduce(closeInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired pending input no longer overrides live state`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
|
||||
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkpoint is revalidating until successful snapshot settles it`() {
|
||||
var state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful snapshot absence settles only the same profile`() {
|
||||
val otherProfile = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same ids cannot alias across profiles or connections`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
val connectionB = owner("connection-b", "default", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
listOf(owner, profileB, connectionB).forEach {
|
||||
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
|
||||
}
|
||||
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
|
||||
assertEquals(
|
||||
profileB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
|
||||
)
|
||||
assertEquals(
|
||||
connectionB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped active row cannot mark duplicate stored ids as working`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(
|
||||
owner = null,
|
||||
runtimeId = "ambiguous-runtime",
|
||||
status = SessionLiveStatus.Working,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial snapshot applies resolved row without settling absent owner`() {
|
||||
val absentOwner = owner("connection-a", "default", "session-b")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
|
||||
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new generation rejects late terminal event and invalidates old alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
|
||||
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-old"))
|
||||
|
||||
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh is unavailable rather than idle`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Working,
|
||||
source = SessionActivityEvidenceSource.ActiveList,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active-list failure does not override exact live session event`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting background and revalidation distinct from working`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
|
||||
|
||||
val background = starting
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
|
||||
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal turn with running process projects background work separately`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal settles pending input and removes its runtime alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative live state is not overwritten by restored checkpoint`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 30,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays checking until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.NeedsInput,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthetic checkpoint input does not confirm restored working state`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = false,
|
||||
generation = 1,
|
||||
observedAtMillis = 11,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = true,
|
||||
generation = 1,
|
||||
observedAtMillis = 12,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative idle active-list row clears stale pending input`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
generation = 1,
|
||||
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime-only update requires alias in current scoped generation`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Waiting,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Idle,
|
||||
generation = 2,
|
||||
observedAtMillis = 40,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
private fun owner(connection: String, profile: String, session: String) =
|
||||
SessionActivityOwner.of(connection, profile, session)
|
||||
|
||||
private fun live(
|
||||
owner: SessionActivityOwner,
|
||||
runtime: String,
|
||||
status: SessionLiveStatus,
|
||||
generation: Long,
|
||||
) = SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
status = status,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun activeList(
|
||||
scope: SessionActivityScope,
|
||||
generation: Long,
|
||||
complete: Boolean = true,
|
||||
vararg runtimes: SessionLiveRuntime,
|
||||
) = SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = runtimes.toList(),
|
||||
isCompleteForScope = complete,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun openInput(
|
||||
owner: SessionActivityOwner,
|
||||
request: String,
|
||||
generation: Long,
|
||||
expiresAt: Long? = null,
|
||||
) = SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
expiresAtMillis = expiresAt,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = id,
|
||||
running = running,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
}
|
||||
+116
@@ -195,6 +195,11 @@ class GatewayClientHarness(
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var activeSessionListPayload: JsonObject = buildJsonObject {
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileCreatePayload: JsonObject = buildJsonObject {
|
||||
put("ok", true)
|
||||
@@ -316,6 +321,7 @@ class GatewayClientHarness(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
)
|
||||
"session.list" -> sessionListPayload
|
||||
"session.active_list" -> activeSessionListPayload
|
||||
"session.title" -> buildJsonObject { put("ok", true) }
|
||||
"prompt.submit" -> promptSubmitPayload
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
|
||||
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list parses every authoritative upstream state`() = runBlocking {
|
||||
harness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-$index")
|
||||
put("session_key", "stored-$index")
|
||||
put("status", status)
|
||||
put("last_active", 1_774_000_000.25 + index)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val result = client.listActiveSessions()
|
||||
val rows = (result as GatewayActiveSessionsResult.Success).sessions
|
||||
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayActiveSessionStatus.Idle,
|
||||
GatewayActiveSessionStatus.Starting,
|
||||
GatewayActiveSessionStatus.Working,
|
||||
GatewayActiveSessionStatus.Waiting,
|
||||
),
|
||||
rows.map(GatewayActiveSession::status),
|
||||
)
|
||||
assertEquals("runtime-2", rows[2].runtimeSessionId)
|
||||
assertEquals("stored-2", rows[2].storedSessionId)
|
||||
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
|
||||
assertTrue(rows.all { it.profile == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
|
||||
val result = client.listActiveSessions()
|
||||
|
||||
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
|
||||
val routeHarness = GatewayClientHarness()
|
||||
routeHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-operator")
|
||||
put("session_key", "stored-shared")
|
||||
put("status", "working")
|
||||
put("last_active", 1_774_000_000.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val routeClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "operator",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = routeScope,
|
||||
)
|
||||
try {
|
||||
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
|
||||
val params = routeHarness.awaitRpc("session.active_list")
|
||||
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
|
||||
|
||||
assertFalse(params.containsKey("profile"))
|
||||
assertNull(result.sessions.single().profile)
|
||||
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
|
||||
} finally {
|
||||
routeClient.shutdown()
|
||||
routeScope.cancel()
|
||||
routeHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
|
||||
harness.methodNotFound += "session.active_list"
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session transient error stays distinct from unsupported`() = runBlocking {
|
||||
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
|
||||
|
||||
val failed = client.listActiveSessions()
|
||||
|
||||
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
|
||||
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
|
||||
harness.rpcErrors.remove("session.active_list")
|
||||
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
|
||||
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(client.backgroundActiveTurn())
|
||||
assertEquals(
|
||||
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
|
||||
client.knownSessionOwner("live-1"),
|
||||
)
|
||||
client.clearSession()
|
||||
client.sessionProfileProvider = { "writer" }
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.DashboardConnectionStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PetBehaviorPreferences
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
|
||||
@@ -138,9 +139,14 @@ class StoreScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
|
||||
private fun capture(
|
||||
name: String,
|
||||
themeId: String = "hermes-relay",
|
||||
themePreference: String = "dark",
|
||||
body: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
|
||||
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
|
||||
// it recolors to the active theme. The preview/test fallback is Classic,
|
||||
// which mismatches the app and reads poorly on light themes.
|
||||
@@ -251,6 +257,14 @@ class StoreScreenshotTest {
|
||||
}
|
||||
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
|
||||
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
|
||||
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
|
||||
SessionActivityStatesScene()
|
||||
}
|
||||
@Test fun s12_session_activity_states_light() = capture(
|
||||
"12_session_activity_states_light",
|
||||
"nous-blue",
|
||||
themePreference = "light",
|
||||
) { SessionActivityStatesScene() }
|
||||
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
|
||||
// Real Appearance screen scrolled to the new Font picker — proves the
|
||||
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
|
||||
@@ -805,6 +819,41 @@ private fun SessionsScene() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActivityStatesScene() {
|
||||
val states = SessionActivityState.entries
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, state ->
|
||||
ChatSession(
|
||||
sessionId = "activity-$index",
|
||||
title = when (state) {
|
||||
SessionActivityState.Starting -> "Launching the agent"
|
||||
SessionActivityState.Working -> "Reviewing the release"
|
||||
SessionActivityState.NeedsInput -> "Approval required"
|
||||
SessionActivityState.BackgroundWork -> "Build still running"
|
||||
SessionActivityState.Checking -> "Reconnecting to Hermes"
|
||||
SessionActivityState.Unavailable -> "Offline session"
|
||||
},
|
||||
model = "gpt-5.6-sol",
|
||||
)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
scopeTitle = "Hermes",
|
||||
scopeSubtitle = "Live session status",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:activity-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsScene() = ConnectionsSettingsScreen(
|
||||
connections = marketingConnections,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPolicyTest {
|
||||
@@ -19,6 +21,127 @@ class SessionDrawerPolicyTest {
|
||||
|
||||
assertEquals("alpha:same", sessionRowKey(alpha))
|
||||
assertEquals("beta:same", sessionRowKey(beta))
|
||||
assertEquals("default:same", sessionRowKey(row("default", "same")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recent activity alone does not mark a session working`() {
|
||||
val recentlyActive = row("default", "recent", recentlyActive = true)
|
||||
|
||||
assertEquals(
|
||||
SessionDrawerStatus.Idle,
|
||||
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate session ids cannot leak activity across profiles`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
|
||||
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
|
||||
assertFalse(sessionRowKey(beta) in scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles ignores ambiguous bare session activity`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf("same" to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected profile may scope legacy bare session activity`() {
|
||||
val row = row("work", "session")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(row),
|
||||
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
|
||||
allowBareSessionIds = true,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
|
||||
scoped,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status filter and grouping use the same authoritative state`() {
|
||||
val restOnly = row("default", "rest-only", recentlyActive = true)
|
||||
val working = row("default", "working")
|
||||
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
|
||||
|
||||
val filtered = filterAndSortSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
|
||||
activityStates = states,
|
||||
)
|
||||
val grouped = groupSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
grouping = SessionDrawerGrouping.Status,
|
||||
activityStates = states,
|
||||
)
|
||||
|
||||
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
|
||||
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanded live phases retain distinct drawer statuses and labels`() {
|
||||
val phases = listOf(
|
||||
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
|
||||
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
|
||||
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
|
||||
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
|
||||
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
|
||||
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
|
||||
)
|
||||
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
|
||||
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
|
||||
|
||||
assertEquals(
|
||||
phases.map { it.second.first },
|
||||
rows.map { sessionDrawerStatus(it, states) },
|
||||
)
|
||||
assertEquals(
|
||||
phases.map { it.second.second },
|
||||
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
R.string.drawer_activity_needs_input,
|
||||
R.string.drawer_activity_starting,
|
||||
R.string.drawer_activity_working,
|
||||
R.string.drawer_activity_background_work,
|
||||
R.string.drawer_activity_checking,
|
||||
R.string.drawer_activity_unavailable,
|
||||
),
|
||||
phases.map { sessionActivityLabelResource(it.first) },
|
||||
)
|
||||
phases.forEachIndexed { index, phase ->
|
||||
assertEquals(
|
||||
listOf("session-$index"),
|
||||
filterAndSortSessionRows(
|
||||
rows = rows,
|
||||
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
|
||||
activityStates = states,
|
||||
).map { it.session.sessionId },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -113,6 +236,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens: Int = 0,
|
||||
cost: Double? = null,
|
||||
updatedAt: Long = 0L,
|
||||
recentlyActive: Boolean = false,
|
||||
) = ProfileSessionRow(
|
||||
profile = profile,
|
||||
session = ChatSession(
|
||||
@@ -126,6 +250,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens = outputTokens,
|
||||
actualCostUsd = cost,
|
||||
lastActivityAt = updatedAt,
|
||||
recentlyActive = recentlyActive,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
|
||||
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.ui.theme.ProfileAccentSwatches
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -359,6 +360,66 @@ class SessionDrawerTest {
|
||||
compose.onNodeWithText("Filters").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer renders every authoritative activity phase distinctly`() {
|
||||
val states = SessionActivityState.entries
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, _ ->
|
||||
ChatSession("session-$index", "Session $index", null)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:session-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"Starting",
|
||||
"Working",
|
||||
"Needs input",
|
||||
"Background work",
|
||||
"Checking",
|
||||
"Unavailable",
|
||||
).forEach { label ->
|
||||
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recency alone renders no working badge`() {
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = listOf(
|
||||
ChatSession(
|
||||
"recent",
|
||||
"Recently updated",
|
||||
null,
|
||||
recentlyActive = true,
|
||||
),
|
||||
),
|
||||
currentSessionId = null,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Working").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles customization can override a profile identity color`() {
|
||||
var changed: Pair<String, String?>? = null
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SessionDrawerTimestampTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun `distinct activity is labeled updated`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relative timestamp changes when the drawer clock advances`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated 2m ago",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session without later activity keeps started label`() {
|
||||
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Started Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
|
||||
sessionId = "session",
|
||||
title = "Session",
|
||||
model = null,
|
||||
startedAt = startedAt,
|
||||
lastActivityAt = lastActivityAt,
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityStateTest {
|
||||
@Test
|
||||
fun `multiple background turns remain visible beside current working turn`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf(
|
||||
"session-a" to SessionActivityState.Working,
|
||||
"session-b" to SessionActivityState.NeedsInput,
|
||||
),
|
||||
currentSessionId = "session-c",
|
||||
isStreaming = true,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Working, resolved["session-a"])
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
|
||||
assertEquals(SessionActivityState.Working, resolved["session-c"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needs input takes precedence over current working state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = emptyMap(),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = true,
|
||||
needsInput = true,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected idle session does not retain a stale background state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf("session-a" to SessionActivityState.Working),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = false,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertFalse(resolved.containsKey("session-a"))
|
||||
}
|
||||
}
|
||||
+95
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -291,6 +293,78 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Working
|
||||
}
|
||||
|
||||
gatewayHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray { })
|
||||
}
|
||||
viewModel.requestSessionActivityRefresh()
|
||||
gatewayHarness.awaitRpcCount("session.active_list", 2)
|
||||
awaitCondition {
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWaitingProjectsNeedsInput() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.NeedsInput
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.methodNotFound += "session.active_list"
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayClient.clearSession()
|
||||
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
|
||||
|
||||
viewModel.sendMessage("This admission should fail")
|
||||
gatewayHarness.awaitRpc("session.resume")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
assertTrue(
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
|
||||
SessionActivityState.Starting,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -2376,6 +2450,27 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
|
||||
}
|
||||
|
||||
private fun bindActivityTestDirectory() {
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeSessionPayload(status: String) = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "live-resumed")
|
||||
put("session_key", STORED_SESSION_ID)
|
||||
put("status", status)
|
||||
put("last_active", 1.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private fun persistedAnswerHistory(
|
||||
answer: String = BACKGROUND_ANSWER,
|
||||
id: String = "persisted-background-answer",
|
||||
|
||||
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
|
||||
controller.bind(source, "same-id", scopeKey = "profile-a")
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
|
||||
controller.selectSession("same-id", scopeKey = "profile-b")
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
|
||||
runCurrent()
|
||||
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityResolutionTest {
|
||||
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
|
||||
private val beta = SessionActivityOwner.of("connection", "beta", "same")
|
||||
|
||||
@Test
|
||||
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
assertTrue(result.ambiguousForCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact foreground runtime binding resolves duplicate stored id`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
currentRuntimeId = "runtime-alpha",
|
||||
)
|
||||
|
||||
assertEquals(alpha, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client known detached runtime resolves exact profile owner`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
knownOwnersByRuntime = mapOf("runtime" to unique),
|
||||
)
|
||||
|
||||
assertEquals(unique, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = "same",
|
||||
status = status,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
)
|
||||
}
|
||||
@@ -3793,3 +3793,70 @@ an upstream Hermes change, while vanilla/current Hermes retains a bounded
|
||||
single-account fallback. Merely configuring a provider credential does not
|
||||
expose tokens to paired devices. The Android UI can add providers without
|
||||
adding provider-specific screens or silently treating missing data as zero usage.
|
||||
|
||||
---
|
||||
|
||||
## ADR 68 — Android session activity has one profile-scoped authority
|
||||
|
||||
**Status:** Accepted (2026-08-25).
|
||||
|
||||
**Context.** Dashboard and API-server session lists expose `is_active`, but
|
||||
upstream defines it as an unended persisted row updated within the last five
|
||||
minutes. It is useful recency metadata, not proof that a model turn is running.
|
||||
Android nevertheless used it as a fallback for **Working**, while local
|
||||
composer state, detached-turn checkpoints, pending requests, and drawer rows
|
||||
each derived activity independently. A completed turn could therefore remain
|
||||
Working, a restart could restore an unverified busy state, and an All Profiles
|
||||
row could inherit another profile's live status through a bare session id.
|
||||
|
||||
Current upstream exposes live authority through the process-wide Gateway
|
||||
`session.active_list`. It reports attachable in-memory runtimes as `starting`,
|
||||
`working`, `waiting`, or `idle`, with both the live id and durable session key.
|
||||
It accepts only an optional `current_session_id`; rows normally have no profile
|
||||
metadata or filter. Exact pending-request events carry more specific
|
||||
Needs-input ownership. Exact turn terminals and `session.info {running:false}`
|
||||
can settle a matching generation. `process.list` is a different contract: a
|
||||
background process may remain after its parent model turn is idle.
|
||||
|
||||
**Decision.** Android owns one composite activity registry keyed by stable
|
||||
connection identity, normalized profile, and durable session id. Runtime ids
|
||||
are aliases only within that owner. The same reducer drives drawer badges and
|
||||
filters, visible composer state, animation, and accessibility.
|
||||
|
||||
The precedence is:
|
||||
|
||||
1. An exact pending approval, clarify, sudo, secret, or MCP request is **Needs input**.
|
||||
2. A successfully resolved process-wide `session.active_list` row supplies
|
||||
**Starting**, **Working**, or **Idle** to its exact client-owned profile
|
||||
record. Waiting without an exact pending payload remains a conservative
|
||||
needs-input state until the request detail arrives or clears.
|
||||
3. An exact terminal event, `session.info {running:false}`, or
|
||||
`session.activate {running:false}` settles only the matching runtime
|
||||
generation.
|
||||
4. A matching `process.list` row may add **Background work** independently; it
|
||||
never keeps the conversation Working.
|
||||
5. A checkpoint restored after process recreation is **Checking** until
|
||||
revalidated. A failed or unsupported live refresh is **Unavailable**.
|
||||
|
||||
Android resolves each active-list row through exact foreground or detached
|
||||
ownership already held by that client, or explicit profile metadata if a
|
||||
future upstream sends it. A bounded REST directory never proves that a durable
|
||||
`session_key` is globally unique. Ambiguous or unresolved rows apply no status.
|
||||
Resolved rows from a partial snapshot may update their exact owners, but they
|
||||
cannot infer absence. A missing row clears stale live state for a scope only
|
||||
when the successful process-wide snapshot was complete and every relevant row
|
||||
was unambiguously resolved. A failed refresh does not settle anything. REST
|
||||
`is_active`, `last_active`, and relative timestamps never influence execution
|
||||
state. Old socket generations, late refreshes, and unscoped session ids cannot
|
||||
revive a newer settled entry.
|
||||
|
||||
**Consequences.** Working and Needs input describe current upstream-owned
|
||||
runtime state instead of recent persistence. All Profiles remains isolated,
|
||||
restart recovery is honest about uncertainty, and background processes stay
|
||||
visible without mislabeling their parent turn. Older Gateways remain usable
|
||||
but show Unavailable when no exact local terminal truth exists. Declarative
|
||||
Gateway scenarios cover all four upstream states, complete-snapshot
|
||||
disappearance, client-side profile isolation, and method-not-found; physical
|
||||
and current-host certification remains tracked in `TODO.md`. An upstream
|
||||
profile field/filter or explicitly owned aggregate activity route would remove
|
||||
the remaining ambiguity for multi-profile clients.
|
||||
|
||||
@@ -38,6 +38,9 @@ the upstream contract identifiers it depends on.
|
||||
| `scope_rejection_inputs` | Exact, foreign, and unscoped event inputs |
|
||||
| `terminal_gap_activate` | Socket closes after live output; replacement `session.activate` reports `running=false`; history is authoritative |
|
||||
| `terminal_gap_session_info` | Scoped `session.info {running:false}` settles a turn without `message.complete` |
|
||||
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
|
||||
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
|
||||
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
|
||||
|
||||
Fixture evidence is a bounded metadata-only ring. It records sequence,
|
||||
connection number, RPC method, event type, scope classification, and outcome.
|
||||
@@ -125,6 +128,16 @@ The check is source-only and non-mutating. It starts no runtime, creates no
|
||||
sessions, and uses no provider or authentication credentials. It fails closed
|
||||
for dirty, fork-marked, or non-vanilla checkouts.
|
||||
|
||||
For activity scenarios, the adapter confirms that current upstream owns
|
||||
`session.active_list`, emits `starting`, `working`, `waiting`, and `idle`, lets
|
||||
pending input outrank running work, accepts only `current_session_id` as its
|
||||
optional selector, and returns both the live runtime id and durable session key
|
||||
from the process-local registry. The runtime fixture then
|
||||
tests client reconciliation, including successful disappearance and explicit
|
||||
method-not-found behavior. Because rows normally carry no profile, partial
|
||||
ownership resolution may update exact matches but cannot infer absence for an
|
||||
unresolved scope. Source inspection alone does not claim a client pass.
|
||||
|
||||
## Planned extensions
|
||||
|
||||
The scenario format is intentionally usable by future official Desktop and TUI
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -476,6 +476,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
|
||||
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
|
||||
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
|
||||
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row only through exact foreground/detached ownership already held by that client, or explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, bare session ids from another profile, and delayed snapshots cannot revive newer settled state.
|
||||
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
|
||||
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
|
||||
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay Surface Matrix
|
||||
|
||||
Updated: 2026-08-20
|
||||
Updated: 2026-08-25
|
||||
|
||||
This matrix records the v1.0.0 route ownership contract. It is meant to keep
|
||||
future app, plugin, and agent work honest about what is vanilla upstream
|
||||
@@ -23,6 +23,9 @@ Verified upstream source snapshot:
|
||||
`hermes_cli/plugins_cmd.py`
|
||||
- Additive Manage contracts were rechecked at upstream MCP hosted-OAuth commits
|
||||
through `4dc2b7be0` and custom-endpoint commit `3d9789357`.
|
||||
- Session activity contracts were rechecked against upstream `main` at
|
||||
`d736f5d53f1d33fabad5a17cb070eb138b618fb8` in `tui_gateway/server.py`,
|
||||
`tui_gateway/methods_session.py`, and `hermes_cli/web_routers/sessions.py`.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -35,6 +38,7 @@ Verified upstream source snapshot:
|
||||
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
|
||||
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
|
||||
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. `message.complete` is the ordinary terminal event; `session.info {running:false}` is the authoritative settle backstop when a replacement socket missed that terminal frame. A reconnect reactivates the exact live runtime with `session.activate`; durable `session.resume` remains the cold-open path and an explicit rejection never creates a replacement context. |
|
||||
| Gateway `session.active_list` | Upstream tui_gateway | No | Authoritative process-wide live activity | Returns attachable runtimes across the Gateway process, with live `id`, durable `session_key`, and `starting`, `working`, `waiting`, or `idle`. The only optional selector is `current_session_id`; rows normally carry no profile metadata. Android attributes a row only from exact foreground/detached ownership already held by that client, or from explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows remain unattributed, and absence settles a scope only after a complete, unambiguously resolved successful snapshot. Method-not-found or refresh failure is Unavailable, not Idle. Pending input outranks running work. |
|
||||
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
|
||||
| Gateway `pet.info`, `pet.gallery`, `pet.select`, `pet.disable` | Upstream tui_gateway | No | Profile-scoped animated companion | `pet.info` supplies bounded PNG/WebP sheet bytes, revision, geometry, real frame counts, loop timing, scale, and row taxonomy. Android passes `knownRevision` to avoid duplicate sheet transfer, renders the active pet through its native activity-aware companion, and keeps phone-local pet packs separate. All four RPCs carry the effective profile. |
|
||||
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
|
||||
@@ -191,6 +195,17 @@ URL for compatibility.
|
||||
|
||||
## API Fallback Compatibility Details
|
||||
|
||||
- Dashboard/API session-list `is_active` is a persistence-recency hint: an
|
||||
unended row whose `last_active` is less than five minutes old. It is not a
|
||||
running-turn signal and must never produce Working, Waiting, or Starting.
|
||||
- Gateway `process.list` describes separately running background processes. A
|
||||
process may outlive its parent model turn, so clients present that as
|
||||
Background work without keeping the conversation in Working.
|
||||
- Upstream can make multi-profile clients safer and simpler by adding profile
|
||||
metadata or a profile filter to `session.active_list`, or by publishing an
|
||||
aggregate activity route with explicit profile ownership. Until then,
|
||||
clients must fail closed on duplicate or unresolved durable keys.
|
||||
|
||||
- Android accepts the API server's final-response image data URLs for PNG,
|
||||
JPEG, GIF, WebP, and BMP. Decoding is strict: MIME and file signatures must
|
||||
agree, encoded and decoded bytes are capped at the upstream 5 MiB limit, and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.0"
|
||||
appVersionCode = "49"
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
@@ -29,12 +29,14 @@ GATEWAY_TERMINAL = "gateway.message_complete"
|
||||
GATEWAY_SETTLED_INFO = "gateway.settled_session_info"
|
||||
SESSION_ACTIVATE = "gateway.session_activate_live"
|
||||
SESSION_RESUME = "gateway.session_resume_durable"
|
||||
SESSION_ACTIVE_LIST = "gateway.session_active_list"
|
||||
API_BOUNDARY = "api.fallback_boundary"
|
||||
ALL_CONTRACTS = (
|
||||
GATEWAY_TERMINAL,
|
||||
GATEWAY_SETTLED_INFO,
|
||||
SESSION_ACTIVATE,
|
||||
SESSION_RESUME,
|
||||
SESSION_ACTIVE_LIST,
|
||||
API_BOUNDARY,
|
||||
)
|
||||
|
||||
@@ -286,6 +288,54 @@ def _check_resume(methods: SourceFile) -> CheckResult:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
|
||||
contract = SESSION_ACTIVE_LIST
|
||||
try:
|
||||
status = server.function("_session_live_status")
|
||||
item = server.function("_session_live_item")
|
||||
handler = methods.method_handler("session.active_list")
|
||||
status_text = server.segment(status)
|
||||
item_strings = _string_constants(item)
|
||||
handler_text = methods.segment(handler)
|
||||
missing_statuses = sorted(
|
||||
{"starting", "working", "waiting", "idle"} - _string_constants(status)
|
||||
)
|
||||
if missing_statuses:
|
||||
raise ValueError("live status missing state(s): " + ", ".join(missing_statuses))
|
||||
pending_at = status_text.find("_session_pending_kind(")
|
||||
running_at = status_text.find('.get("running")')
|
||||
if pending_at < 0 or running_at < 0 or pending_at > running_at:
|
||||
raise ValueError("waiting state no longer takes precedence over running")
|
||||
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
|
||||
if missing_fields:
|
||||
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
|
||||
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
|
||||
missing_markers = [marker for marker in required_markers if marker not in handler_text]
|
||||
if missing_markers or "sessions" not in _string_constants(handler):
|
||||
raise ValueError(
|
||||
"session.active_list no longer snapshots the live registry: "
|
||||
+ ", ".join(missing_markers or ["sessions result"])
|
||||
)
|
||||
handler_strings = _string_constants(handler)
|
||||
if "current_session_id" not in handler_strings:
|
||||
raise ValueError("session.active_list no longer accepts current_session_id")
|
||||
if "profile" in handler_strings:
|
||||
raise ValueError("session.active_list unexpectedly claims a profile filter")
|
||||
return CheckResult(
|
||||
contract,
|
||||
True,
|
||||
(
|
||||
server.evidence(status, "starting, working, waiting, and idle derivation"),
|
||||
server.evidence(item, "live row carries runtime and durable identities"),
|
||||
methods.evidence(
|
||||
handler, "active list snapshots the process-wide in-memory registry"
|
||||
),
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def _check_api_boundary(api: SourceFile) -> CheckResult:
|
||||
contract = API_BOUNDARY
|
||||
try:
|
||||
@@ -365,6 +415,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
|
||||
GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server),
|
||||
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
|
||||
SESSION_RESUME: lambda: _check_resume(methods),
|
||||
SESSION_ACTIVE_LIST: lambda: _check_active_list(server, methods),
|
||||
API_BOUNDARY: lambda: _check_api_boundary(api),
|
||||
}
|
||||
return [checks[requirement]() for requirement in requirements]
|
||||
|
||||
@@ -36,6 +36,26 @@ def _run_prompt_submit(sid, session, agent):
|
||||
finally:
|
||||
session["running"] = False
|
||||
_emit_settled_session_info(sid, session, agent)
|
||||
|
||||
def _session_pending_kind(sid):
|
||||
return "approval" if sid in _pending else ""
|
||||
|
||||
def _session_live_status(sid, session):
|
||||
if _session_pending_kind(sid):
|
||||
return "waiting"
|
||||
ready = session.get("agent_ready")
|
||||
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
|
||||
return "starting"
|
||||
if session.get("running"):
|
||||
return "working"
|
||||
return "idle"
|
||||
|
||||
def _session_live_item(sid, session, current_sid=""):
|
||||
return {
|
||||
"id": sid,
|
||||
"session_key": session.get("session_key", sid),
|
||||
"status": _session_live_status(sid, session),
|
||||
}
|
||||
'''
|
||||
|
||||
METHODS_SOURCE = '''
|
||||
@@ -67,6 +87,14 @@ def _(rid, params):
|
||||
def _(rid, params):
|
||||
session, error = _sess_nowait(params, rid)
|
||||
return _live_session_payload(params["session_id"], session)
|
||||
|
||||
@method("session.active_list")
|
||||
def _(rid, params):
|
||||
current = str(params.get("current_session_id") or "")
|
||||
with _sessions_lock:
|
||||
snapshot = list(_sessions.items())
|
||||
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
|
||||
return _ok(rid, {"sessions": rows})
|
||||
'''
|
||||
|
||||
API_SOURCE = '''
|
||||
@@ -159,6 +187,25 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual((module.GATEWAY_SETTLED_INFO, module.SESSION_ACTIVATE), requirements)
|
||||
|
||||
def test_active_list_requires_waiting_to_outrank_working(self):
|
||||
path = self.root / module.SERVER
|
||||
reordered = SERVER_SOURCE.replace(
|
||||
' if _session_pending_kind(sid):\n'
|
||||
' return "waiting"\n'
|
||||
' ready = session.get("agent_ready")',
|
||||
' if session.get("running"):\n'
|
||||
' return "working"\n'
|
||||
' if _session_pending_kind(sid):\n'
|
||||
' return "waiting"\n'
|
||||
' ready = session.get("agent_ready")',
|
||||
)
|
||||
path.write_text(reordered, encoding="utf-8")
|
||||
|
||||
result = module.audit_sources(self.root, (module.SESSION_ACTIVE_LIST,))[0]
|
||||
|
||||
self.assertFalse(result.passed)
|
||||
self.assertIn("precedence", result.problem)
|
||||
|
||||
def test_manifest_rejects_unknown_contract(self):
|
||||
manifest = self.root / "scenario.json"
|
||||
manifest.write_text(json.dumps({"requires": ["relay.private_route"]}), encoding="utf-8")
|
||||
|
||||
@@ -47,7 +47,8 @@ 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`,
|
||||
`prompt.submit`, and `session.interrupt`.
|
||||
`session.active_list`, `prompt.submit`, 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.
|
||||
- Unknown RPC methods return JSON-RPC `-32601`; wrong live/durable identities
|
||||
@@ -70,6 +71,13 @@ ordered `steps` list using these operations:
|
||||
| `set_running` | Change the authoritative session running state. |
|
||||
| `close` | Create a fixture-controlled socket gap without replaying later frames. |
|
||||
|
||||
An optional `active_list` object scripts process-wide live-runtime snapshots.
|
||||
`supported: false` returns JSON-RPC `-32601`, matching an older Gateway.
|
||||
`supported: true` returns each declared `snapshots` entry in order and retains
|
||||
the final successful snapshot for later polls. Rows use upstream's
|
||||
`starting`/`working`/`waiting`/`idle` vocabulary. A successful empty snapshot
|
||||
is therefore distinct from a failed or unsupported refresh.
|
||||
|
||||
Every bundled manifest also declares a top-level `contract_requirements` string
|
||||
array. Its values use the contract names accepted by the on-demand upstream
|
||||
conformance adapter (for example, `gateway.settled_session_info` and
|
||||
@@ -80,6 +88,18 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
|
||||
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
|
||||
and both issue #365 terminal-gap forms:
|
||||
|
||||
- `active_status_lifecycle`: one successful live snapshot contains starting,
|
||||
working, waiting, and idle rows; the next successful snapshot is empty so a
|
||||
client can prove a complete, unambiguously resolved snapshot clears prior
|
||||
live state.
|
||||
- `active_status_profile_scope`: a process-wide row has no profile metadata and
|
||||
ignores a caller-supplied profile hint. Client adapters must resolve it from
|
||||
exact foreground/detached ownership already held by that client (or future
|
||||
explicit upstream profile metadata); a bounded directory must not invent an
|
||||
owner from apparent uniqueness.
|
||||
- `active_status_unsupported`: `session.active_list` returns method-not-found so
|
||||
older-host fallback remains explicit rather than being mistaken for Idle.
|
||||
|
||||
- `terminal_gap_activate`: live deltas arrive, history persists, the socket
|
||||
closes before `message.complete`, and replacement `session.activate` reports
|
||||
the exact live session with `running=false`. A bounded two-second fixture delivery
|
||||
|
||||
@@ -200,6 +200,47 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(any(event.get("session_id") == "fixture-foreign-session" for event in events))
|
||||
self.assertEqual(fixture.scenario.live_session_id, events[-1]["session_id"])
|
||||
|
||||
async def test_active_list_exposes_all_live_states_then_authoritative_absence(self) -> None:
|
||||
_, base_url = await self.start("active_status_lifecycle")
|
||||
ws, _ = await self.connect(base_url)
|
||||
|
||||
await self.rpc(ws, 1, "session.active_list", {"current_session_id": "fixture-live-working"})
|
||||
first = (await ws.receive_json())["result"]["sessions"]
|
||||
self.assertEqual(
|
||||
["starting", "working", "waiting", "idle"],
|
||||
[row["status"] for row in first],
|
||||
)
|
||||
|
||||
await self.rpc(ws, 2, "session.active_list", {"current_session_id": "fixture-live-working"})
|
||||
second = (await ws.receive_json())["result"]["sessions"]
|
||||
self.assertEqual([], second)
|
||||
|
||||
# An exhausted script remains on its last successful snapshot so polling
|
||||
# cannot accidentally resurrect an earlier live row.
|
||||
await self.rpc(ws, 3, "session.active_list")
|
||||
third = (await ws.receive_json())["result"]["sessions"]
|
||||
self.assertEqual([], third)
|
||||
|
||||
async def test_active_list_rows_require_client_owned_profile_resolution(self) -> None:
|
||||
fixture, base_url = await self.start("active_status_profile_scope")
|
||||
ws, _ = await self.connect(base_url)
|
||||
|
||||
# Upstream accepts current_session_id, not a profile filter. The fixture
|
||||
# deliberately ignores this extra hint and returns an unscoped row.
|
||||
await self.rpc(ws, 1, "session.active_list", {"profile": "default"})
|
||||
rows = (await ws.receive_json())["result"]["sessions"]
|
||||
|
||||
self.assertEqual("research", fixture.scenario.profile)
|
||||
self.assertEqual("fixture-shared-stored-session", rows[0]["session_key"])
|
||||
self.assertNotIn("profile", rows[0])
|
||||
|
||||
async def test_active_list_unsupported_is_explicit_method_not_found(self) -> None:
|
||||
_, base_url = await self.start("active_status_unsupported")
|
||||
ws, _ = await self.connect(base_url)
|
||||
await self.rpc(ws, 1, "session.active_list")
|
||||
frame = await ws.receive_json()
|
||||
self.assertEqual(-32601, frame["error"]["code"])
|
||||
|
||||
async def test_evidence_is_bounded_and_contains_no_rpc_payloads(self) -> None:
|
||||
_, base_url = await self.start("ordinary_turn")
|
||||
ws, _ = await self.connect(base_url)
|
||||
@@ -221,6 +262,9 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
|
||||
class ScenarioTestCase(unittest.TestCase):
|
||||
def test_all_bundled_scenarios_validate(self) -> None:
|
||||
for name in (
|
||||
"active_status_lifecycle",
|
||||
"active_status_profile_scope",
|
||||
"active_status_unsupported",
|
||||
"ordinary_turn",
|
||||
"rapid_tools_interims",
|
||||
"terminal_gap_activate",
|
||||
@@ -232,6 +276,21 @@ class ScenarioTestCase(unittest.TestCase):
|
||||
self.assertEqual(name, scenario.name)
|
||||
self.assertTrue(scenario.contract_requirements)
|
||||
|
||||
def test_active_list_scenario_rejects_unknown_status(self) -> None:
|
||||
scenario = {
|
||||
"name": "invalid_activity",
|
||||
"live_session_id": "live",
|
||||
"stored_session_id": "stored",
|
||||
"turns": [],
|
||||
"active_list": {
|
||||
"supported": True,
|
||||
"snapshots": [[{"id": "live", "session_key": "stored", "status": "recent"}]],
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ScenarioError, "invalid status"):
|
||||
from vanilla_gateway.scenario import Scenario
|
||||
Scenario.from_dict(scenario)
|
||||
|
||||
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
|
||||
self.assertEqual(
|
||||
(
|
||||
|
||||
@@ -15,6 +15,7 @@ class ScenarioError(ValueError):
|
||||
|
||||
|
||||
_STEP_OPS = {"event", "persist", "sleep", "close", "set_running"}
|
||||
_LIVE_STATUSES = {"starting", "working", "waiting", "idle"}
|
||||
_SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]{1,120}")
|
||||
|
||||
|
||||
@@ -27,6 +28,8 @@ class Scenario:
|
||||
contract_requirements: tuple[str, ...]
|
||||
initial_history: tuple[dict[str, Any], ...]
|
||||
turns: tuple[dict[str, Any], ...]
|
||||
active_list_supported: bool
|
||||
active_list_snapshots: tuple[tuple[dict[str, Any], ...], ...]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "Scenario":
|
||||
@@ -34,8 +37,8 @@ class Scenario:
|
||||
missing = [key for key in required if key not in raw]
|
||||
if missing:
|
||||
raise ScenarioError(f"missing scenario fields: {', '.join(missing)}")
|
||||
if not isinstance(raw["turns"], list) or not raw["turns"]:
|
||||
raise ScenarioError("turns must be a non-empty list")
|
||||
if not isinstance(raw["turns"], list):
|
||||
raise ScenarioError("turns must be a list")
|
||||
if not isinstance(raw["name"], str) or not _SAFE_NAME.fullmatch(raw["name"]):
|
||||
raise ScenarioError("name must be a short metadata-safe scenario identifier")
|
||||
for turn_index, turn in enumerate(raw["turns"]):
|
||||
@@ -76,6 +79,41 @@ class Scenario:
|
||||
raise ScenarioError("contract_requirements must be a list of non-empty strings")
|
||||
if len(set(requirements)) != len(requirements):
|
||||
raise ScenarioError("contract_requirements must not contain duplicates")
|
||||
active_list = raw.get("active_list", {})
|
||||
if not isinstance(active_list, dict):
|
||||
raise ScenarioError("active_list must be an object")
|
||||
active_list_supported = active_list.get("supported", False)
|
||||
if not isinstance(active_list_supported, bool):
|
||||
raise ScenarioError("active_list supported must be a boolean")
|
||||
snapshots = active_list.get("snapshots", [])
|
||||
if not isinstance(snapshots, list):
|
||||
raise ScenarioError("active_list snapshots must be a list")
|
||||
if not active_list_supported and snapshots:
|
||||
raise ScenarioError("unsupported active_list cannot declare snapshots")
|
||||
validated_snapshots: list[tuple[dict[str, Any], ...]] = []
|
||||
for snapshot_index, snapshot in enumerate(snapshots):
|
||||
if not isinstance(snapshot, list):
|
||||
raise ScenarioError(f"active_list snapshot {snapshot_index} must be a list")
|
||||
validated_rows: list[dict[str, Any]] = []
|
||||
for row_index, row in enumerate(snapshot):
|
||||
if not isinstance(row, dict):
|
||||
raise ScenarioError(
|
||||
f"active_list snapshot {snapshot_index} row {row_index} must be an object"
|
||||
)
|
||||
if not isinstance(row.get("id"), str) or not row["id"]:
|
||||
raise ScenarioError(
|
||||
f"active_list snapshot {snapshot_index} row {row_index} requires an id"
|
||||
)
|
||||
if not isinstance(row.get("session_key"), str) or not row["session_key"]:
|
||||
raise ScenarioError(
|
||||
f"active_list snapshot {snapshot_index} row {row_index} requires a session_key"
|
||||
)
|
||||
if row.get("status") not in _LIVE_STATUSES:
|
||||
raise ScenarioError(
|
||||
f"active_list snapshot {snapshot_index} row {row_index} has invalid status"
|
||||
)
|
||||
validated_rows.append(dict(row))
|
||||
validated_snapshots.append(tuple(validated_rows))
|
||||
return cls(
|
||||
name=str(raw["name"]),
|
||||
live_session_id=str(raw["live_session_id"]),
|
||||
@@ -84,6 +122,8 @@ class Scenario:
|
||||
contract_requirements=tuple(requirements),
|
||||
initial_history=tuple(dict(row) for row in history),
|
||||
turns=tuple(dict(turn) for turn in raw["turns"]),
|
||||
active_list_supported=active_list_supported,
|
||||
active_list_snapshots=tuple(validated_snapshots),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "active_status_lifecycle",
|
||||
"live_session_id": "fixture-live-working",
|
||||
"stored_session_id": "fixture-shared-stored-session",
|
||||
"profile": "default",
|
||||
"contract_requirements": [
|
||||
"gateway.session_active_list"
|
||||
],
|
||||
"initial_history": [],
|
||||
"turns": [],
|
||||
"active_list": {
|
||||
"supported": true,
|
||||
"snapshots": [
|
||||
[
|
||||
{"id": "fixture-live-starting", "session_key": "fixture-starting", "status": "starting", "current": false},
|
||||
{"id": "fixture-live-working", "session_key": "fixture-shared-stored-session", "status": "working", "current": true},
|
||||
{"id": "fixture-live-waiting", "session_key": "fixture-waiting", "status": "waiting", "current": false},
|
||||
{"id": "fixture-live-idle", "session_key": "fixture-idle", "status": "idle", "current": false}
|
||||
],
|
||||
[]
|
||||
]
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "active_status_profile_scope",
|
||||
"live_session_id": "fixture-live-research",
|
||||
"stored_session_id": "fixture-shared-stored-session",
|
||||
"profile": "research",
|
||||
"contract_requirements": [
|
||||
"gateway.session_active_list"
|
||||
],
|
||||
"initial_history": [],
|
||||
"turns": [],
|
||||
"active_list": {
|
||||
"supported": true,
|
||||
"snapshots": [
|
||||
[
|
||||
{"id": "fixture-live-research", "session_key": "fixture-shared-stored-session", "status": "waiting", "current": true}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "active_status_unsupported",
|
||||
"live_session_id": "fixture-live-unsupported",
|
||||
"stored_session_id": "fixture-stored-unsupported",
|
||||
"profile": "default",
|
||||
"contract_requirements": [
|
||||
"gateway.session_active_list"
|
||||
],
|
||||
"initial_history": [],
|
||||
"turns": [],
|
||||
"active_list": {
|
||||
"supported": false,
|
||||
"snapshots": []
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ class GatewayFixture:
|
||||
self._tickets: set[str] = set()
|
||||
self._history_rows = [dict(row) for row in scenario.initial_history]
|
||||
self._turns = deque(dict(turn) for turn in scenario.turns)
|
||||
self._active_list_snapshots = deque(
|
||||
[dict(row) for row in snapshot]
|
||||
for snapshot in scenario.active_list_snapshots
|
||||
)
|
||||
self._last_active_list_snapshot: list[dict[str, Any]] = []
|
||||
self._queued: deque[_QueuedTurn] = deque()
|
||||
self._running = False
|
||||
self._turn_active = False
|
||||
@@ -137,6 +142,13 @@ class GatewayFixture:
|
||||
elif method == "session.interrupt":
|
||||
self._running = False
|
||||
result = {"ok": True}
|
||||
elif method == "session.active_list" and self.scenario.active_list_supported:
|
||||
if self._active_list_snapshots:
|
||||
self._last_active_list_snapshot = self._active_list_snapshots.popleft()
|
||||
result = {"sessions": [dict(row) for row in self._last_active_list_snapshot]}
|
||||
self.evidence.add(
|
||||
"activity", connection=connection, method=method, outcome="snapshot",
|
||||
)
|
||||
else:
|
||||
await self._rpc_error(socket, request_id, -32601, f"Method not found: {method}")
|
||||
return
|
||||
@@ -290,6 +302,8 @@ class GatewayFixture:
|
||||
"remaining_turns": len(self._turns),
|
||||
"queued_turns": len(self._queued),
|
||||
"history_rows": len(self._history_rows),
|
||||
"profile": self.scenario.profile,
|
||||
"remaining_active_list_snapshots": len(self._active_list_snapshots),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user