Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a894da5fda | ||
|
|
80e636bc30 |
+1
-1
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable.
|
||||
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden.
|
||||
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
|
||||
|
||||
@@ -18,7 +19,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
### Fixed
|
||||
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ package com.hermesandroid.relay.data
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
private const val PROFILE_CONTEXT_SEPARATOR = "::"
|
||||
|
||||
data class ProfileContextIdentity(
|
||||
val connectionId: String,
|
||||
val profileKey: String,
|
||||
) {
|
||||
/** Null means the upstream request must inherit Server Default. */
|
||||
val requestProfileName: String?
|
||||
get() = profileRequestName(profileKey)
|
||||
}
|
||||
private val GENERIC_MODEL_ALIASES = setOf(
|
||||
"hermes-agent",
|
||||
"hermes_agent",
|
||||
@@ -163,7 +173,25 @@ object AgentDisplay {
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
"${connectionId.orEmpty()}$PROFILE_CONTEXT_SEPARATOR${profileSessionKey(profileName)}"
|
||||
|
||||
/**
|
||||
* Parse the canonical profile/context identity used by persisted chat state.
|
||||
*
|
||||
* Legacy or malformed opaque keys deliberately return null: recovery may
|
||||
* still use the exact key for ownership, but must not invent an upstream
|
||||
* profile override from it. The first separator is authoritative so legal
|
||||
* profile names containing `::` remain round-trippable.
|
||||
*/
|
||||
fun parseProfileContextKey(contextKey: String?): ProfileContextIdentity? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.indexOf(PROFILE_CONTEXT_SEPARATOR)
|
||||
if (separator <= 0 || separator + PROFILE_CONTEXT_SEPARATOR.length >= raw.length) return null
|
||||
val connectionId = raw.substring(0, separator).trim()
|
||||
val profileKey = raw.substring(separator + PROFILE_CONTEXT_SEPARATOR.length).trim()
|
||||
if (connectionId.isEmpty() || profileKey.isEmpty()) return null
|
||||
return ProfileContextIdentity(connectionId, profileKey)
|
||||
}
|
||||
|
||||
fun localDisplayAlias(value: String?): String? =
|
||||
value
|
||||
|
||||
@@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
|
||||
data class ChatTurnCheckpoint(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA,
|
||||
val contextKey: String,
|
||||
/** Explicit persisted profile identity; null only for legacy checkpoints. */
|
||||
val profileKey: String? = null,
|
||||
val sessionId: String,
|
||||
val liveSessionId: String? = null,
|
||||
val transport: String,
|
||||
|
||||
@@ -1006,6 +1006,56 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the ephemeral, read-only child-watch projection. This is stricter
|
||||
* than the main transcript: system rows and tool results are not part of
|
||||
* the preview contract, and one live child must not retain unbounded text.
|
||||
*/
|
||||
internal fun boundReadOnlyPreview(
|
||||
maxMessages: Int = 100,
|
||||
maxTotalChars: Int = 32_000,
|
||||
maxFieldChars: Int = 8_000,
|
||||
maxToolChars: Int = 1_000,
|
||||
): Boolean {
|
||||
var truncated = false
|
||||
_messages.update { current ->
|
||||
val visible = current.filterNot { it.role == MessageRole.SYSTEM }
|
||||
if (visible.size != current.size || visible.size > maxMessages) truncated = true
|
||||
var remaining = maxTotalChars
|
||||
val kept = mutableListOf<ChatMessage>()
|
||||
visible.takeLast(maxMessages).asReversed().forEach { message ->
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
return@forEach
|
||||
}
|
||||
fun bounded(value: String, limit: Int): String {
|
||||
val allowed = minOf(limit, remaining)
|
||||
val next = value.takeLast(allowed)
|
||||
if (next.length != value.length) truncated = true
|
||||
remaining -= next.length
|
||||
return next
|
||||
}
|
||||
val content = bounded(message.content, maxFieldChars)
|
||||
val thinking = bounded(message.thinkingContent, maxFieldChars)
|
||||
val tools = message.toolCalls.takeLast(50).map { tool ->
|
||||
if (message.toolCalls.size > 50) truncated = true
|
||||
tool.copy(
|
||||
args = tool.args?.let { bounded(it, maxToolChars) },
|
||||
result = null,
|
||||
error = tool.error?.let { bounded(it, maxToolChars) },
|
||||
)
|
||||
}
|
||||
kept += message.copy(
|
||||
content = content,
|
||||
thinkingContent = thinking,
|
||||
toolCalls = tools,
|
||||
)
|
||||
}
|
||||
kept.asReversed()
|
||||
}
|
||||
return truncated
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate the last client-owned state of an unfinished turn.
|
||||
*
|
||||
@@ -3030,7 +3080,9 @@ class ChatHandler {
|
||||
fun onSubagentEvent(messageId: String, event: GatewaySubagentEvent) {
|
||||
val label = event.goal.trim().take(60).ifBlank { null }
|
||||
when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.START -> {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
-> {
|
||||
if (label != null) subagentLabels[event.taskIndex] = label
|
||||
event.subagentId?.takeIf(String::isNotBlank)?.let {
|
||||
subagentIds[event.taskIndex] = it
|
||||
|
||||
+343
-26
@@ -209,6 +209,9 @@ class GatewayChatClient(
|
||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
|
||||
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
|
||||
internal const val MAX_CHILD_WATCH_HISTORY_ITEMS = 200
|
||||
internal const val MAX_CHILD_WATCH_HISTORY_CHARS = 64_000
|
||||
private const val MAX_PENDING_CHILD_WATCH_EVENTS = 256
|
||||
|
||||
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||
private const val MAX_TURN_REJOINS = 4
|
||||
@@ -381,6 +384,15 @@ class GatewayChatClient(
|
||||
private val prewarmRequestGeneration = AtomicLong(0)
|
||||
private val pendingRpcs = ConcurrentHashMap<Long, CompletableDeferred<JsonObject>>()
|
||||
|
||||
/** Monotonic client-local fence for lazy child watch open/close races. */
|
||||
private val childWatchGeneration = AtomicLong(0)
|
||||
|
||||
/** Live child runtime id -> exact watcher that owns its callbacks. */
|
||||
private val childWatches = ConcurrentHashMap<String, ChildWatchRegistration>()
|
||||
|
||||
/** Events that race a lazy `session.resume` acknowledgement. */
|
||||
private val pendingChildWatchOpens = ConcurrentHashMap<Long, PendingChildWatchOpen>()
|
||||
|
||||
/** Live (per-connection) session id ←→ the stored DB id it was resumed/created from. */
|
||||
@Volatile
|
||||
private var liveSessionId: String? = null
|
||||
@@ -447,6 +459,62 @@ class GatewayChatClient(
|
||||
@Volatile var pendingAsk: GatewayAsk? = null,
|
||||
)
|
||||
|
||||
private class ChildWatchRegistration(
|
||||
val storedSessionId: String,
|
||||
val liveSessionId: String,
|
||||
val profile: String?,
|
||||
val generation: Long,
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
) {
|
||||
lateinit var mapper: GatewayEventMapper
|
||||
}
|
||||
|
||||
private data class ChildWatchEvent(
|
||||
val sessionId: String,
|
||||
val type: String,
|
||||
val payload: JsonObject?,
|
||||
)
|
||||
|
||||
private data class PendingChildWatchReplay(
|
||||
val events: List<ChildWatchEvent>,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
private class PendingChildWatchOpen {
|
||||
private val lock = Any()
|
||||
private val events = mutableListOf<ChildWatchEvent>()
|
||||
private var closed = false
|
||||
private var truncated = false
|
||||
|
||||
fun capture(event: ChildWatchEvent): Boolean = synchronized(lock) {
|
||||
if (closed) return@synchronized false
|
||||
if (events.size >= MAX_PENDING_CHILD_WATCH_EVENTS) {
|
||||
events.removeAt(0)
|
||||
truncated = true
|
||||
}
|
||||
events += event
|
||||
true
|
||||
}
|
||||
|
||||
fun closeAndTake(sessionId: String): PendingChildWatchReplay = synchronized(lock) {
|
||||
closed = true
|
||||
PendingChildWatchReplay(
|
||||
events = events.filter { it.sessionId == sessionId },
|
||||
truncated = truncated,
|
||||
).also { events.clear() }
|
||||
}
|
||||
|
||||
fun close() = synchronized(lock) {
|
||||
closed = true
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private data class BoundedChildHistory(
|
||||
val messages: List<MessageItem>,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Upstream may emit the interrupted turn's tail and terminal event after
|
||||
* `session.interrupt` returns. Keep a short exact-session tombstone so that
|
||||
@@ -947,6 +1015,200 @@ class GatewayChatClient(
|
||||
return sessionReady
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the vanilla-upstream child-session watcher advertised by
|
||||
* `subagent.*.child_session_id`. This RPC deliberately does not mutate
|
||||
* [liveSessionId], [storedSessionId], or [liveSessionProfile]: the parent
|
||||
* conversation keeps owning the main mapper while the returned short live
|
||||
* id routes a second, read-only event stream on the same socket.
|
||||
*
|
||||
* Returned history is bounded locally even when an upstream gateway sends
|
||||
* the child's entire transcript in the resume acknowledgement. The server
|
||||
* may still enforce its own larger resume safety limit before replying.
|
||||
*/
|
||||
suspend fun openChildWatch(
|
||||
childSessionId: String,
|
||||
profile: String? = currentSessionProfile(),
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
historyLimit: Int = MAX_CHILD_WATCH_HISTORY_ITEMS,
|
||||
): Result<GatewayChildWatch> = runCatching {
|
||||
val storedChildId = childSessionId.trim()
|
||||
require(storedChildId.isNotEmpty()) { "child session id is required" }
|
||||
val requestedProfile = profile?.trim()?.takeIf(String::isNotEmpty)
|
||||
connectMutex.withLock {
|
||||
// Allocate and register under the same mutex as the resume RPC so
|
||||
// concurrent opens complete in generation order; an older caller
|
||||
// can never `put` after a newer one for the same live child id.
|
||||
val generation = childWatchGeneration.incrementAndGet()
|
||||
val pending = PendingChildWatchOpen()
|
||||
pendingChildWatchOpens[generation] = pending
|
||||
try {
|
||||
ensureConnected()
|
||||
val result = rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedChildId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
put("lazy", true)
|
||||
put("close_on_disconnect", true)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException(
|
||||
"child session resume failed: ${error.message}",
|
||||
)
|
||||
}
|
||||
val liveChildId = result.stringField("session_id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayPreflightException(
|
||||
"child session resume returned no live session id",
|
||||
)
|
||||
try {
|
||||
requireConfirmedSessionProfile(result, requestedProfile)
|
||||
} catch (error: GatewayPreflightException) {
|
||||
// The wrong profile must not leave an unowned lazy watcher behind.
|
||||
rpc(
|
||||
"session.close",
|
||||
buildJsonObject { put("session_id", liveChildId) },
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
val registration = ChildWatchRegistration(
|
||||
storedSessionId = storedChildId,
|
||||
liveSessionId = liveChildId,
|
||||
profile = requestedProfile,
|
||||
generation = generation,
|
||||
callbacks = callbacks,
|
||||
)
|
||||
val dispatchedCallbacks = dispatchOn(callbacks) {
|
||||
childWatches[liveChildId] === registration
|
||||
}
|
||||
registration.mapper = GatewayEventMapper(
|
||||
dispatchedCallbacks,
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
)
|
||||
childWatches.put(liveChildId, registration)?.let { prior ->
|
||||
if (prior.generation != generation) {
|
||||
notifyChildWatchFailure(
|
||||
prior,
|
||||
"Child watch was replaced by a newer view",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay only frames tagged with the exact live id returned by
|
||||
// this resume. Unknown gateway sessions captured during the
|
||||
// narrow ack race remain foreign and are discarded.
|
||||
val replay = pending.closeAndTake(liveChildId)
|
||||
if (replay.truncated) dispatchedCallbacks.onReconcileRequired()
|
||||
replay.events.forEach { event ->
|
||||
if (childWatches[liveChildId] === registration) {
|
||||
registration.mapper.onEvent(event.type, event.payload)
|
||||
}
|
||||
}
|
||||
val replayedTerminal = replay.events.any {
|
||||
it.type == "message.complete" || it.type == "error"
|
||||
}
|
||||
|
||||
val history = parseChildWatchMessages(result, historyLimit)
|
||||
GatewayChildWatch(
|
||||
storedSessionId = storedChildId,
|
||||
liveSessionId = liveChildId,
|
||||
profile = requestedProfile,
|
||||
generation = generation,
|
||||
messages = history.messages,
|
||||
historyTruncated = history.truncated,
|
||||
running = !replayedTerminal && result.booleanField("running") == true,
|
||||
status = if (replayedTerminal) "idle" else result.stringField("status"),
|
||||
)
|
||||
} finally {
|
||||
pendingChildWatchOpens.remove(generation, pending)
|
||||
pending.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close only the exact lazy watcher represented by [watch]. A stale handle
|
||||
* is a no-op so it can never close a newer watcher whose live id was reused.
|
||||
* The parent session and delegated child continue running server-side.
|
||||
*/
|
||||
suspend fun closeChildWatch(watch: GatewayChildWatch): Result<Unit> =
|
||||
connectMutex.withLock {
|
||||
val registration = childWatches[watch.liveSessionId]
|
||||
?: return@withLock Result.success(Unit)
|
||||
if (
|
||||
registration.generation != watch.generation ||
|
||||
registration.storedSessionId != watch.storedSessionId ||
|
||||
registration.profile != watch.profile ||
|
||||
!childWatches.remove(watch.liveSessionId, registration)
|
||||
) {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
val result = rpc(
|
||||
"session.close",
|
||||
buildJsonObject { put("session_id", watch.liveSessionId) },
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { Result.success(Unit) },
|
||||
onFailure = { error ->
|
||||
// Permit an exact-handle retry. Opens share connectMutex,
|
||||
// so no newer registration can race this restoration.
|
||||
childWatches.putIfAbsent(watch.liveSessionId, registration)
|
||||
Result.failure(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseChildWatchMessages(
|
||||
result: JsonObject,
|
||||
requestedLimit: Int,
|
||||
): BoundedChildHistory {
|
||||
val limit = requestedLimit.coerceIn(1, MAX_CHILD_WATCH_HISTORY_ITEMS)
|
||||
val all = (result["messages"] as? JsonArray).orEmpty()
|
||||
val raw = all.takeLast(limit)
|
||||
var retainedChars = 0
|
||||
var truncated = all.size > raw.size
|
||||
val newestFirst = raw.asReversed().mapNotNull { element ->
|
||||
val message = element as? JsonObject ?: run {
|
||||
truncated = true
|
||||
return@mapNotNull null
|
||||
}
|
||||
// Gateway display history uses `text`; the shared session DTO uses
|
||||
// `content`. Normalize only that projection boundary.
|
||||
val normalized = JsonObject(message.toMutableMap().apply {
|
||||
if (!containsKey("content")) {
|
||||
put("content", message["text"] ?: message["context"] ?: JsonNull)
|
||||
}
|
||||
if (!containsKey("tool_name") && message.containsKey("name")) {
|
||||
put("tool_name", message["name"] ?: JsonNull)
|
||||
}
|
||||
})
|
||||
val serializedChars = normalized.toString().length
|
||||
if (serializedChars > MAX_CHILD_WATCH_HISTORY_CHARS - retainedChars) {
|
||||
truncated = true
|
||||
return@mapNotNull null
|
||||
}
|
||||
val decoded = runCatching {
|
||||
json.decodeFromJsonElement(MessageItem.serializer(), normalized)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "child watch returned an unreadable history row", it)
|
||||
}.getOrNull()
|
||||
if (decoded == null) {
|
||||
truncated = true
|
||||
null
|
||||
} else {
|
||||
retainedChars += serializedChars
|
||||
decoded
|
||||
}
|
||||
}
|
||||
return BoundedChildHistory(newestFirst.asReversed(), truncated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a session-scoped target before a model-selection `config.set`.
|
||||
*
|
||||
@@ -3022,6 +3284,9 @@ class GatewayChatClient(
|
||||
val reason = payload?.stringField("reason")
|
||||
val supportedReason = reason in setOf("idle_timeout", "lru_evict", "ws_orphan_reap")
|
||||
if (!reclaimedLiveId.isNullOrBlank() && supportedReason) {
|
||||
childWatches.remove(reclaimedLiveId)?.let { registration ->
|
||||
notifyChildWatchFailure(registration, "Gateway reclaimed the child watch")
|
||||
}
|
||||
val background = backgroundTurns.remove(reclaimedLiveId)
|
||||
if (background != null) {
|
||||
callbackDispatcher {
|
||||
@@ -3073,6 +3338,23 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// A lazy child watcher is a second session on this shared socket. Route
|
||||
// it before the main-session recovery/foreign-session gates and require
|
||||
// the exact live id returned by its own session.resume acknowledgement.
|
||||
val childWatch = eventSessionId?.let(childWatches::get)
|
||||
if (childWatch != null) {
|
||||
if (childWatches[eventSessionId] === childWatch) {
|
||||
childWatch.mapper.onEvent(type, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val capturedForPendingChildWatch = !eventSessionId.isNullOrBlank() &&
|
||||
eventSessionId != liveSessionId &&
|
||||
!backgroundTurns.containsKey(eventSessionId) &&
|
||||
capturePendingChildWatchEvent(ChildWatchEvent(eventSessionId, type, payload))
|
||||
if (capturedForPendingChildWatch) return
|
||||
|
||||
// A cold session.resume may schedule auto-continue before its RPC
|
||||
// response reaches Android. The recovery buffer is an ownership gate,
|
||||
// not an observational copy: an event is either claimed here for
|
||||
@@ -3315,6 +3597,7 @@ class GatewayChatClient(
|
||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||
}
|
||||
pendingRpcs.clear()
|
||||
failChildWatches("Child watch disconnected from the gateway")
|
||||
val turn = activeTurn
|
||||
if (turn == null) {
|
||||
if (backgroundTurns.isNotEmpty() && !backgroundRejoinInProgress) {
|
||||
@@ -3497,6 +3780,7 @@ class GatewayChatClient(
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
failChildWatches("Child watch closed with the gateway socket")
|
||||
}
|
||||
|
||||
private fun scheduleBackgroundClose() {
|
||||
@@ -3506,7 +3790,7 @@ class GatewayChatClient(
|
||||
backgroundCloseJob?.cancel()
|
||||
backgroundCloseJob = scope.launch {
|
||||
delay(BACKGROUND_CLOSE_GRACE_MS)
|
||||
if (activeTurn == null && backgroundTurns.isEmpty() &&
|
||||
if (activeTurn == null && backgroundTurns.isEmpty() && childWatches.isEmpty() &&
|
||||
!AppForegroundTracker.isForeground.value
|
||||
) {
|
||||
closeSocket("app backgrounded")
|
||||
@@ -3514,6 +3798,28 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun failChildWatches(message: String) {
|
||||
if (childWatches.isEmpty()) return
|
||||
val registrations = childWatches.values.toSet()
|
||||
childWatches.clear()
|
||||
registrations.forEach { notifyChildWatchFailure(it, message) }
|
||||
}
|
||||
|
||||
private fun notifyChildWatchFailure(
|
||||
registration: ChildWatchRegistration,
|
||||
message: String,
|
||||
) {
|
||||
callbackDispatcher { registration.callbacks.onResumeFailure(message) }
|
||||
}
|
||||
|
||||
private fun capturePendingChildWatchEvent(event: ChildWatchEvent): Boolean {
|
||||
var captured = false
|
||||
pendingChildWatchOpens.values.forEach { pending ->
|
||||
if (pending.capture(event)) captured = true
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JSON-RPC
|
||||
// ------------------------------------------------------------------
|
||||
@@ -4062,44 +4368,55 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
/** Wrap callbacks so every invocation lands on the callback dispatcher (main thread). */
|
||||
private fun dispatchOn(callbacks: GatewayTurnCallbacks) = GatewayTurnCallbacks(
|
||||
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
|
||||
onStart = { callbackDispatcher { callbacks.onStart() } },
|
||||
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
|
||||
private fun dispatchOn(
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
stillCurrent: () -> Boolean = { true },
|
||||
) = GatewayTurnCallbacks(
|
||||
onSessionId = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onSessionId(v) } },
|
||||
onStart = { dispatchIfCurrent(stillCurrent) { callbacks.onStart() } },
|
||||
onTextDelta = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onTextDelta(v) } },
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onInterimReconciled = { text ->
|
||||
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onInterimReconciled(text) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onThinkingDelta = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { id, name, args ->
|
||||
callbackDispatcher { callbacks.onToolCallStart(id, name, args) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onToolCallStart(id, name, args) }
|
||||
},
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
|
||||
onComplete = { callbackDispatcher { callbacks.onComplete() } },
|
||||
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onResumeFailure = { v -> callbackDispatcher { callbacks.onResumeFailure(v) } },
|
||||
onFailure = { v -> callbackDispatcher { callbacks.onFailure(v) } },
|
||||
onToolCallDone = { a, b -> dispatchIfCurrent(stillCurrent) { callbacks.onToolCallDone(a, b) } },
|
||||
onToolCallFailed = { a, b -> dispatchIfCurrent(stillCurrent) { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { dispatchIfCurrent(stillCurrent) { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { dispatchIfCurrent(stillCurrent) { callbacks.onReconcileRequired() } },
|
||||
onComplete = { dispatchIfCurrent(stillCurrent) { callbacks.onComplete() } },
|
||||
onUsage = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onUsage(v) } },
|
||||
onError = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onInteractionExpired(v) } },
|
||||
onResumeFailure = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onResumeFailure(v) } },
|
||||
onFailure = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onFailure(v) } },
|
||||
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
|
||||
// onStatusUpdate a default no-op, so omitting it here silently swallows
|
||||
// EVERY gateway status line — the ❌ terminal-error lifecycle update
|
||||
// included. Without it markError never fires, the turn isn't badged
|
||||
// "Error", and onComplete's history reload wipes the error bubble (the
|
||||
// "reply appears then vanishes" bug).
|
||||
onStatusUpdate = { kind, text -> callbackDispatcher { callbacks.onStatusUpdate(kind, text) } },
|
||||
onStatusClear = { kind -> callbackDispatcher { callbacks.onStatusClear(kind) } },
|
||||
onStatusUpdate = { kind, text ->
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onStatusUpdate(kind, text) }
|
||||
},
|
||||
onStatusClear = { kind -> dispatchIfCurrent(stillCurrent) { callbacks.onStatusClear(kind) } },
|
||||
)
|
||||
|
||||
private fun dispatchIfCurrent(stillCurrent: () -> Boolean, callback: () -> Unit) {
|
||||
callbackDispatcher {
|
||||
if (stillCurrent()) callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseGatewayPersonalityOptions(result: JsonObject): List<String> =
|
||||
|
||||
@@ -281,11 +281,12 @@ class GatewayEventMapper(
|
||||
callbacks.onError(payload.string("message") ?: "Gateway error")
|
||||
}
|
||||
|
||||
"subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.spawn_requested", "subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
clearActivityStatuses()
|
||||
val phase = when (type) {
|
||||
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
@@ -306,6 +307,10 @@ class GatewayEventMapper(
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
subagentId = payload.string("subagent_id"),
|
||||
childSessionId = payload.string("child_session_id"),
|
||||
parentId = payload.string("parent_id"),
|
||||
depth = payload.int("depth"),
|
||||
model = payload.string("model"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -257,7 +258,8 @@ data class GatewayToolOutputRisk(
|
||||
|
||||
/**
|
||||
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
|
||||
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
|
||||
* per task: SPAWN_REQUESTED → START → (THINKING | TOOL | PROGRESS)* →
|
||||
* COMPLETE. Field
|
||||
* availability varies by phase — [toolName]/[preview] ride TOOL,
|
||||
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
|
||||
* omit everything beyond the three defaults-bearing fields.
|
||||
@@ -273,10 +275,36 @@ data class GatewaySubagentEvent(
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
val subagentId: String? = null,
|
||||
/** Durable child session id accepted by `session.resume {lazy:true}`. */
|
||||
val childSessionId: String? = null,
|
||||
/** Owning subagent id for nested delegation; null for first-level children. */
|
||||
val parentId: String? = null,
|
||||
/** Zero-based depth used by the upstream spawn-tree renderer. */
|
||||
val depth: Int? = null,
|
||||
/** Effective child model, when the emitter exposes it. */
|
||||
val model: String? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile-pinned, read-only child-session watch opened through the vanilla
|
||||
* upstream Gateway. [storedSessionId] is the durable child id from
|
||||
* `subagent.*`; [liveSessionId] is the short runtime id that tags subsequent
|
||||
* mirror events on this socket. The bounded [messages] snapshot is child-only.
|
||||
*/
|
||||
data class GatewayChildWatch(
|
||||
val storedSessionId: String,
|
||||
val liveSessionId: String,
|
||||
val profile: String?,
|
||||
val generation: Long,
|
||||
val messages: List<MessageItem>,
|
||||
/** True when Android retained only a bounded recent tail of the response. */
|
||||
val historyTruncated: Boolean,
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One session-owned background process returned by the upstream gateway's
|
||||
* `process.list` RPC. The registry calls its process id `session_id`; Android
|
||||
|
||||
@@ -196,7 +196,6 @@ internal class HermesRuntimeBinder(
|
||||
chat.profileSessionPinner = connection::setSessionPinned
|
||||
chat.profileSessionArchiver = connection::setSessionArchived
|
||||
chat.onSessionChanged = connection::saveLastSessionId
|
||||
chat.onFreshDraftSelected = connection::saveFreshDraft
|
||||
chat.setDemoModeWiring(
|
||||
isDemo = { connection.isDemoMode.value },
|
||||
handler = { connection.chatHandler },
|
||||
|
||||
+138
-22
@@ -20,13 +20,17 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -39,9 +43,13 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,6 +57,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -56,6 +65,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Composer-adjacent summary of upstream Hermes processes for the active chat.
|
||||
@@ -63,8 +76,10 @@ import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
* in the global session/navigation drawer.
|
||||
*/
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessStrip(
|
||||
internal fun GatewayBackgroundProcessStrip(
|
||||
processes: List<GatewayProcess>,
|
||||
subagentActivities: List<SubagentActivity>,
|
||||
subagentPreviewVisibility: SubagentPreviewVisibility,
|
||||
loading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -72,16 +87,26 @@ fun GatewayBackgroundProcessStrip(
|
||||
// Initial/switch refreshes are silent. The strip appears only after the
|
||||
// session actually owns a process, avoiding a transient "Checking" row on
|
||||
// every ordinary chat open.
|
||||
if (processes.isEmpty()) return
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty()) return
|
||||
|
||||
val running = processes.count { it.isRunning }
|
||||
val runningAgents = visibleActivities.count { !it.isTerminal }
|
||||
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
|
||||
val displayedCount = if (running > 0) running else processes.size
|
||||
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
|
||||
val interruptedAgents = visibleActivities.count {
|
||||
it.phase == SubagentActivityPhase.INTERRUPTED ||
|
||||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
|
||||
}
|
||||
val failureCount = failed + failedAgents
|
||||
val status = when {
|
||||
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
|
||||
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
failed > 0 -> "$failed ${stringResource(R.string.task_status_failed)}"
|
||||
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
|
||||
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
|
||||
else -> stringResource(R.string.task_status_complete)
|
||||
}
|
||||
val openDescription = stringResource(R.string.current_chat_activity_open)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -90,11 +115,11 @@ fun GatewayBackgroundProcessStrip(
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
"Background processes, $status. Open current chat activity."
|
||||
"$openDescription, $status"
|
||||
stateDescription = status
|
||||
}
|
||||
.clickable(
|
||||
onClickLabel = stringResource(R.string.bg_processes_open),
|
||||
onClickLabel = openDescription,
|
||||
onClick = onClick,
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -105,37 +130,41 @@ fun GatewayBackgroundProcessStrip(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (running > 0 || loading) {
|
||||
if (running > 0 || runningAgents > 0 || loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (failed > 0) Icons.Filled.ErrorOutline else Icons.Filled.CheckCircle,
|
||||
imageVector = when {
|
||||
failureCount > 0 -> Icons.Filled.ErrorOutline
|
||||
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
|
||||
else -> Icons.Filled.CheckCircle
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = if (failed > 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
tint = when {
|
||||
failureCount > 0 -> MaterialTheme.colorScheme.error
|
||||
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_process_count, displayedCount),
|
||||
text = stringResource(R.string.current_chat_activity_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (failed > 0 && running == 0) {
|
||||
color = if (failureCount > 0 && running == 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandLess,
|
||||
imageVector = Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
@@ -149,18 +178,56 @@ fun GatewayBackgroundProcessStrip(
|
||||
/** Mobile analogue of Hermes Desktop's composer process stack + terminal viewer. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessSheet(
|
||||
internal fun GatewayBackgroundProcessSheet(
|
||||
processes: List<GatewayProcess>,
|
||||
subagentActivities: List<SubagentActivity>,
|
||||
subagentChildPreview: SubagentChildPreview?,
|
||||
subagentPreviewVisibility: SubagentPreviewVisibility,
|
||||
loading: Boolean,
|
||||
stoppingProcessIds: Set<String>,
|
||||
onRefresh: () -> Unit,
|
||||
onStop: (String) -> Unit,
|
||||
onDismissProcess: (String) -> Unit,
|
||||
onOpenSubagentChild: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var expandedAgentKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val running = processes.filter { it.isRunning }
|
||||
val recent = processes.filterNot { it.isRunning }
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
val followTarget = subagentActivityFollowTarget(
|
||||
visibleActivities,
|
||||
expandedAgentKeys,
|
||||
subagentPreviewVisibility,
|
||||
subagentChildPreview,
|
||||
)
|
||||
val activityRevision = visibleActivities.sumOf { it.revision } +
|
||||
subagentChildPreview?.messages.orEmpty().sumOf { message ->
|
||||
message.content.length + message.thinkingContent.length +
|
||||
message.toolCalls.sumOf { (it.args?.length ?: 0) + it.name.length }
|
||||
}
|
||||
val nearActivityTail by remember(followTarget, listState) {
|
||||
derivedStateOf {
|
||||
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
followTarget >= 0 && last in maxOf(0, followTarget - 2)..followTarget
|
||||
}
|
||||
}
|
||||
var followAgentTail by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(listState, followTarget) {
|
||||
snapshotFlow { listState.isScrollInProgress to nearActivityTail }.collect { (scrolling, nearTail) ->
|
||||
if (scrolling) followAgentTail = nearTail
|
||||
else if (nearTail) followAgentTail = true
|
||||
}
|
||||
}
|
||||
LaunchedEffect(activityRevision, followTarget) {
|
||||
if (followAgentTail && followTarget >= 0) {
|
||||
listState.scrollToItem(followTarget)
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -178,23 +245,48 @@ fun GatewayBackgroundProcessSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.background_processes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.background_processes_subtitle),
|
||||
stringResource(R.string.current_chat_activity_title),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.background_processes_refresh_a11y))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.current_chat_activity_close),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!followAgentTail && followTarget >= 0) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
followAgentTail = true
|
||||
scope.launch { listState.animateScrollToItem(followTarget) }
|
||||
}) {
|
||||
Text(stringResource(R.string.current_chat_activity_latest))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processes.isEmpty() && !loading) {
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty() && !loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -202,23 +294,47 @@ fun GatewayBackgroundProcessSheet(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Terminal,
|
||||
Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.bg_processes_empty),
|
||||
stringResource(R.string.current_chat_activity_empty),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 560.dp),
|
||||
) {
|
||||
subagentActivityItems(
|
||||
activities = visibleActivities,
|
||||
expandedKeys = expandedAgentKeys,
|
||||
visibility = subagentPreviewVisibility,
|
||||
childPreview = subagentChildPreview,
|
||||
onToggle = { key ->
|
||||
expandedAgentKeys = if (key in expandedAgentKeys) {
|
||||
expandedAgentKeys - key
|
||||
} else {
|
||||
expandedAgentKeys + key
|
||||
}
|
||||
},
|
||||
onOpenChild = onOpenSubagentChild,
|
||||
)
|
||||
if (visibleActivities.isNotEmpty() && processes.isNotEmpty()) {
|
||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
|
||||
item {
|
||||
ProcessSectionLabel(
|
||||
stringResource(R.string.background_processes_title),
|
||||
processes.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (running.isNotEmpty()) {
|
||||
item { ProcessSectionLabel(stringResource(R.string.bg_processes_running), running.size) }
|
||||
items(running, key = { it.id }) { process ->
|
||||
|
||||
@@ -215,6 +215,7 @@ fun SessionDrawerContent(
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
onNewDefaultChat: (() -> Unit)? = null,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onRenameSession: (String, String) -> Unit,
|
||||
@@ -534,7 +535,13 @@ fun SessionDrawerContent(
|
||||
|
||||
// New Chat button
|
||||
Button(
|
||||
onClick = onNewChat,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onNewDefaultChat?.invoke() ?: onNewChat()
|
||||
} else {
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
|
||||
internal data class SubagentPreviewVisibility(
|
||||
val showLifecycle: Boolean = true,
|
||||
val showReasoning: Boolean = true,
|
||||
val showToolNames: Boolean = true,
|
||||
val showToolDetails: Boolean = true,
|
||||
val showChildHistory: Boolean = true,
|
||||
)
|
||||
|
||||
internal fun LazyListScope.subagentActivityItems(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
onToggle: (String) -> Unit,
|
||||
onOpenChild: (String) -> Unit,
|
||||
) {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return
|
||||
item(key = "subagent-section") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_section),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_disclosure),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
activities.forEach { activity ->
|
||||
val key = activity.stableKey
|
||||
item(key = "subagent-header-$key") {
|
||||
SubagentActivityHeader(
|
||||
activity = activity,
|
||||
expanded = key in expandedKeys,
|
||||
visibility = visibility,
|
||||
onClick = {
|
||||
if (key !in expandedKeys && visibility.showChildHistory) onOpenChild(key)
|
||||
onToggle(key)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (key in expandedKeys) {
|
||||
if (activity.truncated) {
|
||||
item(key = "subagent-truncated-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_older_omitted))
|
||||
}
|
||||
}
|
||||
activity.events.forEach { event ->
|
||||
item(key = "subagent-event-$key-${event.sequence}") {
|
||||
SubagentEventRow(event, visibility)
|
||||
}
|
||||
}
|
||||
if (activity.partialAfterGap) {
|
||||
item(key = "subagent-gap-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_partial))
|
||||
}
|
||||
}
|
||||
childPreview?.takeIf { visibility.showChildHistory && it.activityKey == key }?.let { preview ->
|
||||
when (preview.childWatchAvailable) {
|
||||
null -> item(key = "subagent-child-loading-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_child_loading))
|
||||
}
|
||||
false -> item(key = "subagent-child-unavailable-$key") {
|
||||
SubagentMetaRow(
|
||||
preview.error?.takeIf(String::isNotBlank)
|
||||
?: stringResource(R.string.agent_activity_child_unavailable),
|
||||
)
|
||||
}
|
||||
true -> {
|
||||
item(key = "subagent-child-heading-$key") {
|
||||
SubagentMetaRow(
|
||||
if (preview.running) {
|
||||
stringResource(R.string.agent_activity_child_live)
|
||||
} else {
|
||||
stringResource(R.string.agent_activity_child_history)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (preview.historyTruncated) {
|
||||
item(key = "subagent-child-truncated-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_child_truncated))
|
||||
}
|
||||
}
|
||||
preview.messages.filterNot { it.role == MessageRole.SYSTEM }.forEach { message ->
|
||||
item(key = "subagent-child-message-$key-${message.uiKey}") {
|
||||
SubagentChildMessageRow(message, visibility)
|
||||
}
|
||||
}
|
||||
preview.error?.takeIf(String::isNotBlank)?.let { error ->
|
||||
item(key = "subagent-child-error-$key") { SubagentMetaRow(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "subagent-child-tail-$key") {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun subagentActivityItemCount(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
): Int {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return 0
|
||||
return 1 + activities.sumOf { activity ->
|
||||
val expanded = activity.stableKey in expandedKeys
|
||||
val preview = childPreview?.takeIf {
|
||||
visibility.showChildHistory && it.activityKey == activity.stableKey
|
||||
}
|
||||
val previewRows = when (preview?.childWatchAvailable) {
|
||||
null -> if (preview != null) 1 else 0
|
||||
false -> 1
|
||||
true -> 1 + preview.messages.count { it.role != MessageRole.SYSTEM } +
|
||||
(if (preview.historyTruncated) 1 else 0) +
|
||||
(if (preview.error.isNullOrBlank()) 0 else 1)
|
||||
} + if (preview != null) 1 else 0 // explicit bottom anchor for growing rows
|
||||
1 + if (!expanded) 0 else activity.events.size +
|
||||
(if (activity.truncated) 1 else 0) +
|
||||
(if (activity.partialAfterGap) 1 else 0) + previewRows
|
||||
}
|
||||
}
|
||||
|
||||
internal fun subagentActivityFollowTarget(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
): Int {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return -1
|
||||
var index = 0 // section heading
|
||||
var selectedTarget = -1
|
||||
activities.forEach { activity ->
|
||||
index += 1 // lane header
|
||||
if (activity.stableKey in expandedKeys) {
|
||||
if (activity.truncated) index += 1
|
||||
index += activity.events.size
|
||||
if (activity.partialAfterGap) index += 1
|
||||
if (visibility.showChildHistory && childPreview?.activityKey == activity.stableKey) {
|
||||
index += when (childPreview.childWatchAvailable) {
|
||||
null, false -> 1
|
||||
true -> 1 + childPreview.messages.count { it.role != MessageRole.SYSTEM } +
|
||||
(if (childPreview.historyTruncated) 1 else 0) +
|
||||
(if (childPreview.error.isNullOrBlank()) 0 else 1)
|
||||
}
|
||||
index += 1 // explicit bottom anchor for growing child content
|
||||
selectedTarget = index
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (selectedTarget >= 0) selectedTarget else index
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentActivityHeader(
|
||||
activity: SubagentActivity,
|
||||
expanded: Boolean,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val title = activity.goal.takeIf { visibility.showReasoning && it.isNotBlank() }
|
||||
?: stringResource(R.string.agent_activity_fallback, activity.taskIndex + 1)
|
||||
val phaseLabel = phaseLabel(activity.phase)
|
||||
val description = stringResource(
|
||||
R.string.agent_activity_lane_a11y,
|
||||
title,
|
||||
phaseLabel,
|
||||
activity.taskIndex + 1,
|
||||
activity.taskCount,
|
||||
)
|
||||
val icon: ImageVector = when (activity.phase) {
|
||||
SubagentActivityPhase.COMPLETED -> Icons.Filled.CheckCircle
|
||||
SubagentActivityPhase.FAILED -> Icons.Filled.ErrorOutline
|
||||
SubagentActivityPhase.INTERRUPTED,
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
-> Icons.Filled.PauseCircleOutline
|
||||
SubagentActivityPhase.STARTED,
|
||||
SubagentActivityPhase.THINKING,
|
||||
SubagentActivityPhase.TOOL,
|
||||
SubagentActivityPhase.PROGRESS,
|
||||
-> Icons.Filled.HourglassTop
|
||||
}
|
||||
val tint = when (activity.phase) {
|
||||
SubagentActivityPhase.FAILED -> MaterialTheme.colorScheme.error
|
||||
SubagentActivityPhase.COMPLETED -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics {
|
||||
contentDescription = description
|
||||
stateDescription = phaseLabel
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
}
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.agent_activity_task_position,
|
||||
activity.taskIndex + 1,
|
||||
activity.taskCount,
|
||||
phaseLabel,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
activity.durationSeconds?.takeIf { activity.isTerminal }?.let { seconds ->
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_duration, seconds),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Icon(
|
||||
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
if (expanded) R.string.cd_subagent_collapse else R.string.cd_subagent_expand,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentEventRow(
|
||||
event: SubagentActivityEvent,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
) {
|
||||
val label = when (event.kind) {
|
||||
SubagentActivityEventKind.STARTED -> stringResource(R.string.agent_activity_event_started)
|
||||
SubagentActivityEventKind.UPDATE -> stringResource(R.string.agent_activity_event_update)
|
||||
SubagentActivityEventKind.TOOL -> stringResource(R.string.agent_activity_event_tool)
|
||||
SubagentActivityEventKind.COMPLETED -> phaseLabel(event.phase)
|
||||
}
|
||||
val showText = when (event.kind) {
|
||||
SubagentActivityEventKind.STARTED -> false
|
||||
SubagentActivityEventKind.UPDATE,
|
||||
SubagentActivityEventKind.COMPLETED,
|
||||
-> visibility.showReasoning
|
||||
SubagentActivityEventKind.TOOL -> visibility.showToolDetails
|
||||
}
|
||||
val toolName = event.toolName?.takeIf { visibility.showToolNames || visibility.showToolDetails }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, end = 20.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
toolName?.let {
|
||||
Text(
|
||||
" · $it",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
event.text?.takeIf { showText && it.isNotBlank() }?.let { text ->
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = if (event.kind == SubagentActivityEventKind.TOOL) {
|
||||
FontFamily.Monospace
|
||||
} else {
|
||||
FontFamily.Default
|
||||
},
|
||||
maxLines = 8,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentMetaRow(text: String) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(start = 38.dp, end = 20.dp, top = 4.dp, bottom = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentChildMessageRow(
|
||||
message: ChatMessage,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
) {
|
||||
if (message.role == MessageRole.SYSTEM) return
|
||||
val role = when (message.role) {
|
||||
MessageRole.USER -> stringResource(R.string.agent_activity_child_role_task)
|
||||
MessageRole.ASSISTANT -> stringResource(R.string.agent_activity_child_role_agent)
|
||||
MessageRole.SYSTEM -> stringResource(R.string.agent_activity_child_role_system)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, end = 20.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
role,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
message.thinkingContent.takeIf { visibility.showReasoning && it.isNotBlank() }?.let { thought ->
|
||||
Text(
|
||||
thought,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 10,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
message.content.takeIf(String::isNotBlank)?.let { content ->
|
||||
Text(
|
||||
content,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 20,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (visibility.showToolNames || visibility.showToolDetails) {
|
||||
message.toolCalls.forEach { tool ->
|
||||
Text(
|
||||
buildString {
|
||||
append(tool.name)
|
||||
if (visibility.showToolDetails) {
|
||||
tool.args?.takeIf(String::isNotBlank)?.let { append(" · ").append(it.take(500)) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: SubagentActivityPhase): String = stringResource(
|
||||
when (phase) {
|
||||
SubagentActivityPhase.STARTED -> R.string.agent_activity_status_started
|
||||
SubagentActivityPhase.THINKING -> R.string.agent_activity_status_thinking
|
||||
SubagentActivityPhase.TOOL -> R.string.agent_activity_status_tool
|
||||
SubagentActivityPhase.PROGRESS -> R.string.agent_activity_status_progress
|
||||
SubagentActivityPhase.COMPLETED -> R.string.agent_activity_status_completed
|
||||
SubagentActivityPhase.FAILED -> R.string.agent_activity_status_failed
|
||||
SubagentActivityPhase.INTERRUPTED -> R.string.agent_activity_status_interrupted
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT -> R.string.agent_activity_status_unavailable
|
||||
},
|
||||
)
|
||||
@@ -216,6 +216,7 @@ import com.hermesandroid.relay.ui.components.CHAT_PET_STEP_MESSAGE_MARKER
|
||||
import com.hermesandroid.relay.ui.components.CHAT_PET_USER_MESSAGE_PERCH_PREFIX
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.components.InjectedContextSheet
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.loadedContentTransform
|
||||
@@ -928,6 +929,8 @@ fun ChatScreen(
|
||||
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
|
||||
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
@@ -1062,6 +1065,13 @@ fun ChatScreen(
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val subagentPreviewVisibility = SubagentPreviewVisibility(
|
||||
showLifecycle = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showReasoning = showThinking,
|
||||
showToolNames = toolDisplay == "compact" || toolDisplay == "detailed",
|
||||
showToolDetails = toolDisplay == "detailed",
|
||||
showChildHistory = !supervised,
|
||||
)
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -1362,6 +1372,7 @@ fun ChatScreen(
|
||||
// A process inventory is scoped to one gateway session. Never leave a
|
||||
// sheet opened onto a different chat after a drawer/profile switch.
|
||||
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
showBackgroundProcesses = false
|
||||
}
|
||||
|
||||
@@ -2385,15 +2396,8 @@ fun ChatScreen(
|
||||
}
|
||||
val selectProfileFromShelf: (com.hermesandroid.relay.data.Profile?) -> Unit = { profile ->
|
||||
if (AgentDisplay.profileSessionKey(profile?.name) != selectedProfileKey) {
|
||||
val profileName = profile?.name
|
||||
chatViewModel.selectProfileFromHeader(
|
||||
profileName = profileName,
|
||||
profile = profile,
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnection?.id,
|
||||
profileName = profileName,
|
||||
),
|
||||
)
|
||||
connectionViewModel.selectProfile(profile)
|
||||
chatViewModel.activateGatewayProfile(profile)
|
||||
}
|
||||
}
|
||||
val hasLiveConversationSurface = messages.isNotEmpty() || isStreaming
|
||||
@@ -2476,6 +2480,25 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
val defaultProfile = agentProfiles.firstOrNull {
|
||||
it.name.equals("default", ignoreCase = true)
|
||||
} ?: com.hermesandroid.relay.data.Profile(
|
||||
name = "default",
|
||||
model = "",
|
||||
description = "Default",
|
||||
)
|
||||
val opened = chatViewModel.createProfileChat(
|
||||
profileName = "default",
|
||||
profile = defaultProfile,
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnection?.id,
|
||||
profileName = "default",
|
||||
),
|
||||
)
|
||||
if (opened) scope.launch { drawerState.close() }
|
||||
},
|
||||
onSelectSession = { sessionId ->
|
||||
chatViewModel.switchSession(sessionId)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -3942,6 +3965,8 @@ fun ChatScreen(
|
||||
if (isGatewayTransport) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
)
|
||||
@@ -4851,12 +4876,19 @@ fun ChatScreen(
|
||||
if (showBackgroundProcesses) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
subagentChildPreview = subagentChildPreview,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
stoppingProcessIds = stoppingProcessIds,
|
||||
onRefresh = chatViewModel::refreshBackgroundProcesses,
|
||||
onStop = chatViewModel::stopBackgroundProcess,
|
||||
onDismissProcess = chatViewModel::dismissBackgroundProcess,
|
||||
onDismiss = { showBackgroundProcesses = false },
|
||||
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
|
||||
onDismiss = {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
showBackgroundProcesses = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import com.hermesandroid.relay.data.applyMessageReaction
|
||||
import com.hermesandroid.relay.data.parseChatQuotedPrompt
|
||||
import com.hermesandroid.relay.data.prepareTextTransportAttachments
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
@@ -420,10 +419,8 @@ class ChatViewModel : ViewModel() {
|
||||
_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))
|
||||
val identity = AgentDisplay.parseProfileContextKey(contextKey) ?: return null
|
||||
return SessionActivityScope.of(identity.connectionId, identity.profileKey)
|
||||
}
|
||||
|
||||
private fun activityOwner(
|
||||
@@ -535,7 +532,8 @@ class ChatViewModel : ViewModel() {
|
||||
private fun backgroundTurnKey(sessionId: String, profile: String?): TurnCheckpointKey? {
|
||||
val profileKey = AgentDisplay.profileSessionKey(profile)
|
||||
return backgroundTurnCheckpoints.keys.firstOrNull { key ->
|
||||
key.sessionId == sessionId && key.contextKey.substringAfterLast("::") == profileKey
|
||||
key.sessionId == sessionId &&
|
||||
AgentDisplay.parseProfileContextKey(key.contextKey)?.profileKey == profileKey
|
||||
}
|
||||
}
|
||||
private var checkpointWriteJob: Job? = null
|
||||
@@ -548,6 +546,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private data class ActiveTurnCheckpointSeed(
|
||||
var contextKey: String?,
|
||||
val profileKey: String?,
|
||||
var sessionId: String,
|
||||
var liveSessionId: String?,
|
||||
var transport: String,
|
||||
@@ -661,7 +660,6 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
/** Callback to persist session ID — set by RelayApp */
|
||||
var onSessionChanged: ((String?) -> Unit)? = null
|
||||
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Send a user message into an agent **Thread** (a `source=phone` session)
|
||||
@@ -680,7 +678,6 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private data class PendingThread(val chatId: String, val name: String)
|
||||
private var pendingThread: PendingThread? = null
|
||||
private val threadNavigationGeneration = AtomicLong(0L)
|
||||
|
||||
/**
|
||||
* A "+ New Thread" whose first message has been sent — we're now polling for
|
||||
@@ -696,17 +693,6 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
private var creatingThread: CreatingThread? = null
|
||||
|
||||
/**
|
||||
* Provisional phone Threads are route-owned drafts, not transferable chat
|
||||
* drafts. Leaving that surface retires only the pending local route; durable
|
||||
* inbox/session rows and learned session-to-chat-id mappings stay intact.
|
||||
*/
|
||||
private fun exitProvisionalThread() {
|
||||
threadNavigationGeneration.incrementAndGet()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
}
|
||||
|
||||
/**
|
||||
* `sessionId` → phone-platform `chat_id`, learned for threads this app
|
||||
* created ([switchToCreatedThread]) or received a message in
|
||||
@@ -2092,6 +2078,10 @@ class ChatViewModel : ViewModel() {
|
||||
private var chatVisible = false
|
||||
private var gatewayProcessSource: GatewayProcessSource? = null
|
||||
private val gatewayProcessController = GatewayProcessController(viewModelScope)
|
||||
private val subagentActivityController = SubagentActivityController()
|
||||
private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope)
|
||||
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
|
||||
subagentChildPreviewController.state
|
||||
|
||||
/** Session-scoped upstream shell processes shown beside the composer. */
|
||||
val backgroundProcesses: StateFlow<List<GatewayProcess>> = gatewayProcessController.processes
|
||||
@@ -2103,6 +2093,33 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundProcessesLoading: StateFlow<Boolean> = gatewayProcessController.loading
|
||||
val stoppingProcessIds: StateFlow<Set<String>> = gatewayProcessController.stoppingProcessIds
|
||||
|
||||
/** Bounded parent-session lifecycle previews for the active chat's delegated work. */
|
||||
internal val subagentActivities: StateFlow<List<SubagentActivity>> =
|
||||
subagentActivityController.activities
|
||||
|
||||
fun openSubagentChildPreview(activityKey: String) {
|
||||
val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
|
||||
val parentSessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val parentScopeKey = activeProfileContextKey
|
||||
val client = gatewayClient
|
||||
subagentChildPreviewController.open(
|
||||
activity = activity,
|
||||
client = client,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
gatewayRouteActive = streamingEndpoint == "gateway",
|
||||
stillOwnsParent = {
|
||||
gatewayClient === client &&
|
||||
chatHandler?.currentSessionId?.value == parentSessionId &&
|
||||
activeProfileContextKey == parentScopeKey
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun closeSubagentChildPreview() {
|
||||
subagentChildPreviewController.close()
|
||||
}
|
||||
|
||||
private val _messageReactionsSupported = MutableStateFlow(true)
|
||||
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
|
||||
|
||||
@@ -2456,6 +2473,8 @@ class ChatViewModel : ViewModel() {
|
||||
resetApprovalModeState()
|
||||
_messageReactionsSupported.value = true
|
||||
gatewayProcessSource = client?.let(::GatewayChatProcessSource)
|
||||
closeSubagentChildPreview()
|
||||
subagentActivityController.resetConnection()
|
||||
gatewayProcessController.bind(
|
||||
newSource = gatewayProcessSource,
|
||||
sessionId = chatHandler?.currentSessionId?.value,
|
||||
@@ -2618,7 +2637,7 @@ class ChatViewModel : ViewModel() {
|
||||
val matching = backgroundTurnCheckpoints.keys.filter { key ->
|
||||
val checkpoint = backgroundTurnCheckpoints[key]
|
||||
key.sessionId == completion.storedSessionId &&
|
||||
key.contextKey.substringAfterLast("::") == profileKey &&
|
||||
AgentDisplay.parseProfileContextKey(key.contextKey)?.profileKey == profileKey &&
|
||||
checkpoint?.liveSessionId == completion.liveSessionId
|
||||
}
|
||||
if (matching.isEmpty()) return
|
||||
@@ -2667,11 +2686,14 @@ class ChatViewModel : ViewModel() {
|
||||
queuedRecovery: QueuedRecoveryHandoff? = null,
|
||||
): GatewayInboundTurnRegistration? {
|
||||
val handler = chatHandler ?: return null
|
||||
val eventScopeKey = activeProfileContextKey
|
||||
val eventProfile = currentSessionProfileName()
|
||||
fun matchesAdmissionContext(): Boolean =
|
||||
gatewayClient === client &&
|
||||
streamingEndpoint == "gateway" &&
|
||||
chatHandler === handler &&
|
||||
handler.currentSessionId.value == storedSessionId
|
||||
handler.currentSessionId.value == storedSessionId &&
|
||||
activeProfileContextKey == eventScopeKey
|
||||
|
||||
val messageId = "gateway-inbound-${UUID.randomUUID()}"
|
||||
val queuedUserMessageId = "gateway-queued-user-${UUID.randomUUID()}"
|
||||
@@ -2706,6 +2728,11 @@ class ChatViewModel : ViewModel() {
|
||||
onStart = {
|
||||
if (!started && ownsBoundTurn()) {
|
||||
started = true
|
||||
subagentActivityController.beginTurn(
|
||||
storedSessionId,
|
||||
eventScopeKey,
|
||||
messageId,
|
||||
)
|
||||
cancelAnswerRecovery()
|
||||
intentionallyCancelled = false
|
||||
firstTokenNotified = false
|
||||
@@ -2764,6 +2791,7 @@ class ChatViewModel : ViewModel() {
|
||||
?.content
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (canWriteTranscript) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
val failed = handler.messages.value
|
||||
.lastOrNull { it.id == messageId }
|
||||
?.badges
|
||||
@@ -2799,6 +2827,7 @@ class ChatViewModel : ViewModel() {
|
||||
onError = { message ->
|
||||
val canWriteTranscript = acceptsEvent()
|
||||
if (canWriteTranscript) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(message)
|
||||
emitError(Exception(message), context = "send_message")
|
||||
@@ -2816,7 +2845,16 @@ class ChatViewModel : ViewModel() {
|
||||
if (acceptsEvent()) handler.onToolGenerating(messageId, name)
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (acceptsEvent()) handler.onSubagentEvent(messageId, event)
|
||||
if (acceptsEvent()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = storedSessionId,
|
||||
eventScopeKey = eventScopeKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = eventProfile,
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
}
|
||||
},
|
||||
onMoaReference = { event ->
|
||||
if (acceptsEvent()) handler.onMoaReference(messageId, event)
|
||||
@@ -3386,7 +3424,13 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
scopeKey: String? = activeProfileContextKey,
|
||||
) {
|
||||
subagentChildPreview.value?.let { preview ->
|
||||
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
|
||||
closeSubagentChildPreview()
|
||||
}
|
||||
}
|
||||
gatewayProcessController.selectSession(sessionId, scopeKey)
|
||||
subagentActivityController.selectSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3653,6 +3697,14 @@ class ChatViewModel : ViewModel() {
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.connectionState.collect { state ->
|
||||
if (gatewayClient !== client) return@collect
|
||||
subagentActivityController.onConnectionReady(
|
||||
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
|
||||
)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverPersonality.collect { value ->
|
||||
if (gatewayClient !== client || value == null) return@collect
|
||||
@@ -4222,7 +4274,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionRefreshJob?.cancel()
|
||||
_isLoadingSessions.value = false
|
||||
conversationBindingController.reset()
|
||||
exitProvisionalThread()
|
||||
relayCapabilityGeneration.incrementAndGet()
|
||||
relayReasoningCapabilities.value = emptyMap()
|
||||
_reasoningCapabilityRevision.value += 1L
|
||||
@@ -4270,7 +4321,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String,
|
||||
): Boolean {
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
exitProvisionalThread()
|
||||
// Detach the old live gateway session without reading launch/global
|
||||
// model options: session.info for the resumed owner is authoritative.
|
||||
activateGatewayProfile(profile, refreshModelOptions = false)
|
||||
@@ -4280,7 +4330,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = sessionId,
|
||||
explicitProfileName = profileName,
|
||||
explicitDisplayProfile = profile,
|
||||
explicitBinding = true,
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -4291,15 +4340,11 @@ class ChatViewModel : ViewModel() {
|
||||
* `default` profile wins over the server's sticky active profile everywhere.
|
||||
*/
|
||||
fun createProfileChat(
|
||||
profileName: String?,
|
||||
profileName: String,
|
||||
profile: Profile?,
|
||||
contextKey: String,
|
||||
): Boolean {
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
// A provisional phone Thread belongs to its original connection/chat_id
|
||||
// and cannot transfer to another profile. Exit it before binding or
|
||||
// persisting the destination draft so the next send uses session.create.
|
||||
exitProvisionalThread()
|
||||
activateGatewayProfile(profile, refreshModelOptions = false)
|
||||
refreshActiveAgentName(profile, relabelGenericMessages = true)
|
||||
switchProfileContextInternal(
|
||||
@@ -4307,49 +4352,11 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = null,
|
||||
explicitProfileName = profileName,
|
||||
explicitDisplayProfile = profile,
|
||||
explicitBinding = true,
|
||||
)
|
||||
// Selection has already moved persistence to the target profile, so
|
||||
// clear that profile/transport's stored last-session slot as part of
|
||||
// the same draft transfer. A restart must reopen the draft, not the
|
||||
// target profile's previous conversation.
|
||||
persistFreshDraft(profileName)
|
||||
AppAnalytics.onSessionCreated()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic owner switch for the Chat header.
|
||||
*
|
||||
* Empty ordinary drafts and provisional phone Threads both become a fresh
|
||||
* destination-profile draft, but only after provisional routing is retired.
|
||||
* Durable sessions keep the established profile-selection lifecycle, whose
|
||||
* binder may restore the destination profile's compatible last session.
|
||||
*/
|
||||
fun selectProfileFromHeader(
|
||||
profileName: String?,
|
||||
profile: Profile?,
|
||||
contextKey: String,
|
||||
): Boolean {
|
||||
val handler = chatHandler ?: return false
|
||||
val currentSessionId = handler.currentSessionId.value
|
||||
val activeSession = handler.sessions.value.firstOrNull {
|
||||
it.sessionId == currentSessionId
|
||||
}
|
||||
if (currentSessionId == null || activeSession?.source == "phone") {
|
||||
return createProfileChat(profileName, profile, contextKey)
|
||||
}
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
exitProvisionalThread()
|
||||
activateGatewayProfile(profile)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun persistFreshDraft(profileName: String?) {
|
||||
val transport = SessionTransport.forEndpoint(streamingEndpoint)
|
||||
onFreshDraftSelected?.invoke(profileName, transport) ?: onSessionChanged?.invoke(null)
|
||||
}
|
||||
|
||||
fun switchProfileContext(contextKey: String, sessionId: String?) {
|
||||
clearOpenedSessionOwner()
|
||||
switchProfileContextInternal(contextKey, sessionId)
|
||||
@@ -4372,19 +4379,14 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
explicitProfileName: String? = null,
|
||||
explicitDisplayProfile: Profile? = null,
|
||||
explicitBinding: Boolean = false,
|
||||
reconciliation: Boolean = false,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
dismissChatFailure()
|
||||
val previousBinding = conversationBinding.value
|
||||
val isInitialContextBinding = !previousBinding.isBound
|
||||
val targetProfileName = if (explicitBinding) {
|
||||
explicitProfileName
|
||||
} else {
|
||||
sessionProfileNameProvider()
|
||||
}
|
||||
if (explicitBinding) {
|
||||
val targetProfileName = explicitProfileName ?: sessionProfileNameProvider()
|
||||
if (explicitProfileName != null) {
|
||||
val accepted = conversationBindingController.openExplicit(
|
||||
contextKey = contextKey,
|
||||
profileName = explicitProfileName,
|
||||
@@ -4742,12 +4744,9 @@ class ChatViewModel : ViewModel() {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
// A new chat clears only the durable session identity. Keep the bound
|
||||
// profile/context so an All Profiles conversation becomes a fresh
|
||||
// draft for that same owner instead of falling back to the globally
|
||||
// restored default profile.
|
||||
conversationBindingController.startFreshDraft()
|
||||
exitProvisionalThread()
|
||||
clearOpenedSessionOwner()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
|
||||
// Gateway turns continue as detached siblings; SSE remains exclusive.
|
||||
releaseTurnForNavigation(handler)
|
||||
@@ -4777,7 +4776,7 @@ class ChatViewModel : ViewModel() {
|
||||
_fastEnabled.value = null
|
||||
approvalModeRevision.incrementAndGet()
|
||||
pendingYolo = null
|
||||
persistFreshDraft(currentSessionProfileName())
|
||||
onSessionChanged?.invoke(null)
|
||||
AppAnalytics.onSessionCreated()
|
||||
onReady?.invoke(null)
|
||||
return
|
||||
@@ -4847,7 +4846,6 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
fun startNewThread(name: String) {
|
||||
val handler = chatHandler ?: return
|
||||
exitProvisionalThread()
|
||||
recordPreResetEvidence(handler, "new_thread")
|
||||
releaseTurnForNavigation(handler)
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
@@ -4884,7 +4882,6 @@ class ChatViewModel : ViewModel() {
|
||||
.sortedBy { it.receivedAt }
|
||||
if (ordered.isEmpty()) return
|
||||
|
||||
exitProvisionalThread()
|
||||
recordPreResetEvidence(handler, "open_proactive_thread")
|
||||
|
||||
releaseTurnForNavigation(handler)
|
||||
@@ -4894,6 +4891,7 @@ class ChatViewModel : ViewModel() {
|
||||
chatId = normalizedChatId,
|
||||
name = ordered.last().title.ifBlank { "Hermes" },
|
||||
)
|
||||
creatingThread = null
|
||||
gatewayClient?.clearSession()
|
||||
handler.setSessionId(null)
|
||||
selectBackgroundProcessSession(null)
|
||||
@@ -4946,20 +4944,11 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private fun switchToCreatedThread() {
|
||||
val creating = creatingThread ?: return
|
||||
val generation = threadNavigationGeneration.get()
|
||||
viewModelScope.launch {
|
||||
for (delayMs in longArrayOf(900L, 1300L, 1800L, 2500L, 3500L, 4500L)) {
|
||||
delay(delayMs)
|
||||
if (
|
||||
threadNavigationGeneration.get() != generation ||
|
||||
creatingThread != creating
|
||||
) return@launch
|
||||
refreshSessions()
|
||||
delay(400L) // let the refresh job land in the sessions flow
|
||||
if (
|
||||
threadNavigationGeneration.get() != generation ||
|
||||
creatingThread != creating
|
||||
) return@launch
|
||||
val match = chatHandler?.sessions?.value?.firstOrNull {
|
||||
it.source == "phone" && it.sessionId !in creating.knownIds
|
||||
}
|
||||
@@ -4989,7 +4978,8 @@ class ChatViewModel : ViewModel() {
|
||||
val handler = chatHandler ?: return
|
||||
dismissChatFailure()
|
||||
if (streamingEndpoint != "gateway" && apiClient == null) return
|
||||
exitProvisionalThread()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
|
||||
// Keep a Gateway sibling alive and detach its callbacks. SSE remains a
|
||||
// single exclusive stream and is interrupted on navigation.
|
||||
@@ -6339,6 +6329,16 @@ class ChatViewModel : ViewModel() {
|
||||
checkpointWriteJob?.cancel()
|
||||
activeTurnCheckpointSeed = ActiveTurnCheckpointSeed(
|
||||
contextKey = activeProfileContextKey,
|
||||
// Persist the explicit UI selection, not the effective sticky
|
||||
// server profile used to bind the current live session. Server
|
||||
// Default must survive restart as the sentinel even when Hermes
|
||||
// currently resolves it to a named profile such as `victor`.
|
||||
profileKey = AgentDisplay.profileSessionKey(
|
||||
conversationBinding.value.let { binding ->
|
||||
if (binding.hasExplicitOwner) binding.profileName
|
||||
else selectedProfileProvider()?.name
|
||||
},
|
||||
),
|
||||
sessionId = sessionId,
|
||||
liveSessionId = null,
|
||||
transport = transport,
|
||||
@@ -6379,6 +6379,7 @@ class ChatViewModel : ViewModel() {
|
||||
checkpointWriteJob?.cancel()
|
||||
activeTurnCheckpointSeed = ActiveTurnCheckpointSeed(
|
||||
contextKey = checkpoint.contextKey,
|
||||
profileKey = checkpoint.profileKey,
|
||||
sessionId = checkpoint.sessionId,
|
||||
liveSessionId = checkpoint.liveSessionId,
|
||||
transport = checkpoint.transport,
|
||||
@@ -6439,6 +6440,7 @@ class ChatViewModel : ViewModel() {
|
||||
val now = System.currentTimeMillis()
|
||||
return ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = seed.profileKey,
|
||||
sessionId = sessionId,
|
||||
liveSessionId = gatewayClient?.currentLiveSessionId(sessionId) ?: seed.liveSessionId,
|
||||
transport = seed.transport,
|
||||
@@ -6886,6 +6888,11 @@ class ChatViewModel : ViewModel() {
|
||||
queuedSuccessorPending: AtomicBoolean,
|
||||
): GatewayTurnCallbacks {
|
||||
val messageId = checkpoint.assistant.id
|
||||
subagentActivityController.beginTurn(
|
||||
checkpoint.sessionId,
|
||||
checkpoint.contextKey,
|
||||
messageId,
|
||||
)
|
||||
fun owns(): Boolean = ownsTurnCheckpoint(checkpoint, handler)
|
||||
return GatewayTurnCallbacks(
|
||||
onSessionId = { },
|
||||
@@ -6936,6 +6943,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
if (owns()) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val failed = handler.messages.value
|
||||
.lastOrNull { it.id == messageId }
|
||||
@@ -6973,6 +6981,7 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onError = { error ->
|
||||
if (owns()) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
if (queuedSuccessorPending.get()) {
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(error)
|
||||
@@ -6997,6 +7006,18 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (owns()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = checkpoint.sessionId,
|
||||
eventScopeKey = checkpoint.contextKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = if (checkpoint.profileKey != null) {
|
||||
AgentDisplay.profileRequestName(checkpoint.profileKey)
|
||||
} else {
|
||||
AgentDisplay.parseProfileContextKey(checkpoint.contextKey)
|
||||
?.requestProfileName
|
||||
},
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
@@ -8769,6 +8790,7 @@ class ChatViewModel : ViewModel() {
|
||||
// wins — stop the poller before finalizing so the turn can't
|
||||
// finish twice.
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
subagentActivityController.endTurn(currentMessageId)
|
||||
val completedTransport = dispatchedSseEndpoint
|
||||
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
|
||||
val turnErrored = handler.messages.value
|
||||
@@ -8896,6 +8918,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
markTransportFailed(errorMsg)
|
||||
subagentActivityController.endTurn(currentMessageId)
|
||||
stopImageActivityBridge()
|
||||
flushAndReleaseStreamDeltas()
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
@@ -9296,6 +9319,13 @@ class ChatViewModel : ViewModel() {
|
||||
// context rides the SSE systemMessage (invisible) + the on-demand
|
||||
// android_phone_status tool instead. See PhoneStatusPromptBuilder.
|
||||
_steerableTurn.value = true
|
||||
handler.currentSessionId.value?.let { existingSessionId ->
|
||||
subagentActivityController.beginTurn(
|
||||
existingSessionId,
|
||||
activeProfileContextKey,
|
||||
currentMessageId,
|
||||
)
|
||||
}
|
||||
gateway.sendTurn(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
text = message,
|
||||
@@ -9317,6 +9347,11 @@ class ChatViewModel : ViewModel() {
|
||||
markSessionActivityStarting(sid)
|
||||
updateTurnCheckpointSession(sid)
|
||||
selectBackgroundProcessSession(sid)
|
||||
subagentActivityController.beginTurn(
|
||||
sid,
|
||||
activeProfileContextKey,
|
||||
currentMessageId,
|
||||
)
|
||||
gatewayProcessController.sessionReady(sid)
|
||||
onSessionChanged?.invoke(sid)
|
||||
// The brand-new chat now has a session — apply any
|
||||
@@ -9361,6 +9396,13 @@ class ChatViewModel : ViewModel() {
|
||||
onSubagentEvent = { event ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
eventScopeKey = activeProfileContextKey,
|
||||
turnId = currentMessageId,
|
||||
event = event,
|
||||
profile = currentSessionProfileName(),
|
||||
)
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
|
||||
@@ -6884,7 +6884,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ground truth about which transport can resume it, robust to a
|
||||
// turn that fell back from gateway to SSE.
|
||||
val transport = SessionTransport.forSessionId(sessionId)
|
||||
profileController.markSessionPersisted(connectionId, profileName, transport)
|
||||
profileController.profileSessionStore.setSessionId(
|
||||
connectionId,
|
||||
profileName,
|
||||
@@ -6921,20 +6920,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist an intentional empty draft without conflating it with transient null state. */
|
||||
fun saveFreshDraft(profileName: String?, transport: SessionTransport) {
|
||||
_lastSessionId.value = null
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
profileController.markFreshDraft(connectionId, profileName, transport)
|
||||
if (profileName == null) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_LAST_SESSION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared methods ---
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
|
||||
-12
@@ -102,18 +102,6 @@ internal class ConversationBindingController {
|
||||
)
|
||||
}
|
||||
|
||||
/** A user-requested draft keeps its owner and fences persisted-session reconciliation. */
|
||||
fun startFreshDraft() {
|
||||
val current = _state.value
|
||||
if (!current.isBound) return
|
||||
if (current.sessionId == null && current.hasExplicitOwner) return
|
||||
_state.value = current.copy(
|
||||
sessionId = null,
|
||||
origin = ConversationBindingOrigin.ExplicitSession,
|
||||
revision = current.revision + 1,
|
||||
)
|
||||
}
|
||||
|
||||
fun releaseExplicitOwner() {
|
||||
if (!_state.value.hasExplicitOwner) return
|
||||
reset()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
internal enum class SubagentActivityPhase {
|
||||
STARTED,
|
||||
THINKING,
|
||||
TOOL,
|
||||
PROGRESS,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
INTERRUPTED,
|
||||
ENDED_WITH_PARENT,
|
||||
}
|
||||
|
||||
internal enum class SubagentActivityEventKind { STARTED, UPDATE, TOOL, COMPLETED }
|
||||
|
||||
internal data class SubagentActivityEvent(
|
||||
val sequence: Long,
|
||||
val kind: SubagentActivityEventKind,
|
||||
val text: String? = null,
|
||||
val toolName: String? = null,
|
||||
val phase: SubagentActivityPhase,
|
||||
val observedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* A bounded, ephemeral projection of parent-session `subagent.*` events.
|
||||
*
|
||||
* This is intentionally not a child transcript. Upstream currently exposes no
|
||||
* durable child-session key or child-history route, so the projection is owned
|
||||
* by the exact profile-scoped parent session and parent turn that emitted it.
|
||||
*/
|
||||
internal data class SubagentActivity(
|
||||
val laneId: Long,
|
||||
val turnId: String,
|
||||
val taskIndex: Int,
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val subagentId: String? = null,
|
||||
val childSessionId: String? = null,
|
||||
val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
val model: String? = null,
|
||||
val profile: String? = null,
|
||||
val phase: SubagentActivityPhase,
|
||||
val summary: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
val events: List<SubagentActivityEvent> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
val partialAfterGap: Boolean = false,
|
||||
val revision: Long = 0L,
|
||||
) {
|
||||
val stableKey: String
|
||||
get() = "$turnId:$laneId"
|
||||
|
||||
val isTerminal: Boolean
|
||||
get() = phase in setOf(
|
||||
SubagentActivityPhase.COMPLETED,
|
||||
SubagentActivityPhase.FAILED,
|
||||
SubagentActivityPhase.INTERRUPTED,
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps live child activity isolated from the unrelated `process.list`
|
||||
* registry. All text is control-sanitized and bounded before entering UI state.
|
||||
*/
|
||||
internal class SubagentActivityController(
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
companion object {
|
||||
internal const val MAX_EVENTS_PER_CHILD = 50
|
||||
internal const val MAX_CHARS_PER_CHILD = 32_000
|
||||
internal const val MAX_GOAL_CHARS = 500
|
||||
internal const val MAX_EVENT_TEXT_CHARS = 2_000
|
||||
internal const val MAX_TOOL_NAME_CHARS = 160
|
||||
}
|
||||
|
||||
private val _activities = MutableStateFlow<List<SubagentActivity>>(emptyList())
|
||||
val activities: StateFlow<List<SubagentActivity>> = _activities.asStateFlow()
|
||||
|
||||
private var storedSessionId: String? = null
|
||||
private var scopeKey: String? = null
|
||||
private var activeTurnId: String? = null
|
||||
private var sequence = 0L
|
||||
private var laneSequence = 0L
|
||||
private var connectionWasReady = false
|
||||
private var pendingGap = false
|
||||
|
||||
fun selectSession(sessionId: String?, newScopeKey: String?) {
|
||||
if (storedSessionId == sessionId && scopeKey == newScopeKey) return
|
||||
storedSessionId = sessionId
|
||||
scopeKey = newScopeKey
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun resetConnection() {
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun onConnectionReady(ready: Boolean) {
|
||||
if (connectionWasReady && !ready && _activities.value.any { !it.isTerminal }) {
|
||||
pendingGap = true
|
||||
}
|
||||
if (ready && pendingGap) {
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
)
|
||||
}
|
||||
pendingGap = false
|
||||
}
|
||||
connectionWasReady = ready
|
||||
}
|
||||
|
||||
fun beginTurn(sessionId: String?, eventScopeKey: String?, turnId: String) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId == turnId) return
|
||||
activeTurnId = turnId
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun onEvent(
|
||||
sessionId: String?,
|
||||
eventScopeKey: String?,
|
||||
turnId: String,
|
||||
event: GatewaySubagentEvent,
|
||||
profile: String? = null,
|
||||
) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId != turnId) return
|
||||
|
||||
val taskIndex = event.taskIndex.coerceAtLeast(0)
|
||||
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
|
||||
?: event.childSessionId?.takeIf(String::isNotBlank)
|
||||
val identityMatch = eventIdentity?.let { identity ->
|
||||
_activities.value.firstOrNull {
|
||||
it.subagentId == identity || it.childSessionId == identity
|
||||
}
|
||||
}
|
||||
val compatibleIndexMatches = _activities.value.filter { activity ->
|
||||
activity.taskIndex == taskIndex &&
|
||||
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
|
||||
event.subagentId == activity.subagentId) &&
|
||||
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
|
||||
event.childSessionId == activity.childSessionId) &&
|
||||
(event.parentId.isNullOrBlank() || activity.parentId.isNullOrBlank() ||
|
||||
event.parentId == activity.parentId) &&
|
||||
(event.depth == null || activity.depth == null || event.depth == activity.depth)
|
||||
}
|
||||
val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
|
||||
if (
|
||||
current?.isTerminal == true &&
|
||||
event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
event.phase != GatewaySubagentEvent.Phase.START
|
||||
) return
|
||||
|
||||
val base = if (current?.isTerminal == true) null else current
|
||||
val phase = event.toActivityPhase()
|
||||
val goal = sanitize(event.goal, MAX_GOAL_CHARS)
|
||||
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
val summary = sanitize(event.summary, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
val toolName = sanitize(event.toolName, MAX_TOOL_NAME_CHARS).ifBlank { null }
|
||||
val eventRow = SubagentActivityEvent(
|
||||
sequence = sequence++,
|
||||
kind = when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
-> SubagentActivityEventKind.STARTED
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.PROGRESS,
|
||||
-> SubagentActivityEventKind.UPDATE
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityEventKind.TOOL
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> SubagentActivityEventKind.COMPLETED
|
||||
},
|
||||
text = if (event.phase == GatewaySubagentEvent.Phase.COMPLETE) summary else preview,
|
||||
toolName = toolName,
|
||||
phase = phase,
|
||||
observedAtMillis = clock(),
|
||||
)
|
||||
|
||||
val priorEvents = base?.events.orEmpty()
|
||||
val coalesced = eventRow.kind == SubagentActivityEventKind.UPDATE &&
|
||||
priorEvents.lastOrNull()?.let { previous ->
|
||||
previous.kind == eventRow.kind && previous.text == eventRow.text
|
||||
} == true
|
||||
val appended = if (coalesced) priorEvents else priorEvents + eventRow
|
||||
val (boundedEvents, truncated) = boundEvents(appended)
|
||||
val next = SubagentActivity(
|
||||
laneId = base?.laneId ?: laneSequence++,
|
||||
turnId = turnId,
|
||||
taskIndex = taskIndex,
|
||||
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
|
||||
goal = goal.ifBlank { base?.goal.orEmpty() },
|
||||
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
|
||||
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
|
||||
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
|
||||
depth = event.depth ?: base?.depth,
|
||||
model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
|
||||
profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
|
||||
phase = phase,
|
||||
summary = summary ?: base?.summary,
|
||||
durationSeconds = event.durationSeconds ?: base?.durationSeconds,
|
||||
events = boundedEvents,
|
||||
truncated = base?.truncated == true || truncated,
|
||||
partialAfterGap = base?.partialAfterGap == true,
|
||||
revision = (base?.revision ?: 0L) + 1,
|
||||
)
|
||||
_activities.value = (_activities.value.filterNot { it.stableKey == next.stableKey } + next)
|
||||
.sortedWith(compareBy<SubagentActivity> { it.isTerminal }.thenBy { it.taskIndex })
|
||||
}
|
||||
|
||||
fun endTurn(turnId: String) {
|
||||
if (activeTurnId != turnId) return
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
phase = SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun boundEvents(
|
||||
events: List<SubagentActivityEvent>,
|
||||
): Pair<List<SubagentActivityEvent>, Boolean> {
|
||||
val bounded = events.toMutableList()
|
||||
var truncated = false
|
||||
fun charCount(): Int = bounded.sumOf { (it.text?.length ?: 0) + (it.toolName?.length ?: 0) }
|
||||
while (bounded.size > MAX_EVENTS_PER_CHILD || charCount() > MAX_CHARS_PER_CHILD) {
|
||||
if (bounded.size <= 1) break
|
||||
bounded.removeAt(if (bounded.first().kind == SubagentActivityEventKind.STARTED) 1 else 0)
|
||||
truncated = true
|
||||
}
|
||||
return bounded to truncated
|
||||
}
|
||||
}
|
||||
|
||||
private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when (phase) {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START -> SubagentActivityPhase.STARTED
|
||||
GatewaySubagentEvent.Phase.THINKING -> SubagentActivityPhase.THINKING
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
|
||||
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
|
||||
"failed", "error" -> SubagentActivityPhase.FAILED
|
||||
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
|
||||
else -> SubagentActivityPhase.COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
private val ANSI_ESCAPE = Regex("\\u001B(?:\\[[0-?]*[ -/]*[@-~]|\\][^\\u0007]*(?:\\u0007|\\u001B\\\\))")
|
||||
|
||||
private fun sanitize(value: String?, maxChars: Int): String = value.orEmpty()
|
||||
.replace(ANSI_ESCAPE, "")
|
||||
.filter { it == '\n' || it == '\t' || it >= ' ' }
|
||||
.trim()
|
||||
.take(maxChars)
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChildWatch
|
||||
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal data class SubagentChildPreview(
|
||||
val activityKey: String,
|
||||
val parentSessionId: String,
|
||||
val parentScopeKey: String?,
|
||||
/** null while opening, false for a truthful parent-event fallback. */
|
||||
val childWatchAvailable: Boolean? = null,
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val running: Boolean = false,
|
||||
val status: String? = null,
|
||||
val historyTruncated: Boolean = false,
|
||||
val partialAfterGap: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
internal class SubagentChildPreviewController(
|
||||
private val scope: CoroutineScope,
|
||||
private val openWatch: suspend (
|
||||
GatewayChatClient,
|
||||
String,
|
||||
String?,
|
||||
GatewayTurnCallbacks,
|
||||
) -> Result<GatewayChildWatch> = { client, sessionId, profile, callbacks ->
|
||||
client.openChildWatch(sessionId, profile, callbacks)
|
||||
},
|
||||
private val closeWatch: suspend (GatewayChatClient, GatewayChildWatch) -> Result<Unit> =
|
||||
{ client, watch -> client.closeChildWatch(watch) },
|
||||
) {
|
||||
private class WatchContext(
|
||||
val activity: SubagentActivity,
|
||||
val client: GatewayChatClient,
|
||||
val parentSessionId: String,
|
||||
val parentScopeKey: String?,
|
||||
val generation: Long,
|
||||
val stillOwnsParent: () -> Boolean,
|
||||
) {
|
||||
val handler = ChatHandler()
|
||||
var messageOrdinal = 0
|
||||
var messageId = "child-watch-$generation-0"
|
||||
var contentTruncated = false
|
||||
var initialized = false
|
||||
var pendingOverflow = false
|
||||
val pendingCallbacks = mutableListOf<() -> Unit>()
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<SubagentChildPreview?>(null)
|
||||
val state: StateFlow<SubagentChildPreview?> = _state.asStateFlow()
|
||||
private val generation = AtomicLong(0)
|
||||
private var watch: GatewayChildWatch? = null
|
||||
private var watchClient: GatewayChatClient? = null
|
||||
|
||||
fun open(
|
||||
activity: SubagentActivity,
|
||||
client: GatewayChatClient?,
|
||||
parentSessionId: String,
|
||||
parentScopeKey: String?,
|
||||
gatewayRouteActive: Boolean,
|
||||
stillOwnsParent: () -> Boolean,
|
||||
) {
|
||||
if (isAlreadyOpen(activity.stableKey, parentSessionId, parentScopeKey)) return
|
||||
close(clearState = false)
|
||||
if (activity.childSessionId.isNullOrBlank() || client == null || !gatewayRouteActive) {
|
||||
_state.value = fallbackState(activity, parentSessionId, parentScopeKey)
|
||||
return
|
||||
}
|
||||
|
||||
val context = WatchContext(
|
||||
activity = activity,
|
||||
client = client,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
generation = generation.incrementAndGet(),
|
||||
stillOwnsParent = stillOwnsParent,
|
||||
)
|
||||
_state.value = baseState(context)
|
||||
// Once upstream creates a lazy watcher, only the resume acknowledgement
|
||||
// reveals the live id needed to close it. Let a dismissed open finish;
|
||||
// generation invalidation makes [acceptOpenedWatch] close the late handle.
|
||||
scope.launch { openWatch(context) }
|
||||
}
|
||||
|
||||
fun close() = close(clearState = true)
|
||||
|
||||
private suspend fun openWatch(context: WatchContext) {
|
||||
openWatch(
|
||||
context.client,
|
||||
context.activity.childSessionId.orEmpty(),
|
||||
context.activity.profile,
|
||||
callbacks(context),
|
||||
).fold(
|
||||
onSuccess = { opened -> acceptOpenedWatch(context, opened) },
|
||||
onFailure = { error -> publishOpenFailure(context, error.message) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun acceptOpenedWatch(context: WatchContext, opened: GatewayChildWatch) {
|
||||
if (!owns(context)) {
|
||||
closeWatch(context.client, opened)
|
||||
return
|
||||
}
|
||||
watch = opened
|
||||
watchClient = context.client
|
||||
context.handler.setSessionId(opened.storedSessionId)
|
||||
context.handler.loadMessageHistory(opened.messages)
|
||||
context.contentTruncated = context.handler.boundReadOnlyPreview()
|
||||
val pending = synchronized(context.pendingCallbacks) {
|
||||
context.initialized = true
|
||||
context.pendingCallbacks.toList().also { context.pendingCallbacks.clear() }
|
||||
}
|
||||
publish(
|
||||
context = context,
|
||||
running = opened.running,
|
||||
status = opened.status,
|
||||
historyTruncated = opened.historyTruncated || context.contentTruncated,
|
||||
partial = context.activity.partialAfterGap || context.pendingOverflow,
|
||||
)
|
||||
pending.forEach { callback -> if (owns(context)) callback() }
|
||||
}
|
||||
|
||||
private fun callbacks(context: WatchContext) = GatewayTurnCallbacks(
|
||||
onSessionId = { },
|
||||
onStart = { runOrQueue(context) { startMessage(context) } },
|
||||
onTextDelta = { delta ->
|
||||
runOrQueue(context) { mutate(context) { onTextDelta(context.messageId, delta) } }
|
||||
},
|
||||
onThinkingDelta = { delta ->
|
||||
runOrQueue(context) { mutate(context) { onThinkingDelta(context.messageId, delta) } }
|
||||
},
|
||||
onToolCallStart = { id, name, preview ->
|
||||
runOrQueue(context) {
|
||||
mutate(context) { onToolCallStart(context.messageId, id, name, preview) }
|
||||
}
|
||||
},
|
||||
onToolCallDone = { id, preview ->
|
||||
runOrQueue(context) {
|
||||
mutate(context, ensureMessage = false) {
|
||||
onToolCallComplete(context.messageId, id, preview)
|
||||
}
|
||||
}
|
||||
},
|
||||
onToolCallFailed = { id, error ->
|
||||
runOrQueue(context) {
|
||||
mutate(context, ensureMessage = false) {
|
||||
onToolCallFailed(context.messageId, id, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTurnComplete = {
|
||||
runOrQueue(context) {
|
||||
if (owns(context)) context.handler.onTurnComplete(context.messageId)
|
||||
}
|
||||
},
|
||||
onReconcileRequired = { runOrQueue(context) { publish(context, partial = true) } },
|
||||
onComplete = { runOrQueue(context) { complete(context) } },
|
||||
onUsage = { },
|
||||
onError = { message ->
|
||||
runOrQueue(context) {
|
||||
publish(context, running = false, partial = true, error = message)
|
||||
}
|
||||
},
|
||||
onToolGenerating = { },
|
||||
onSubagentEvent = { event ->
|
||||
runOrQueue(context) { mutate(context) { onSubagentEvent(context.messageId, event) } }
|
||||
},
|
||||
onMoaReference = { },
|
||||
onInteractionRequest = { },
|
||||
onInteractionExpired = { },
|
||||
onResumeFailure = { message ->
|
||||
runOrQueue(context) {
|
||||
publish(context, running = false, partial = true, error = message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun runOrQueue(context: WatchContext, callback: () -> Unit) {
|
||||
if (!owns(context)) return
|
||||
val runNow = synchronized(context.pendingCallbacks) {
|
||||
if (context.initialized) {
|
||||
true
|
||||
} else {
|
||||
if (context.pendingCallbacks.size >= 256) {
|
||||
context.pendingCallbacks.removeAt(0)
|
||||
context.pendingOverflow = true
|
||||
}
|
||||
context.pendingCallbacks += callback
|
||||
false
|
||||
}
|
||||
}
|
||||
if (runNow) callback()
|
||||
}
|
||||
|
||||
private fun startMessage(context: WatchContext) {
|
||||
if (!owns(context)) return
|
||||
context.messageId = "child-watch-${context.generation}-${context.messageOrdinal++}"
|
||||
ensureLiveMessage(context)
|
||||
publish(context)
|
||||
}
|
||||
|
||||
private inline fun mutate(
|
||||
context: WatchContext,
|
||||
ensureMessage: Boolean = true,
|
||||
mutation: ChatHandler.() -> Unit,
|
||||
) {
|
||||
if (!owns(context)) return
|
||||
if (ensureMessage) ensureLiveMessage(context)
|
||||
context.handler.mutation()
|
||||
context.contentTruncated = context.handler.boundReadOnlyPreview() || context.contentTruncated
|
||||
publish(context)
|
||||
}
|
||||
|
||||
private fun complete(context: WatchContext) {
|
||||
if (!owns(context)) return
|
||||
context.handler.onStreamComplete(context.messageId)
|
||||
// The child mirror's message.complete omits failed/interrupted status.
|
||||
// Keep this neutral; the parent activity lane is authoritative.
|
||||
publish(context, running = false)
|
||||
}
|
||||
|
||||
private fun ensureLiveMessage(context: WatchContext) {
|
||||
if (context.handler.messages.value.any { it.id == context.messageId }) return
|
||||
context.handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = context.messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish(
|
||||
context: WatchContext,
|
||||
running: Boolean = true,
|
||||
status: String? = _state.value?.status,
|
||||
historyTruncated: Boolean =
|
||||
_state.value?.historyTruncated == true || context.contentTruncated,
|
||||
partial: Boolean = _state.value?.partialAfterGap == true,
|
||||
error: String? = null,
|
||||
) {
|
||||
if (!owns(context)) return
|
||||
_state.value = baseState(context).copy(
|
||||
childWatchAvailable = true,
|
||||
messages = context.handler.messages.value.takeLast(200),
|
||||
running = running,
|
||||
status = status,
|
||||
historyTruncated = historyTruncated,
|
||||
partialAfterGap = partial,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishOpenFailure(context: WatchContext, message: String?) {
|
||||
if (!owns(context)) return
|
||||
_state.value = fallbackState(
|
||||
context.activity,
|
||||
context.parentSessionId,
|
||||
context.parentScopeKey,
|
||||
).copy(error = message)
|
||||
}
|
||||
|
||||
private fun owns(context: WatchContext): Boolean =
|
||||
generation.get() == context.generation && context.stillOwnsParent()
|
||||
|
||||
private fun isAlreadyOpen(key: String, sessionId: String, scopeKey: String?): Boolean =
|
||||
_state.value?.let {
|
||||
it.activityKey == key &&
|
||||
it.parentSessionId == sessionId &&
|
||||
it.parentScopeKey == scopeKey &&
|
||||
it.error.isNullOrBlank() &&
|
||||
it.childWatchAvailable != false
|
||||
} == true
|
||||
|
||||
private fun baseState(context: WatchContext) = SubagentChildPreview(
|
||||
activityKey = context.activity.stableKey,
|
||||
parentSessionId = context.parentSessionId,
|
||||
parentScopeKey = context.parentScopeKey,
|
||||
)
|
||||
|
||||
private fun fallbackState(
|
||||
activity: SubagentActivity,
|
||||
parentSessionId: String,
|
||||
parentScopeKey: String?,
|
||||
) = SubagentChildPreview(
|
||||
activityKey = activity.stableKey,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
childWatchAvailable = false,
|
||||
partialAfterGap = activity.partialAfterGap,
|
||||
)
|
||||
|
||||
private fun close(clearState: Boolean) {
|
||||
generation.incrementAndGet()
|
||||
val closingWatch = watch
|
||||
val closingClient = watchClient
|
||||
watch = null
|
||||
watchClient = null
|
||||
if (closingWatch != null && closingClient != null) {
|
||||
scope.launch { closeWatch(closingClient, closingWatch) }
|
||||
}
|
||||
if (clearState) _state.value = null
|
||||
}
|
||||
}
|
||||
@@ -155,14 +155,6 @@ class ProfileController(
|
||||
private val avatarRefreshGeneration = AtomicLong(0L)
|
||||
private val petRefreshGeneration = AtomicLong(0L)
|
||||
private val petGalleryGeneration = AtomicLong(0L)
|
||||
private val sessionRestoreGeneration = AtomicLong(0L)
|
||||
private val freshDraftScopes = ConcurrentHashMap.newKeySet<SessionScopeKey>()
|
||||
|
||||
private data class SessionScopeKey(
|
||||
val connectionId: String,
|
||||
val profileName: String?,
|
||||
val transport: SessionTransport,
|
||||
)
|
||||
private val petThumbnailRequests = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val agentProfiles: StateFlow<List<Profile>> = combine(
|
||||
@@ -1471,48 +1463,10 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a user-requested empty draft for one exact conversation scope.
|
||||
*
|
||||
* The in-memory marker fences any stored-session read that was already in
|
||||
* flight, while clearing the exact transport slot makes the draft survive a
|
||||
* process restart. Other profiles, connections, transports, and the server's
|
||||
* actual session/history rows are untouched.
|
||||
*/
|
||||
fun markFreshDraft(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) {
|
||||
val scopeKey = SessionScopeKey(connectionId, profileName, transport)
|
||||
freshDraftScopes += scopeKey
|
||||
sessionRestoreGeneration.incrementAndGet()
|
||||
if (
|
||||
activeConnectionId.value == connectionId &&
|
||||
_selectedProfile.value?.name == profileName
|
||||
) {
|
||||
setLastSessionId(null)
|
||||
}
|
||||
scope.launch {
|
||||
profileSessionStore.setSessionId(connectionId, profileName, transport, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** A real session supersedes the fresh-draft marker for its exact scope. */
|
||||
fun markSessionPersisted(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) {
|
||||
freshDraftScopes -= SessionScopeKey(connectionId, profileName, transport)
|
||||
sessionRestoreGeneration.incrementAndGet()
|
||||
}
|
||||
|
||||
fun refreshLastSessionForProfile(
|
||||
connectionId: String?,
|
||||
profileName: String?,
|
||||
) {
|
||||
val generation = sessionRestoreGeneration.incrementAndGet()
|
||||
setLastSessionId(null)
|
||||
if (connectionId == null) return
|
||||
// Defer until the active transport is known — restoring an id the
|
||||
@@ -1524,8 +1478,6 @@ class ProfileController(
|
||||
// `default` (or any other name), but its last-session slot must remain
|
||||
// distinct from explicitly selecting that named profile.
|
||||
val sessionProfileName = profileName
|
||||
val scopeKey = SessionScopeKey(connectionId, sessionProfileName, transport)
|
||||
if (scopeKey in freshDraftScopes) return
|
||||
scope.launch {
|
||||
val profileScoped = profileSessionStore
|
||||
.sessionIdFlow(connectionId, sessionProfileName, transport)
|
||||
@@ -1541,8 +1493,6 @@ class ProfileController(
|
||||
null
|
||||
}
|
||||
if (
|
||||
sessionRestoreGeneration.get() == generation &&
|
||||
scopeKey !in freshDraftScopes &&
|
||||
activeConnectionId.value == connectionId &&
|
||||
_selectedProfile.value?.name == profileName &&
|
||||
activeSessionTransport() == transport
|
||||
|
||||
@@ -4280,4 +4280,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d alteração</item><item quantity="other">%1$d alterações</item></plurals>
|
||||
<string name="settings_git_workspace">Espaço de trabalho Git</string>
|
||||
<string name="settings_git_workspace_desc">Revise alterações, branches, commits e remotos</string>
|
||||
<string name="current_chat_activity_title">Atividade atual do chat</string>
|
||||
<string name="current_chat_activity_subtitle">Detalhes ao vivo e somente leitura deste chat</string>
|
||||
<string name="current_chat_activity_open">Visualizar atividade atual do chat</string>
|
||||
<string name="current_chat_activity_summary">%1$d agentes · %2$d processos</string>
|
||||
<string name="current_chat_activity_close">Fechar prévia da atividade</string>
|
||||
<string name="current_chat_activity_empty">Nenhuma atividade atual neste chat</string>
|
||||
<string name="current_chat_activity_latest">Mais recente</string>
|
||||
<string name="agent_activity_section">Atividade ao vivo dos agentes</string>
|
||||
<string name="agent_activity_disclosure">Atualizações recebidas por este chat. O histórico completo do agente filho pode não estar disponível.</string>
|
||||
<string name="agent_activity_fallback">Agente %d</string>
|
||||
<string name="agent_activity_task_position">%1$d de %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agente %3$d de %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Atividade anterior omitida</string>
|
||||
<string name="agent_activity_partial">Atualizações ao vivo retomadas. Pode faltar atividade do período offline.</string>
|
||||
<string name="agent_activity_event_started">Iniciado</string>
|
||||
<string name="agent_activity_event_update">Atualização</string>
|
||||
<string name="agent_activity_event_tool">Prévia da ferramenta</string>
|
||||
<string name="agent_activity_status_started">Iniciando</string>
|
||||
<string name="agent_activity_status_thinking">Pensando</string>
|
||||
<string name="agent_activity_status_tool">Usando uma ferramenta</string>
|
||||
<string name="agent_activity_status_progress">Trabalhando</string>
|
||||
<string name="agent_activity_status_completed">Concluído</string>
|
||||
<string name="agent_activity_status_failed">Falhou</string>
|
||||
<string name="agent_activity_status_interrupted">Interrompido</string>
|
||||
<string name="agent_activity_status_unavailable">Estado final indisponível</string>
|
||||
<string name="agent_activity_child_loading">Abrindo histórico filho somente leitura…</string>
|
||||
<string name="agent_activity_child_unavailable">O histórico filho não está disponível nesta versão ou rota do Hermes. A atividade da sessão principal é mostrada acima.</string>
|
||||
<string name="agent_activity_child_live">Histórico filho · atualizações ao vivo</string>
|
||||
<string name="agent_activity_child_history">Histórico filho · somente leitura</string>
|
||||
<string name="agent_activity_child_truncated">Atividade filha recente exibida · detalhes antigos ou muito grandes foram omitidos</string>
|
||||
<string name="agent_activity_child_role_task">Tarefa</string>
|
||||
<string name="agent_activity_child_role_agent">Agente</string>
|
||||
<string name="agent_activity_child_role_system">Sistema</string>
|
||||
</resources>
|
||||
|
||||
@@ -4362,4 +4362,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="other">%1$d 个更改</item></plurals>
|
||||
<string name="settings_git_workspace">Git 工作区</string>
|
||||
<string name="settings_git_workspace_desc">查看更改、分支、提交和远程仓库</string>
|
||||
<string name="current_chat_activity_title">当前聊天活动</string>
|
||||
<string name="current_chat_activity_subtitle">此聊天中的只读实时详情</string>
|
||||
<string name="current_chat_activity_open">预览当前聊天活动</string>
|
||||
<string name="current_chat_activity_summary">%1$d 个代理 · %2$d 个进程</string>
|
||||
<string name="current_chat_activity_close">关闭活动预览</string>
|
||||
<string name="current_chat_activity_empty">此聊天当前没有活动</string>
|
||||
<string name="current_chat_activity_latest">最新</string>
|
||||
<string name="agent_activity_section">实时代理活动</string>
|
||||
<string name="agent_activity_disclosure">此聊天接收到的更新。可能无法获取子代理的完整历史记录。</string>
|
||||
<string name="agent_activity_fallback">代理 %d</string>
|
||||
<string name="agent_activity_task_position">第 %1$d 个,共 %2$d 个 · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s,%2$s,第 %3$d 个代理,共 %4$d 个</string>
|
||||
<string name="agent_activity_duration">%1$.1f 秒</string>
|
||||
<string name="agent_activity_older_omitted">已省略较早的活动</string>
|
||||
<string name="agent_activity_partial">实时更新已恢复。离线期间的活动可能缺失。</string>
|
||||
<string name="agent_activity_event_started">已开始</string>
|
||||
<string name="agent_activity_event_update">更新</string>
|
||||
<string name="agent_activity_event_tool">工具预览</string>
|
||||
<string name="agent_activity_status_started">正在启动</string>
|
||||
<string name="agent_activity_status_thinking">正在思考</string>
|
||||
<string name="agent_activity_status_tool">正在使用工具</string>
|
||||
<string name="agent_activity_status_progress">正在工作</string>
|
||||
<string name="agent_activity_status_completed">已完成</string>
|
||||
<string name="agent_activity_status_failed">失败</string>
|
||||
<string name="agent_activity_status_interrupted">已中断</string>
|
||||
<string name="agent_activity_status_unavailable">最终状态不可用</string>
|
||||
<string name="agent_activity_child_loading">正在打开只读子历史记录…</string>
|
||||
<string name="agent_activity_child_unavailable">此 Hermes 版本或路由不提供子历史记录。上方显示父会话活动。</string>
|
||||
<string name="agent_activity_child_live">子历史记录 · 实时更新</string>
|
||||
<string name="agent_activity_child_history">子历史记录 · 只读</string>
|
||||
<string name="agent_activity_child_truncated">显示近期子活动 · 已省略较早或过大的详情</string>
|
||||
<string name="agent_activity_child_role_task">任务</string>
|
||||
<string name="agent_activity_child_role_agent">代理</string>
|
||||
<string name="agent_activity_child_role_system">系统</string>
|
||||
</resources>
|
||||
|
||||
@@ -4437,4 +4437,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d Änderung</item><item quantity="other">%1$d Änderungen</item></plurals>
|
||||
<string name="settings_git_workspace">Git-Arbeitsbereich</string>
|
||||
<string name="settings_git_workspace_desc">Änderungen, Branches, Commits und Remotes prüfen</string>
|
||||
<string name="current_chat_activity_title">Aktuelle Chat-Aktivität</string>
|
||||
<string name="current_chat_activity_subtitle">Schreibgeschützte Live-Details aus diesem Chat</string>
|
||||
<string name="current_chat_activity_open">Aktuelle Chat-Aktivität ansehen</string>
|
||||
<string name="current_chat_activity_summary">%1$d Agenten · %2$d Prozesse</string>
|
||||
<string name="current_chat_activity_close">Aktivitätsvorschau schließen</string>
|
||||
<string name="current_chat_activity_empty">Keine aktuelle Aktivität in diesem Chat</string>
|
||||
<string name="current_chat_activity_latest">Neueste</string>
|
||||
<string name="agent_activity_section">Live-Agentenaktivität</string>
|
||||
<string name="agent_activity_disclosure">Von diesem Chat empfangene Updates. Der vollständige Verlauf des untergeordneten Agenten ist möglicherweise nicht verfügbar.</string>
|
||||
<string name="agent_activity_fallback">Agent %d</string>
|
||||
<string name="agent_activity_task_position">%1$d von %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, Agent %3$d von %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Ältere Aktivität ausgelassen</string>
|
||||
<string name="agent_activity_partial">Live-Updates fortgesetzt. Aktivität während der Offlinezeit kann fehlen.</string>
|
||||
<string name="agent_activity_event_started">Gestartet</string>
|
||||
<string name="agent_activity_event_update">Update</string>
|
||||
<string name="agent_activity_event_tool">Werkzeugvorschau</string>
|
||||
<string name="agent_activity_status_started">Wird gestartet</string>
|
||||
<string name="agent_activity_status_thinking">Denkt nach</string>
|
||||
<string name="agent_activity_status_tool">Verwendet ein Werkzeug</string>
|
||||
<string name="agent_activity_status_progress">Arbeitet</string>
|
||||
<string name="agent_activity_status_completed">Abgeschlossen</string>
|
||||
<string name="agent_activity_status_failed">Fehlgeschlagen</string>
|
||||
<string name="agent_activity_status_interrupted">Unterbrochen</string>
|
||||
<string name="agent_activity_status_unavailable">Endstatus nicht verfügbar</string>
|
||||
<string name="agent_activity_child_loading">Schreibgeschützter untergeordneter Verlauf wird geöffnet…</string>
|
||||
<string name="agent_activity_child_unavailable">Der untergeordnete Verlauf ist in dieser Hermes-Version oder Route nicht verfügbar. Die Aktivität der übergeordneten Sitzung wird oben angezeigt.</string>
|
||||
<string name="agent_activity_child_live">Untergeordneter Verlauf · Live-Updates</string>
|
||||
<string name="agent_activity_child_history">Untergeordneter Verlauf · schreibgeschützt</string>
|
||||
<string name="agent_activity_child_truncated">Neueste untergeordnete Aktivität angezeigt · ältere oder zu große Details ausgelassen</string>
|
||||
<string name="agent_activity_child_role_task">Aufgabe</string>
|
||||
<string name="agent_activity_child_role_agent">Agent</string>
|
||||
<string name="agent_activity_child_role_system">System</string>
|
||||
</resources>
|
||||
|
||||
@@ -4128,4 +4128,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d cambio</item><item quantity="other">%1$d cambios</item></plurals>
|
||||
<string name="settings_git_workspace">Espacio de Git</string>
|
||||
<string name="settings_git_workspace_desc">Revisa cambios, ramas, commits y remotos</string>
|
||||
<string name="current_chat_activity_title">Actividad actual del chat</string>
|
||||
<string name="current_chat_activity_subtitle">Detalles en vivo de solo lectura de este chat</string>
|
||||
<string name="current_chat_activity_open">Ver la actividad actual del chat</string>
|
||||
<string name="current_chat_activity_summary">%1$d agentes · %2$d procesos</string>
|
||||
<string name="current_chat_activity_close">Cerrar vista previa de actividad</string>
|
||||
<string name="current_chat_activity_empty">No hay actividad actual en este chat</string>
|
||||
<string name="current_chat_activity_latest">Más reciente</string>
|
||||
<string name="agent_activity_section">Actividad de agentes en vivo</string>
|
||||
<string name="agent_activity_disclosure">Actualizaciones recibidas por este chat. Es posible que el historial completo del agente secundario no esté disponible.</string>
|
||||
<string name="agent_activity_fallback">Agente %d</string>
|
||||
<string name="agent_activity_task_position">%1$d de %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agente %3$d de %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Se omitió la actividad anterior</string>
|
||||
<string name="agent_activity_partial">Se reanudaron las actualizaciones en vivo. Puede faltar actividad mientras estaba sin conexión.</string>
|
||||
<string name="agent_activity_event_started">Iniciado</string>
|
||||
<string name="agent_activity_event_update">Actualización</string>
|
||||
<string name="agent_activity_event_tool">Vista previa de herramienta</string>
|
||||
<string name="agent_activity_status_started">Iniciando</string>
|
||||
<string name="agent_activity_status_thinking">Pensando</string>
|
||||
<string name="agent_activity_status_tool">Usando una herramienta</string>
|
||||
<string name="agent_activity_status_progress">Trabajando</string>
|
||||
<string name="agent_activity_status_completed">Completado</string>
|
||||
<string name="agent_activity_status_failed">Falló</string>
|
||||
<string name="agent_activity_status_interrupted">Interrumpido</string>
|
||||
<string name="agent_activity_status_unavailable">Estado final no disponible</string>
|
||||
<string name="agent_activity_child_loading">Abriendo el historial secundario de solo lectura…</string>
|
||||
<string name="agent_activity_child_unavailable">El historial secundario no está disponible en esta versión o ruta de Hermes. La actividad de la sesión principal se muestra arriba.</string>
|
||||
<string name="agent_activity_child_live">Historial secundario · actualizaciones en vivo</string>
|
||||
<string name="agent_activity_child_history">Historial secundario · solo lectura</string>
|
||||
<string name="agent_activity_child_truncated">Se muestra la actividad secundaria reciente · se omitieron detalles anteriores o demasiado grandes</string>
|
||||
<string name="agent_activity_child_role_task">Tarea</string>
|
||||
<string name="agent_activity_child_role_agent">Agente</string>
|
||||
<string name="agent_activity_child_role_system">Sistema</string>
|
||||
</resources>
|
||||
|
||||
@@ -4433,4 +4433,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="other">%1$d 件の変更</item></plurals>
|
||||
<string name="settings_git_workspace">Git ワークスペース</string>
|
||||
<string name="settings_git_workspace_desc">変更、ブランチ、コミット、リモートを確認</string>
|
||||
<string name="current_chat_activity_title">現在のチャットのアクティビティ</string>
|
||||
<string name="current_chat_activity_subtitle">このチャットからの読み取り専用ライブ詳細</string>
|
||||
<string name="current_chat_activity_open">現在のチャットのアクティビティを表示</string>
|
||||
<string name="current_chat_activity_summary">エージェント %1$d · プロセス %2$d</string>
|
||||
<string name="current_chat_activity_close">アクティビティのプレビューを閉じる</string>
|
||||
<string name="current_chat_activity_empty">このチャットに現在のアクティビティはありません</string>
|
||||
<string name="current_chat_activity_latest">最新</string>
|
||||
<string name="agent_activity_section">エージェントのライブアクティビティ</string>
|
||||
<string name="agent_activity_disclosure">このチャットが受信した更新です。子エージェントの完全な履歴は利用できない場合があります。</string>
|
||||
<string name="agent_activity_fallback">エージェント %d</string>
|
||||
<string name="agent_activity_task_position">%2$d 件中 %1$d 件目 · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s、%2$s、%4$d 件中 %3$d 件目のエージェント</string>
|
||||
<string name="agent_activity_duration">%1$.1f秒</string>
|
||||
<string name="agent_activity_older_omitted">古いアクティビティは省略されました</string>
|
||||
<string name="agent_activity_partial">ライブ更新を再開しました。オフライン中のアクティビティが欠けている場合があります。</string>
|
||||
<string name="agent_activity_event_started">開始</string>
|
||||
<string name="agent_activity_event_update">更新</string>
|
||||
<string name="agent_activity_event_tool">ツールのプレビュー</string>
|
||||
<string name="agent_activity_status_started">開始中</string>
|
||||
<string name="agent_activity_status_thinking">思考中</string>
|
||||
<string name="agent_activity_status_tool">ツールを使用中</string>
|
||||
<string name="agent_activity_status_progress">作業中</string>
|
||||
<string name="agent_activity_status_completed">完了</string>
|
||||
<string name="agent_activity_status_failed">失敗</string>
|
||||
<string name="agent_activity_status_interrupted">中断</string>
|
||||
<string name="agent_activity_status_unavailable">最終状態を確認できません</string>
|
||||
<string name="agent_activity_child_loading">読み取り専用の子履歴を開いています…</string>
|
||||
<string name="agent_activity_child_unavailable">この Hermes のバージョンまたはルートでは子履歴を利用できません。親セッションのアクティビティは上に表示されます。</string>
|
||||
<string name="agent_activity_child_live">子履歴 · ライブ更新</string>
|
||||
<string name="agent_activity_child_history">子履歴 · 読み取り専用</string>
|
||||
<string name="agent_activity_child_truncated">最近の子アクティビティを表示 · 古い詳細または大きすぎる詳細は省略されました</string>
|
||||
<string name="agent_activity_child_role_task">タスク</string>
|
||||
<string name="agent_activity_child_role_agent">エージェント</string>
|
||||
<string name="agent_activity_child_role_system">システム</string>
|
||||
</resources>
|
||||
|
||||
@@ -4174,4 +4174,38 @@
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d изменение</item><item quantity="few">%1$d изменения</item><item quantity="many">%1$d изменений</item><item quantity="other">%1$d изменения</item></plurals>
|
||||
<string name="settings_git_workspace">Рабочая область Git</string>
|
||||
<string name="settings_git_workspace_desc">Изменения, ветки, коммиты и удалённые репозитории</string>
|
||||
<string name="current_chat_activity_title">Текущая активность чата</string>
|
||||
<string name="current_chat_activity_subtitle">Доступные только для чтения сведения в реальном времени из этого чата</string>
|
||||
<string name="current_chat_activity_open">Просмотреть текущую активность чата</string>
|
||||
<string name="current_chat_activity_summary">Агенты: %1$d · процессы: %2$d</string>
|
||||
<string name="current_chat_activity_close">Закрыть просмотр активности</string>
|
||||
<string name="current_chat_activity_empty">В этом чате сейчас нет активности</string>
|
||||
<string name="current_chat_activity_latest">Последнее</string>
|
||||
<string name="agent_activity_section">Активность агентов в реальном времени</string>
|
||||
<string name="agent_activity_disclosure">Обновления, полученные этим чатом. Полная история дочернего агента может быть недоступна.</string>
|
||||
<string name="agent_activity_fallback">Агент %d</string>
|
||||
<string name="agent_activity_task_position">%1$d из %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, агент %3$d из %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1f с</string>
|
||||
<string name="agent_activity_older_omitted">Более ранняя активность опущена</string>
|
||||
<string name="agent_activity_partial">Обновления возобновлены. Активность во время отсутствия подключения может быть пропущена.</string>
|
||||
<string name="agent_activity_event_started">Запущено</string>
|
||||
<string name="agent_activity_event_update">Обновление</string>
|
||||
<string name="agent_activity_event_tool">Предпросмотр инструмента</string>
|
||||
<string name="agent_activity_status_started">Запуск</string>
|
||||
<string name="agent_activity_status_thinking">Размышляет</string>
|
||||
<string name="agent_activity_status_tool">Использует инструмент</string>
|
||||
<string name="agent_activity_status_progress">Работает</string>
|
||||
<string name="agent_activity_status_completed">Завершено</string>
|
||||
<string name="agent_activity_status_failed">Ошибка</string>
|
||||
<string name="agent_activity_status_interrupted">Прервано</string>
|
||||
<string name="agent_activity_status_unavailable">Итоговое состояние недоступно</string>
|
||||
<string name="agent_activity_child_loading">Открывается дочерняя история только для чтения…</string>
|
||||
<string name="agent_activity_child_unavailable">Дочерняя история недоступна в этой версии или маршруте Hermes. Активность родительского сеанса показана выше.</string>
|
||||
<string name="agent_activity_child_live">Дочерняя история · обновления в реальном времени</string>
|
||||
<string name="agent_activity_child_history">Дочерняя история · только чтение</string>
|
||||
<string name="agent_activity_child_truncated">Показана недавняя дочерняя активность · более ранние или слишком большие сведения опущены</string>
|
||||
<string name="agent_activity_child_role_task">Задача</string>
|
||||
<string name="agent_activity_child_role_agent">Агент</string>
|
||||
<string name="agent_activity_child_role_system">Система</string>
|
||||
</resources>
|
||||
|
||||
@@ -3735,6 +3735,40 @@
|
||||
<string name="tool_failed_a11y">Failed</string>
|
||||
<string name="background_process_count">Background · %1$d</string>
|
||||
<string name="background_processes_title">Background processes</string>
|
||||
<string name="current_chat_activity_title">Current chat activity</string>
|
||||
<string name="current_chat_activity_subtitle">Read-only live details from this chat</string>
|
||||
<string name="current_chat_activity_open">Preview current chat activity</string>
|
||||
<string name="current_chat_activity_summary">%1$d agents · %2$d processes</string>
|
||||
<string name="current_chat_activity_close">Close activity preview</string>
|
||||
<string name="current_chat_activity_empty">No current activity in this chat</string>
|
||||
<string name="current_chat_activity_latest">Latest</string>
|
||||
<string name="agent_activity_section">Live agent activity</string>
|
||||
<string name="agent_activity_disclosure">Updates received by this chat. Full child history may be unavailable.</string>
|
||||
<string name="agent_activity_fallback">Agent %d</string>
|
||||
<string name="agent_activity_task_position">%1$d of %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agent %3$d of %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Older activity omitted</string>
|
||||
<string name="agent_activity_partial">Live updates resumed. Activity while offline may be missing.</string>
|
||||
<string name="agent_activity_event_started">Started</string>
|
||||
<string name="agent_activity_event_update">Update</string>
|
||||
<string name="agent_activity_event_tool">Tool preview</string>
|
||||
<string name="agent_activity_status_started">Starting</string>
|
||||
<string name="agent_activity_status_thinking">Thinking</string>
|
||||
<string name="agent_activity_status_tool">Using a tool</string>
|
||||
<string name="agent_activity_status_progress">Working</string>
|
||||
<string name="agent_activity_status_completed">Completed</string>
|
||||
<string name="agent_activity_status_failed">Failed</string>
|
||||
<string name="agent_activity_status_interrupted">Interrupted</string>
|
||||
<string name="agent_activity_status_unavailable">Final state unavailable</string>
|
||||
<string name="agent_activity_child_loading">Opening read-only child history…</string>
|
||||
<string name="agent_activity_child_unavailable">Child transcript unavailable on this Hermes version or route. Parent-session activity is shown above.</string>
|
||||
<string name="agent_activity_child_live">Child history · live updates</string>
|
||||
<string name="agent_activity_child_history">Child history · read only</string>
|
||||
<string name="agent_activity_child_truncated">Recent child activity shown · older or oversized details omitted</string>
|
||||
<string name="agent_activity_child_role_task">Task</string>
|
||||
<string name="agent_activity_child_role_agent">Agent</string>
|
||||
<string name="agent_activity_child_role_system">System</string>
|
||||
<string name="background_processes_refresh_a11y">Refresh processes</string>
|
||||
<string name="background_processes_subtitle">Current chat · live output and recent results</string>
|
||||
<string name="background_processes_stop">Stop</string>
|
||||
|
||||
@@ -277,4 +277,40 @@ class AgentDisplayTest {
|
||||
assertEquals("conn::__server_default__", AgentDisplay.profileContextKey("conn", null))
|
||||
assertEquals("conn::mizu", AgentDisplay.profileContextKey("conn", "mizu"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseProfileContextKey_preservesRequestIdentityAndConnectionScope() {
|
||||
val serverDefault = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-a", null),
|
||||
)
|
||||
assertEquals("connection-a", serverDefault?.connectionId)
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, serverDefault?.profileKey)
|
||||
assertNull(serverDefault?.requestProfileName)
|
||||
|
||||
val literalDefault = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
)
|
||||
assertEquals("default", literalDefault?.profileKey)
|
||||
assertEquals("default", literalDefault?.requestProfileName)
|
||||
|
||||
val named = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-b", "mizu"),
|
||||
)
|
||||
assertEquals("connection-b", named?.connectionId)
|
||||
assertEquals("mizu", named?.requestProfileName)
|
||||
|
||||
val delimitedProfile = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-c", "team::writer"),
|
||||
)
|
||||
assertEquals("connection-c", delimitedProfile?.connectionId)
|
||||
assertEquals("team::writer", delimitedProfile?.requestProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseProfileContextKey_failsClosedForLegacyOrMalformedKeys() {
|
||||
assertNull(AgentDisplay.parseProfileContextKey("connection/profile-default"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey("connection-a::"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey("::default"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey(null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
assertEquals(checkpoint, store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkpointWithoutProfileKey_remainsReadableAsLegacyIdentity() = runTest {
|
||||
val current = sampleCheckpoint().copy(profileKey = "default")
|
||||
val legacyJson = Json.encodeToString(current)
|
||||
.replace("\"profileKey\":\"default\",", "")
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringPreferencesKey("chat_inflight_turn_checkpoint_v1")] = legacyJson
|
||||
}
|
||||
|
||||
val restored = store.read()
|
||||
assertEquals(current.contextKey, restored?.contextKey)
|
||||
assertNull(restored?.profileKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRunningSessions_mergeAndRemoveIndependently() = runTest {
|
||||
val first = sampleCheckpoint()
|
||||
@@ -106,7 +120,8 @@ class ChatTurnCheckpointStoreTest {
|
||||
}
|
||||
|
||||
private fun sampleCheckpoint() = ChatTurnCheckpoint(
|
||||
contextKey = "connection-a/profile-default",
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
profileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
sessionId = "stored-42",
|
||||
liveSessionId = "live-42",
|
||||
transport = "gateway",
|
||||
|
||||
@@ -17,8 +17,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class ProfileSessionStoreTest {
|
||||
|
||||
private val dataStore = InMemoryPreferencesDataStore()
|
||||
private val store = ProfileSessionStore(dataStore)
|
||||
private val store = ProfileSessionStore(InMemoryPreferencesDataStore())
|
||||
|
||||
@Test
|
||||
fun setAndGet_defaultProfileSession() = runBlocking {
|
||||
@@ -86,26 +85,6 @@ class ProfileSessionStoreTest {
|
||||
assertEquals("session-sse", store.sessionIdFlow("conn-1", "mizu", SSE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearedDraftSurvivesStoreRecreationAndPreservesOtherScopes() = runBlocking {
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "session-gw")
|
||||
store.setSessionId("conn-1", "mizu", SSE, "session-sse")
|
||||
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
|
||||
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, null)
|
||||
val restartedStore = ProfileSessionStore(dataStore)
|
||||
|
||||
assertNull(restartedStore.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
|
||||
assertEquals(
|
||||
"session-sse",
|
||||
restartedStore.sessionIdFlow("conn-1", "mizu", SSE).first(),
|
||||
)
|
||||
assertEquals(
|
||||
"session-other",
|
||||
restartedStore.sessionIdFlow("conn-2", "mizu", GATEWAY).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearConnectionRemovesAllProfilesAndTransportsForThatConnectionOnly() = runBlocking {
|
||||
store.setSessionId("conn-1", null, GATEWAY, "session-default")
|
||||
|
||||
+209
-1
@@ -74,6 +74,12 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var recoveryAssistant = ""
|
||||
|
||||
@Volatile
|
||||
var recoveryMessages: JsonArray = JsonArray(emptyList())
|
||||
|
||||
@Volatile
|
||||
var resumeEventsBeforeAck: List<Pair<String, JsonObject?>> = emptyList()
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
var recoveryInflightError: String? = null
|
||||
@@ -539,6 +545,16 @@ class GatewayClientHarness(
|
||||
put("error", buildJsonObject { put("message", "$method refused") })
|
||||
}
|
||||
}
|
||||
if (method == "session.resume" && result != null) {
|
||||
val liveId = (result["session_id"] as? JsonPrimitive)?.contentOrNull
|
||||
val events = resumeEventsBeforeAck
|
||||
resumeEventsBeforeAck = emptyList()
|
||||
if (!liveId.isNullOrBlank()) {
|
||||
events.forEach { (type, payload) ->
|
||||
webSocket.send(eventFrame(type, payload, liveId))
|
||||
}
|
||||
}
|
||||
}
|
||||
webSocket.send(reply.toString())
|
||||
}
|
||||
}
|
||||
@@ -549,6 +565,7 @@ class GatewayClientHarness(
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
put("messages", recoveryMessages)
|
||||
if (!omitSessionProfileMetadata || recoveryProject != null) {
|
||||
put("info", buildJsonObject {
|
||||
if (!omitSessionProfileMetadata) {
|
||||
@@ -760,13 +777,14 @@ class GatewayChatClientTest {
|
||||
rpcTimeoutMs: Long = 15_000L,
|
||||
promptSubmitTimeoutMs: Long = 1_800_000L,
|
||||
turnIdleTimeoutMs: Long = 180_000L,
|
||||
callbackDispatcher: (block: () -> Unit) -> Unit = { it() },
|
||||
) = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
callbackDispatcher = callbackDispatcher,
|
||||
onGatewayUnsupported = { unsupportedMarked = true },
|
||||
scope = scope,
|
||||
// Keep the mid-turn reconnect window short so `failed rejoin`
|
||||
@@ -2182,6 +2200,196 @@ class GatewayChatClientTest {
|
||||
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
|
||||
harness.sessionProfileOverride = "operator"
|
||||
harness.resumeLiveSessionIds["parent-session"] = "live-parent"
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
client.sessionProfileProvider = { "operator" }
|
||||
|
||||
assertTrue(client.prewarmAwait("parent-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
|
||||
harness.recoveryRunning = true
|
||||
harness.recoveryMessages = JsonArray(listOf(
|
||||
buildJsonObject { put("role", "user"); put("text", "old") },
|
||||
buildJsonObject { put("role", "assistant"); put("text", "recent") },
|
||||
buildJsonObject { put("role", "assistant"); put("text", "newest") },
|
||||
))
|
||||
val childRecorder = Recorder()
|
||||
val watch = client.openChildWatch(
|
||||
childSessionId = "child-session",
|
||||
profile = "operator",
|
||||
callbacks = childRecorder.callbacks,
|
||||
historyLimit = 2,
|
||||
).getOrThrow()
|
||||
|
||||
val resume = harness.awaitRpcCount("session.resume", 2).last()
|
||||
assertEquals("child-session", (resume["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("operator", (resume["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(true, (resume["lazy"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertEquals(true, (resume["close_on_disconnect"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertEquals("live-child", watch.liveSessionId)
|
||||
assertTrue(watch.running)
|
||||
assertTrue(watch.historyTruncated)
|
||||
assertEquals(listOf("recent", "newest"), watch.messages.map { it.contentText })
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-child"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"reasoning.delta",
|
||||
buildJsonObject { put("text", "checking") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "working") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "done") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(childRecorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("checking"), childRecorder.thinkingDeltas.toList())
|
||||
assertTrue(childRecorder.textDeltas.contains("working"))
|
||||
assertTrue(childRecorder.errors.isEmpty())
|
||||
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
val close = harness.awaitRpc("session.close")
|
||||
assertEquals("live-child", (close["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent child opens keep newest generation and stale close is harmless`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val recorders = listOf(Recorder(), Recorder())
|
||||
val opens = recorders.map { recorder ->
|
||||
async(Dispatchers.IO) {
|
||||
client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
}
|
||||
val watches = opens.map { it.await() }
|
||||
harness.awaitServerSocket()
|
||||
val stale = watches.minBy { it.generation }
|
||||
val newest = watches.maxBy { it.generation }
|
||||
|
||||
client.closeChildWatch(stale).getOrThrow()
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.close" })
|
||||
|
||||
client.closeChildWatch(newest).getOrThrow()
|
||||
val close = harness.awaitRpc("session.close")
|
||||
assertEquals("live-child", (close["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(1, recorders.sumOf { it.resumeFailures.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch replays terminal event that arrives before resume ack`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
harness.recoveryRunning = true
|
||||
harness.resumeEventsBeforeAck = listOf(
|
||||
"message.start" to null,
|
||||
"message.delta" to buildJsonObject { put("text", "pre-ack") },
|
||||
"message.complete" to buildJsonObject { put("text", "done") },
|
||||
)
|
||||
val recorder = Recorder()
|
||||
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
|
||||
assertEquals("live-child", watch.liveSessionId)
|
||||
assertFalse(watch.running)
|
||||
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(recorder.textDeltas.contains("pre-ack"))
|
||||
assertTrue(recorder.errors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed child watch close can be retried`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
harness.rpcErrors["session.close"] = 5000 to "busy"
|
||||
|
||||
assertTrue(client.closeChildWatch(watch).isFailure)
|
||||
harness.rpcErrors.remove("session.close")
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
|
||||
val closes = harness.awaitRpcCount("session.close", 2)
|
||||
assertEquals(2, closes.size)
|
||||
assertTrue(closes.all {
|
||||
(it["session_id"] as? JsonPrimitive)?.contentOrNull == "live-child"
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued child callback is dropped after exact watch closes`() = runBlocking {
|
||||
client.shutdown()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val queuedCallbacks = ConcurrentLinkedQueue<() -> Unit>()
|
||||
client = buildClient(callbackDispatcher = { queuedCallbacks += it })
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val recorder = Recorder()
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "stale") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
awaitCondition { queuedCallbacks.isNotEmpty() }
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
while (true) queuedCallbacks.poll()?.invoke() ?: break
|
||||
|
||||
assertTrue(recorder.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch history enforces total character bound`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
harness.recoveryMessages = JsonArray(listOf(
|
||||
buildJsonObject { put("role", "assistant"); put("text", "kept") },
|
||||
buildJsonObject {
|
||||
put("role", "assistant")
|
||||
put("text", "x".repeat(GatewayChatClient.MAX_CHILD_WATCH_HISTORY_CHARS + 1))
|
||||
},
|
||||
))
|
||||
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
|
||||
assertTrue(watch.historyTruncated)
|
||||
assertEquals(listOf("kept"), watch.messages.map { it.contentText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress session uses dedicated rpc and parses authoritative messages`() {
|
||||
val r = Recorder()
|
||||
|
||||
+16
-6
@@ -595,6 +595,10 @@ class GatewayEventMapperTest {
|
||||
fun `subagent lifecycle maps phases and fields`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"subagent.spawn_requested",
|
||||
obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17","child_session_id":"session-17","parent_id":"parent-child","depth":2,"model":"hermes-4"}"""),
|
||||
)
|
||||
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17"}"""))
|
||||
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
|
||||
mapper.onEvent(
|
||||
@@ -609,6 +613,7 @@ class GatewayEventMapperTest {
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.TOOL,
|
||||
@@ -617,17 +622,22 @@ class GatewayEventMapperTest {
|
||||
),
|
||||
r.subagentEvents.map { it.phase },
|
||||
)
|
||||
val start = r.subagentEvents[0]
|
||||
val spawn = r.subagentEvents[0]
|
||||
assertEquals("session-17", spawn.childSessionId)
|
||||
assertEquals("parent-child", spawn.parentId)
|
||||
assertEquals(2, spawn.depth)
|
||||
assertEquals("hermes-4", spawn.model)
|
||||
val start = r.subagentEvents[1]
|
||||
assertEquals(1, start.taskIndex)
|
||||
assertEquals(3, start.taskCount)
|
||||
assertEquals("research topic", start.goal)
|
||||
assertEquals("child-17", start.subagentId)
|
||||
assertEquals("hmm", r.subagentEvents[1].preview)
|
||||
val tool = r.subagentEvents[2]
|
||||
assertEquals("hmm", r.subagentEvents[2].preview)
|
||||
val tool = r.subagentEvents[3]
|
||||
assertEquals("web_search", tool.toolName)
|
||||
assertEquals("searching docs", tool.preview)
|
||||
assertEquals("halfway", r.subagentEvents[3].preview)
|
||||
val complete = r.subagentEvents[4]
|
||||
assertEquals("halfway", r.subagentEvents[4].preview)
|
||||
val complete = r.subagentEvents[5]
|
||||
assertEquals("completed", complete.status)
|
||||
assertEquals("found it", complete.summary)
|
||||
assertEquals(12.5, complete.durationSeconds!!, 0.001)
|
||||
@@ -1057,7 +1067,7 @@ class GatewayEventMapperTest {
|
||||
"message.complete", "error", "clarify.request", "approval.request",
|
||||
"sudo.request", "secret.request", "reasoning.available",
|
||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"tool.generating", "subagent.spawn_requested", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.progress", "moa.phase", "moa.aggregating",
|
||||
).forEach { type ->
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReadOnlyPreviewBoundsTest {
|
||||
@Test
|
||||
fun `child preview drops system rows results and oversized live content`() {
|
||||
val handler = ChatHandler()
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage("system", MessageRole.SYSTEM, "private system context", 1L),
|
||||
)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "child",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "x".repeat(20_000),
|
||||
timestamp = 2L,
|
||||
thinkingContent = "y".repeat(20_000),
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
name = "read_file",
|
||||
args = "a".repeat(5_000),
|
||||
result = "secret result",
|
||||
success = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(handler.boundReadOnlyPreview(maxTotalChars = 4_000, maxFieldChars = 2_000))
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(listOf("child"), messages.map(ChatMessage::id))
|
||||
assertTrue(messages.sumOf { it.content.length + it.thinkingContent.length } <= 4_000)
|
||||
assertTrue(messages.single().toolCalls.single().args.orEmpty().length <= 1_000)
|
||||
assertEquals(null, messages.single().toolCalls.single().result)
|
||||
assertFalse(messages.any { it.role == MessageRole.SYSTEM })
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class SubagentActivitySheetScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun concurrentLiveAgentsRenderAsReadOnlyActivity() {
|
||||
val activities = listOf(
|
||||
activity(0, 0, "Inspect Android event handling", SubagentActivityPhase.PROGRESS),
|
||||
activity(1, 1, "Review privacy boundaries", SubagentActivityPhase.INTERRUPTED),
|
||||
)
|
||||
val preview = SubagentChildPreview(
|
||||
activityKey = activities.first().stableKey,
|
||||
parentSessionId = "parent",
|
||||
parentScopeKey = "scope",
|
||||
childWatchAvailable = true,
|
||||
messages = listOf(
|
||||
ChatMessage("task", MessageRole.USER, "Trace the upstream child watch contract.", 1L),
|
||||
ChatMessage("answer", MessageRole.ASSISTANT, "The child-only history is available read-only.", 2L),
|
||||
),
|
||||
running = true,
|
||||
status = "streaming",
|
||||
)
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = emptyList(),
|
||||
subagentActivities = activities,
|
||||
subagentChildPreview = preview,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(),
|
||||
loading = false,
|
||||
stoppingProcessIds = emptySet(),
|
||||
onRefresh = {},
|
||||
onStop = {},
|
||||
onDismissProcess = {},
|
||||
onOpenSubagentChild = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription(
|
||||
"Inspect Android event handling, Working, agent 1 of 2",
|
||||
).performClick()
|
||||
compose.onNodeWithText("Child history · live updates").assertExists()
|
||||
compose.onNodeWithText("The child-only history is available read-only.").assertExists()
|
||||
compose.onNodeWithText("Stop").assertDoesNotExist()
|
||||
compose.onRoot().captureRoboImage("build/ui-regression/subagent-activity-sheet.png")
|
||||
}
|
||||
|
||||
private fun activity(
|
||||
laneId: Long,
|
||||
taskIndex: Int,
|
||||
goal: String,
|
||||
phase: SubagentActivityPhase,
|
||||
) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = taskIndex,
|
||||
taskCount = 2,
|
||||
goal = goal,
|
||||
phase = phase,
|
||||
childSessionId = "child-$taskIndex",
|
||||
profile = "default",
|
||||
events = listOf(
|
||||
SubagentActivityEvent(
|
||||
sequence = laneId,
|
||||
kind = SubagentActivityEventKind.UPDATE,
|
||||
text = if (phase == SubagentActivityPhase.INTERRUPTED) {
|
||||
"Stopped safely"
|
||||
} else {
|
||||
"Mapping Gateway events"
|
||||
},
|
||||
phase = phase,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -304,8 +304,9 @@ class SessionDrawerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new chat from all profiles keeps the current conversation owner`() {
|
||||
fun `new chat from all profiles requests an explicit default draft`() {
|
||||
var scopedNewChats = 0
|
||||
var defaultNewChats = 0
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
@@ -318,6 +319,7 @@ class SessionDrawerTest {
|
||||
onRefreshAllProfiles = {},
|
||||
onSelectProfileSession = { _, _ -> },
|
||||
onNewChat = { scopedNewChats++ },
|
||||
onNewDefaultChat = { defaultNewChats++ },
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
@@ -330,7 +332,8 @@ class SessionDrawerTest {
|
||||
compose.onNodeWithText("New Chat").performClick()
|
||||
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, scopedNewChats)
|
||||
assertEquals(0, scopedNewChats)
|
||||
assertEquals(1, defaultNewChats)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentActivityPreviewTest {
|
||||
@Test
|
||||
fun `supervised visibility excludes child history rows`() {
|
||||
val activity = activity(laneId = 0, taskIndex = 0)
|
||||
val preview = preview(activity, messageCount = 2)
|
||||
val expanded = setOf(activity.stableKey)
|
||||
|
||||
val full = subagentActivityItemCount(
|
||||
listOf(activity),
|
||||
expanded,
|
||||
SubagentPreviewVisibility(showChildHistory = true),
|
||||
preview,
|
||||
)
|
||||
val supervised = subagentActivityItemCount(
|
||||
listOf(activity),
|
||||
expanded,
|
||||
SubagentPreviewVisibility(showChildHistory = false),
|
||||
preview,
|
||||
)
|
||||
|
||||
assertTrue(full > supervised)
|
||||
assertEquals(4, full - supervised) // heading, two child messages, and tail anchor
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `follow target stays with selected child instead of final concurrent lane`() {
|
||||
val first = activity(laneId = 0, taskIndex = 0)
|
||||
val second = activity(laneId = 1, taskIndex = 1)
|
||||
val preview = preview(first, messageCount = 1)
|
||||
val target = subagentActivityFollowTarget(
|
||||
activities = listOf(first, second),
|
||||
expandedKeys = setOf(first.stableKey, second.stableKey),
|
||||
visibility = SubagentPreviewVisibility(),
|
||||
childPreview = preview,
|
||||
)
|
||||
val total = subagentActivityItemCount(
|
||||
listOf(first, second),
|
||||
setOf(first.stableKey, second.stableKey),
|
||||
SubagentPreviewVisibility(),
|
||||
preview,
|
||||
)
|
||||
|
||||
assertTrue(target < total - 1)
|
||||
}
|
||||
|
||||
private fun activity(laneId: Long, taskIndex: Int) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = taskIndex,
|
||||
taskCount = 2,
|
||||
goal = "Task $taskIndex",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
events = listOf(
|
||||
SubagentActivityEvent(
|
||||
sequence = 0,
|
||||
kind = SubagentActivityEventKind.UPDATE,
|
||||
text = "Working",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun preview(activity: SubagentActivity, messageCount: Int) = SubagentChildPreview(
|
||||
activityKey = activity.stableKey,
|
||||
parentSessionId = "parent",
|
||||
parentScopeKey = "scope",
|
||||
childWatchAvailable = true,
|
||||
messages = List(messageCount) { index ->
|
||||
ChatMessage("message-$index", MessageRole.ASSISTANT, "Text", index.toLong())
|
||||
},
|
||||
)
|
||||
}
|
||||
+199
-266
@@ -14,12 +14,9 @@ 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.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.ProactiveMessage
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
@@ -434,17 +431,8 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
|
||||
|
||||
viewModel.createNewChat()
|
||||
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
|
||||
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", owner.name),
|
||||
sessionId = "x-bot-session",
|
||||
)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
assertFalse(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertEquals(global.name, gatewayClient.sessionProfileProvider())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -659,258 +647,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals("default", gatewayClient.sessionProfileProvider())
|
||||
assertEquals(null, handler.currentSessionId.value)
|
||||
assertEquals("Hermes", handler.activeAgentName)
|
||||
assertEquals("cleared", persistedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftTransferKeepsNullableServerDefaultAndRejectsOldSessionRestore() {
|
||||
val named = Profile(name = "x-bot", model = "grok-4.3", description = "X Bot")
|
||||
var selected: Profile? = named
|
||||
var persistedDraft: Pair<String?, SessionTransport>? = null
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onFreshDraftSelected = { profileName, transport ->
|
||||
persistedDraft = profileName to transport
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
viewModel.createProfileChat(
|
||||
profileName = null,
|
||||
profile = null,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(selected)
|
||||
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertNull(viewModel.conversationBinding.value.profileName)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertEquals(null to SessionTransport.GATEWAY, persistedDraft)
|
||||
assertNull(gatewayClient.sessionProfileProvider())
|
||||
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", null),
|
||||
sessionId = "old-default-session",
|
||||
)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftTransferToNamedProfileCreatesInsteadOfResumingItsOldSession() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
var persistedDraft: Pair<String?, SessionTransport>? = null
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onFreshDraftSelected = { profileName, transport ->
|
||||
persistedDraft = profileName to transport
|
||||
}
|
||||
|
||||
viewModel.openProfileSession(
|
||||
profileName = alpha.name,
|
||||
profile = alpha,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", alpha.name),
|
||||
sessionId = "alpha-session",
|
||||
)
|
||||
viewModel.createNewChat()
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(beta, selected)
|
||||
assertEquals(beta.name to SessionTransport.GATEWAY, persistedDraft)
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
sessionId = "beta-old-session",
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
|
||||
gatewayHarness.createdSessionProfileName = beta.name
|
||||
val resumeCountBeforeFreshSend = gatewayHarness.rpcLog.count {
|
||||
it.first == "session.resume"
|
||||
}
|
||||
viewModel.sendMessage("Fresh beta turn")
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertEquals(
|
||||
resumeCountBeforeFreshSend,
|
||||
gatewayHarness.rpcLog.count { it.first == "session.resume" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerProfileSwitchExitsProvisionalThreadBeforeFreshProfileSend() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
val proactiveChatIds = mutableListOf<String?>()
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
|
||||
|
||||
viewModel.openProactiveThread(
|
||||
chatId = "old-phone-chat",
|
||||
entries = listOf(
|
||||
ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
viewModel.sendMessage("Fresh beta turn")
|
||||
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertTrue(proactiveChatIds.isEmpty())
|
||||
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerProfileSwitchExitsPromotedPhoneSessionWithoutReusingItsChatId() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
val proactiveChatIds = mutableListOf<String?>()
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
|
||||
handler.addSession(
|
||||
com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = "promoted-phone-session",
|
||||
title = "Promoted thread",
|
||||
model = null,
|
||||
source = "phone",
|
||||
),
|
||||
)
|
||||
handler.setSessionId("promoted-phone-session")
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
viewModel.sendMessage("Fresh beta after Thread")
|
||||
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertTrue(proactiveChatIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatAndConnectionSwitchRetireProvisionalThreadRouting() {
|
||||
val entry = ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
)
|
||||
val inbound = ProactiveMessage(
|
||||
messageId = "late-1",
|
||||
chatId = "old-phone-chat",
|
||||
text = "Late old-thread message",
|
||||
title = "Old phone thread",
|
||||
surfacing = "thread",
|
||||
sentAt = 2L,
|
||||
)
|
||||
|
||||
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
|
||||
viewModel.createNewChat()
|
||||
assertFalse(viewModel.injectThreadMessage(inbound))
|
||||
|
||||
val switches = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
viewModel.observeConnectionSwitches(switches)
|
||||
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
|
||||
switches.tryEmit("connection-b")
|
||||
awaitCondition { handler.messages.value.isEmpty() }
|
||||
assertFalse(viewModel.injectThreadMessage(inbound))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleThreadPromotionCannotReplaceTransferredProfileDraft() {
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = Profile(name = "alpha", model = "model-a")
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, _, _, _ -> }
|
||||
viewModel.openProactiveThread(
|
||||
"old-phone-chat",
|
||||
listOf(
|
||||
ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
),
|
||||
),
|
||||
)
|
||||
viewModel.sendMessage("Promote the old Thread")
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
handler.addSession(
|
||||
com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = "late-promoted-thread",
|
||||
title = "Late promoted thread",
|
||||
model = null,
|
||||
source = "phone",
|
||||
),
|
||||
)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
|
||||
Thread.sleep(100)
|
||||
|
||||
assertNull(handler.currentSessionId.value)
|
||||
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
|
||||
assertEquals("unchanged", persistedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1560,6 +1297,120 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredServerDefaultSubagentWatchOmitsProfileOverride() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
persistedProfileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
expectedProfile = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredLiteralDefaultSubagentWatchKeepsExplicitProfile() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
persistedProfileKey = "default",
|
||||
expectedProfile = "default",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredNamedSubagentWatchKeepsOwningProfileAcrossConnectionScope() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-b", "team::writer"),
|
||||
persistedProfileKey = "team::writer",
|
||||
expectedProfile = "team::writer",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredLegacyCheckpointFailsClosedWithoutInventingProfile() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = "connection-a/profile-default",
|
||||
persistedProfileKey = null,
|
||||
expectedProfile = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
|
||||
assertCurrentCheckpointProfileKey(
|
||||
profileName = null,
|
||||
effectiveSessionProfileName = "victor",
|
||||
expectedProfileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentLiteralDefaultCheckpointPersistsNamedProfile() {
|
||||
assertCurrentCheckpointProfileKey(
|
||||
profileName = "default",
|
||||
effectiveSessionProfileName = "default",
|
||||
expectedProfileKey = "default",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitConversationOwnerWinsAmbientSelectorInCurrentCheckpoint() {
|
||||
val checkpointStore = MemoryCheckpointStore()
|
||||
val global = Profile(name = "global", model = "global-model")
|
||||
val writer = Profile(name = "writer", model = "writer-model")
|
||||
viewModel.setSelectedProfileProvider { global }
|
||||
viewModel.setSessionProfileNameProvider { global.name }
|
||||
viewModel.setProfileMessageLoader { Result.success(emptyList()) }
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
|
||||
assertTrue(
|
||||
viewModel.openProfileSession(
|
||||
profileName = writer.name,
|
||||
profile = writer,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", writer.name),
|
||||
sessionId = STORED_SESSION_ID,
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.conversationBinding.value.hasExplicitOwner }
|
||||
|
||||
viewModel.sendMessage("Persist explicit owner")
|
||||
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { checkpointStore.checkpoint?.profileKey == writer.name }
|
||||
assertEquals(writer.name, checkpointStore.checkpoint?.profileKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveNonRecoveredSubagentWatchKeepsLiteralDefaultProfile() {
|
||||
val profile = Profile(name = "default", model = "model")
|
||||
viewModel.setSelectedProfileProvider { profile }
|
||||
viewModel.setSessionProfileNameProvider { profile.name }
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", profile.name),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
gatewayHarness.resumeLiveSessionIds["live-child-stored"] = "live-child"
|
||||
|
||||
viewModel.sendMessage("Delegate live work")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"subagent.start",
|
||||
buildJsonObject {
|
||||
put("goal", "Inspect live path")
|
||||
put("task_index", 0)
|
||||
put("task_count", 1)
|
||||
put("subagent_id", "live-child-agent")
|
||||
put("child_session_id", "live-child-stored")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.subagentActivities.value.size == 1 }
|
||||
viewModel.openSubagentChildPreview(viewModel.subagentActivities.value.single().stableKey)
|
||||
|
||||
val resume = gatewayHarness.awaitRpcCount("session.resume", 2).last()
|
||||
assertEquals(JsonPrimitive("default"), resume["profile"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
|
||||
viewModel.sendMessage("Run the guarded command")
|
||||
@@ -2728,6 +2579,88 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertRecoveredSubagentWatchProfile(
|
||||
contextKey: String,
|
||||
persistedProfileKey: String?,
|
||||
expectedProfile: String?,
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = persistedProfileKey,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("pending-user", "Delegate this", now - 2_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "pending-assistant",
|
||||
content = "Partial",
|
||||
timestamp = now - 1_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 1_900L,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Partial"
|
||||
gatewayHarness.resumeLiveSessionIds["child-stored"] = "child-live"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"subagent.start",
|
||||
buildJsonObject {
|
||||
put("goal", "Inspect recovery")
|
||||
put("task_index", 0)
|
||||
put("task_count", 1)
|
||||
put("subagent_id", "child-agent")
|
||||
put("child_session_id", "child-stored")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.subagentActivities.value.size == 1 }
|
||||
viewModel.openSubagentChildPreview(viewModel.subagentActivities.value.single().stableKey)
|
||||
|
||||
val resume = gatewayHarness.awaitRpcCount("session.resume", 2).last()
|
||||
if (expectedProfile == null) {
|
||||
assertFalse(resume.containsKey("profile"))
|
||||
} else {
|
||||
assertEquals(JsonPrimitive(expectedProfile), resume["profile"])
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertCurrentCheckpointProfileKey(
|
||||
profileName: String?,
|
||||
effectiveSessionProfileName: String?,
|
||||
expectedProfileKey: String,
|
||||
) {
|
||||
val checkpointStore = MemoryCheckpointStore()
|
||||
val profile = profileName?.let { Profile(name = it, model = "model") }
|
||||
viewModel.setSelectedProfileProvider { profile }
|
||||
viewModel.setSessionProfileNameProvider { effectiveSessionProfileName }
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", profileName),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
|
||||
viewModel.sendMessage("Persist profile identity")
|
||||
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { checkpointStore.checkpoint?.profileKey == expectedProfileKey }
|
||||
assertEquals(expectedProfileKey, checkpointStore.checkpoint?.profileKey)
|
||||
}
|
||||
|
||||
private fun activeSessionPayload(status: String) = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
|
||||
-27
@@ -58,33 +58,6 @@ class ConversationBindingControllerTest {
|
||||
assertEquals("a2", controller.state.value.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newDraftKeepsExplicitAllProfilesOwnerAndRejectsStaleRestore() {
|
||||
val alpha = Profile("alpha", "model-a", "Alpha")
|
||||
controller.openExplicit("c::alpha", alpha.name, "a1", alpha, null)
|
||||
|
||||
controller.startFreshDraft()
|
||||
|
||||
assertEquals("c::alpha", controller.state.value.contextKey)
|
||||
assertEquals("alpha", controller.state.value.profileName)
|
||||
assertNull(controller.state.value.sessionId)
|
||||
assertEquals(alpha, controller.state.value.displayProfile)
|
||||
assertTrue(controller.state.value.hasExplicitOwner)
|
||||
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
|
||||
assertNull(controller.state.value.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newDraftPromotesGlobalOwnerAndRejectsItsStoredSession() {
|
||||
controller.forceGlobal("c::alpha", "alpha", "a1")
|
||||
|
||||
controller.startFreshDraft()
|
||||
|
||||
assertTrue(controller.state.value.hasExplicitOwner)
|
||||
assertNull(controller.state.value.sessionId)
|
||||
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileLockRejectsOtherOwnersAndAllowsTheLockedOwner() {
|
||||
val locked = AgentDisplay.profileSessionKey("beta")
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentActivityControllerTest {
|
||||
private var now = 1_000L
|
||||
private val controller = SubagentActivityController { now++ }
|
||||
|
||||
@Test
|
||||
fun `interleaved children retain independent lifecycle previews`() {
|
||||
controller.selectSession("parent", "connection::default")
|
||||
controller.beginTurn("parent", "connection::default", "turn-1")
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.START, goal = "Research"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(1, GatewaySubagentEvent.Phase.START, goal = "Review"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "Halfway"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(1, GatewaySubagentEvent.Phase.TOOL, preview = "file.kt", tool = "read_file"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "complete", summary = "Done"))
|
||||
|
||||
val activities = controller.activities.value.sortedBy { it.taskIndex }
|
||||
assertEquals(listOf("Research", "Review"), activities.map { it.goal })
|
||||
assertEquals(SubagentActivityPhase.COMPLETED, activities[0].phase)
|
||||
assertEquals("Done", activities[0].summary)
|
||||
assertEquals(SubagentActivityPhase.TOOL, activities[1].phase)
|
||||
assertEquals("read_file", activities[1].events.last().toolName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile session and newer turn fence stale events`() {
|
||||
controller.selectSession("shared", "connection::alpha")
|
||||
controller.beginTurn("shared", "connection::alpha", "turn-old")
|
||||
controller.onEvent("shared", "connection::alpha", "turn-old", event(0, GatewaySubagentEvent.Phase.START, goal = "Old"))
|
||||
controller.selectSession("shared", "connection::beta")
|
||||
controller.onEvent("shared", "connection::alpha", "turn-old", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "stale"))
|
||||
assertTrue(controller.activities.value.isEmpty())
|
||||
|
||||
controller.beginTurn("shared", "connection::beta", "turn-new")
|
||||
controller.onEvent("shared", "connection::beta", "turn-new", event(0, GatewaySubagentEvent.Phase.START, goal = "New"))
|
||||
controller.beginTurn("shared", "connection::beta", "turn-newer")
|
||||
controller.onEvent("shared", "connection::beta", "turn-newer", event(0, GatewaySubagentEvent.Phase.START, goal = "Newest"))
|
||||
controller.onEvent("shared", "connection::beta", "turn-new", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "late"))
|
||||
assertEquals(listOf("Newest"), controller.activities.value.map { it.goal })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal truth distinguishes failure interruption and missing terminal`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn-1")
|
||||
controller.onEvent("parent", "scope", "turn-1", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onEvent("parent", "scope", "turn-1", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "interrupted"))
|
||||
assertEquals(SubagentActivityPhase.INTERRUPTED, controller.activities.value.single().phase)
|
||||
|
||||
controller.beginTurn("parent", "scope", "turn-2")
|
||||
controller.onEvent("parent", "scope", "turn-2", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onEvent("parent", "scope", "turn-2", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "failed"))
|
||||
assertEquals(SubagentActivityPhase.FAILED, controller.activities.value.single().phase)
|
||||
|
||||
controller.beginTurn("parent", "scope", "turn-3")
|
||||
controller.onEvent("parent", "scope", "turn-3", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.endTurn("turn-3")
|
||||
assertEquals(SubagentActivityPhase.ENDED_WITH_PARENT, controller.activities.value.single().phase)
|
||||
assertTrue(controller.activities.value.single().partialAfterGap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect marks only live activity partial and late events do not reopen terminal child`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onConnectionReady(true)
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onConnectionReady(false)
|
||||
controller.onConnectionReady(true)
|
||||
assertTrue(controller.activities.value.single().partialAfterGap)
|
||||
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "complete"))
|
||||
val revision = controller.activities.value.single().revision
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "late"))
|
||||
assertEquals(revision, controller.activities.value.single().revision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event history is sanitized coalesced and bounded`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.START, goal = "\u001B[31mSecret\u0000"))
|
||||
repeat(SubagentActivityController.MAX_EVENTS_PER_CHILD + 10) { index ->
|
||||
controller.onEvent(
|
||||
"parent",
|
||||
"scope",
|
||||
"turn",
|
||||
event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "update-$index"),
|
||||
)
|
||||
}
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "same"))
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "same"))
|
||||
|
||||
val activity = controller.activities.value.single()
|
||||
assertEquals("Secret", activity.goal)
|
||||
assertTrue(activity.truncated)
|
||||
assertTrue(activity.events.size <= SubagentActivityController.MAX_EVENTS_PER_CHILD)
|
||||
assertEquals(1, activity.events.count { it.text == "same" })
|
||||
assertFalse(activity.events.any { it.text?.contains('\u0000') == true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity enrichment keeps one lane while conflicting child stays separate`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(0, GatewaySubagentEvent.Phase.SPAWN_REQUESTED, childId = "session-1"),
|
||||
)
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(0, GatewaySubagentEvent.Phase.START, subagentId = "agent-1"),
|
||||
)
|
||||
assertEquals(1, controller.activities.value.size)
|
||||
assertEquals("session-1", controller.activities.value.single().childSessionId)
|
||||
assertEquals("agent-1", controller.activities.value.single().subagentId)
|
||||
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(
|
||||
0,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
subagentId = "agent-2",
|
||||
childId = "session-2",
|
||||
),
|
||||
)
|
||||
assertEquals(2, controller.activities.value.size)
|
||||
assertEquals(2, controller.activities.value.map { it.stableKey }.distinct().size)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
index: Int,
|
||||
phase: GatewaySubagentEvent.Phase,
|
||||
goal: String = "",
|
||||
preview: String? = null,
|
||||
tool: String? = null,
|
||||
status: String? = null,
|
||||
summary: String? = null,
|
||||
subagentId: String? = null,
|
||||
childId: String? = null,
|
||||
) = GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = index,
|
||||
taskCount = 2,
|
||||
goal = goal,
|
||||
preview = preview,
|
||||
toolName = tool,
|
||||
status = status,
|
||||
summary = summary,
|
||||
subagentId = subagentId,
|
||||
childSessionId = childId,
|
||||
)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChildWatch
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SubagentChildPreviewControllerTest {
|
||||
@Test
|
||||
fun `pre-ack live callbacks append after hydrated history`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, _, _, callbacks ->
|
||||
callbacks.onStart()
|
||||
callbacks.onTextDelta("live")
|
||||
callbacks.onComplete()
|
||||
Result.success(watch(messages = listOf(message("history")), running = true))
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity(), client, "parent", "scope", true) { true }
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = controller.state.value!!
|
||||
assertTrue(state.messages.any { it.content == "history" })
|
||||
assertTrue(state.messages.any { it.content == "live" })
|
||||
assertFalse(state.running)
|
||||
assertFalse(state.status == "completed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissed in-flight open closes the late exact watch without publishing`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val acknowledgement = CompletableDeferred<GatewayChildWatch>()
|
||||
var closed: GatewayChildWatch? = null
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, _, _, _ -> Result.success(acknowledgement.await()) },
|
||||
closeWatch = { _, watch ->
|
||||
closed = watch
|
||||
Result.success(Unit)
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity(), client, "parent", "scope", true) { true }
|
||||
runCurrent()
|
||||
controller.close()
|
||||
val late = watch()
|
||||
acknowledgement.complete(late)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(late, closed)
|
||||
assertNull(controller.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale open generation closes late watch and cannot replace newer profile`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val oldAcknowledgement = CompletableDeferred<GatewayChildWatch>()
|
||||
val closed = mutableListOf<GatewayChildWatch>()
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, sessionId, profile, _ ->
|
||||
if (sessionId == "child-old") {
|
||||
Result.success(oldAcknowledgement.await())
|
||||
} else {
|
||||
Result.success(watch(sessionId, "live-new", profile))
|
||||
}
|
||||
},
|
||||
closeWatch = { _, watch ->
|
||||
closed += watch
|
||||
Result.success(Unit)
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity("child-old", "old", laneId = 0), client, "parent", "scope", true) { true }
|
||||
runCurrent()
|
||||
controller.open(activity("child-new", "default", laneId = 1), client, "parent", "scope", true) { true }
|
||||
advanceUntilIdle()
|
||||
assertEquals("default", controller.state.value?.status)
|
||||
|
||||
val late = watch("child-old", "live-old", "old")
|
||||
oldAcknowledgement.complete(late)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(late in closed)
|
||||
assertEquals("default", controller.state.value?.status)
|
||||
assertEquals("turn:1", controller.state.value?.activityKey)
|
||||
}
|
||||
|
||||
private fun activity(
|
||||
childSessionId: String = "child-stored",
|
||||
profile: String = "default",
|
||||
laneId: Long = 0,
|
||||
) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = 0,
|
||||
taskCount = 1,
|
||||
goal = "Inspect",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
childSessionId = childSessionId,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
private fun message(text: String) = MessageItem(
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
private fun watch(
|
||||
messages: List<MessageItem> = emptyList(),
|
||||
running: Boolean = false,
|
||||
) = GatewayChildWatch(
|
||||
storedSessionId = "child-stored",
|
||||
liveSessionId = "child-live",
|
||||
profile = "default",
|
||||
generation = 1,
|
||||
messages = messages,
|
||||
historyTruncated = false,
|
||||
running = running,
|
||||
status = if (running) "streaming" else "idle",
|
||||
)
|
||||
|
||||
private fun watch(
|
||||
storedSessionId: String,
|
||||
liveSessionId: String,
|
||||
status: String?,
|
||||
) = GatewayChildWatch(
|
||||
storedSessionId = storedSessionId,
|
||||
liveSessionId = liveSessionId,
|
||||
profile = status,
|
||||
generation = if (storedSessionId == "child-old") 1 else 2,
|
||||
messages = emptyList(),
|
||||
historyTruncated = false,
|
||||
running = true,
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
-39
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardProfileScope
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -367,42 +366,4 @@ class ProfileControllerLockTest {
|
||||
controller.selectProfile(coder)
|
||||
assertEquals(coder, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftFencesRestoreAndClearsOnlyItsConnectionProfileTransport() = runBlocking {
|
||||
val sessions = controller.profileSessionStore
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "old-sse")
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.GATEWAY, "old-gateway")
|
||||
sessions.setSessionId("other-connection", mizu.name, SessionTransport.SSE, "other-sse")
|
||||
controller.selectProfile(mizu)
|
||||
awaitSelected(mizu.name)
|
||||
|
||||
controller.markFreshDraft(connectionId, mizu.name, SessionTransport.SSE)
|
||||
withTimeout(5_000) {
|
||||
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.SSE)
|
||||
.first { it == null }
|
||||
}
|
||||
|
||||
// Simulate an older read observing the pre-clear value: the live intent
|
||||
// fence still wins until a real session supersedes the draft.
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "stale-sse")
|
||||
controller.refreshLastSessionForProfile(connectionId, mizu.name)
|
||||
assertNull(lastSessionIds.last())
|
||||
|
||||
assertEquals(
|
||||
"old-gateway",
|
||||
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.GATEWAY).first(),
|
||||
)
|
||||
assertEquals(
|
||||
"other-sse",
|
||||
sessions.sessionIdFlow("other-connection", mizu.name, SessionTransport.SSE).first(),
|
||||
)
|
||||
|
||||
controller.markSessionPersisted(connectionId, mizu.name, SessionTransport.SSE)
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "new-sse")
|
||||
controller.refreshLastSessionForProfile(connectionId, mizu.name)
|
||||
withTimeout(5_000) {
|
||||
while (lastSessionIds.lastOrNull() != "new-sse") Thread.sleep(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2836,20 +2836,6 @@ session or starts a draft, never hot-swaps a live session. Model/provider,
|
||||
personality, reasoning, approval, Fast, and YOLO state reset at the ViewModel
|
||||
context boundary before destination session truth can repopulate them.
|
||||
|
||||
New Chat retains the current concrete conversation owner even when the drawer is
|
||||
browsing All Profiles. A profile choice made from that empty draft transfers an
|
||||
explicit fresh-draft intent rather than restoring the destination's previous
|
||||
session. Android persists and generation-fences that intent by exact
|
||||
connection/profile/transport; it clears only the resumable pointer, leaving the
|
||||
stored conversation, transcript, and per-owner composer drafts intact.
|
||||
|
||||
Phone Threads keep their connection/chat-id ownership when leaving that surface.
|
||||
They are never transferred into a different profile binding: the atomic header
|
||||
switch retires provisional or in-progress promotion state before creating the
|
||||
destination profile draft, while durable inbox rows, promoted sessions,
|
||||
notification ownership, and session-to-chat-id indexes remain untouched. A
|
||||
generation fence prevents a delayed promotion from replacing the new draft.
|
||||
|
||||
**Consequences.** The hamburger remains exclusively the Session Drawer. Agent
|
||||
Passport stays focused on inspection and configuration. The drawer may widen
|
||||
its read-only browse scope to all profiles and organize that combined set by
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
+1
-1
@@ -475,7 +475,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Upstream animated pets** — the agent sheet consumes the profile-scoped Gateway `pet.info`, `pet.gallery`, `pet.select`, and `pet.disable` contracts. Android caches the bounded PNG/WebP sprite sheet by connection, effective profile, and `spritesheetRevision`; it sends `knownRevision` on refresh and reuses the existing bounded pet renderer for the returned geometry, row taxonomy, and activity states. The active upstream pet becomes the phone companion unless the user explicitly selected a phone-local floating pet. Selection and disable write Hermes `display.pet.*` state and therefore follow the profile across current Hermes surfaces; a method-not-found response leaves older hosts on the established local pet flow.
|
||||
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
|
||||
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
|
||||
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. New Chat from All Profiles keeps the current conversation owner, and selecting another profile while that draft is empty transfers fresh-draft intent to the destination instead of restoring its prior session. That intent is fenced and persisted by exact connection/profile/transport while the prior session and history remain available in the drawer. Provisional and promoted phone Threads are not transferable profile drafts: a header profile switch exits their local routing state before establishing the destination draft, preserves their durable inbox/session/index data, and generation-fences any pending promotion. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
|
||||
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
|
||||
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
|
||||
- **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.
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
|
||||
API_BOUNDARY = "api.fallback_boundary"
|
||||
ALL_CONTRACTS = (
|
||||
GATEWAY_TERMINAL,
|
||||
@@ -37,6 +38,7 @@ ALL_CONTRACTS = (
|
||||
SESSION_ACTIVATE,
|
||||
SESSION_RESUME,
|
||||
SESSION_ACTIVE_LIST,
|
||||
SUBAGENT_CHILD_WATCH,
|
||||
API_BOUNDARY,
|
||||
)
|
||||
|
||||
@@ -375,6 +377,38 @@ def _check_api_boundary(api: SourceFile) -> CheckResult:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def _check_subagent_child_watch(server: SourceFile, methods: SourceFile) -> CheckResult:
|
||||
contract = SUBAGENT_CHILD_WATCH
|
||||
try:
|
||||
resume = methods.method_handler("session.resume")
|
||||
resume_segment = methods.segment(resume)
|
||||
resume_strings = _string_constants(resume)
|
||||
server_strings = _string_constants(server.tree)
|
||||
missing_resume = sorted(
|
||||
{"lazy", "close_on_disconnect"} - resume_strings
|
||||
)
|
||||
if missing_resume:
|
||||
raise ValueError("lazy child resume field(s) missing: " + ", ".join(missing_resume))
|
||||
if "include_ancestors" not in resume_segment:
|
||||
raise ValueError("lazy child resume does not declare child-only history")
|
||||
missing_events = sorted(
|
||||
{"child_session_id", "subagent.text", "reasoning.delta", "message.delta"}
|
||||
- server_strings
|
||||
)
|
||||
if missing_events:
|
||||
raise ValueError("child watch mirror event(s) missing: " + ", ".join(missing_events))
|
||||
return CheckResult(
|
||||
contract,
|
||||
True,
|
||||
(
|
||||
methods.evidence(resume, "session.resume supports a lazy child-only watch"),
|
||||
"tui_gateway/server.py: child_session_id routes child mirror events",
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def load_requirements(manifest: Path | None) -> tuple[str, ...]:
|
||||
if manifest is None:
|
||||
return ALL_CONTRACTS
|
||||
@@ -416,6 +450,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
|
||||
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
|
||||
SESSION_RESUME: lambda: _check_resume(methods),
|
||||
SESSION_ACTIVE_LIST: lambda: _check_active_list(server, methods),
|
||||
SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods),
|
||||
API_BOUNDARY: lambda: _check_api_boundary(api),
|
||||
}
|
||||
return [checks[requirement]() for requirement in requirements]
|
||||
|
||||
@@ -56,6 +56,12 @@ def _session_live_item(sid, session, current_sid=""):
|
||||
"session_key": session.get("session_key", sid),
|
||||
"status": _session_live_status(sid, session),
|
||||
}
|
||||
|
||||
def _mirror_subagent_child(event):
|
||||
child = event.get("child_session_id")
|
||||
if event.get("type") == "subagent.text":
|
||||
return (child, "reasoning.delta", "message.delta")
|
||||
return child
|
||||
'''
|
||||
|
||||
METHODS_SOURCE = '''
|
||||
@@ -65,6 +71,9 @@ def method(name):
|
||||
@method("session.resume")
|
||||
def _(rid, params):
|
||||
target = params.get("session_id", "")
|
||||
lazy = bool(params.get("lazy"))
|
||||
close_on_disconnect = bool(params.get("close_on_disconnect"))
|
||||
include_ancestors = not lazy
|
||||
found = db.get_session(target)
|
||||
if not found:
|
||||
return _err(rid, 4007, "session not found")
|
||||
|
||||
@@ -88,6 +88,11 @@ 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:
|
||||
|
||||
- `subagent_child_preview`: interleaved concurrent child lifecycle events carry
|
||||
stable child/session identity, thinking/progress/tool previews, and distinct
|
||||
completed/interrupted terminal states. Its upstream requirement also proves
|
||||
the vanilla lazy child-session watch contract used by read-only clients.
|
||||
|
||||
- `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
|
||||
|
||||
@@ -267,6 +267,7 @@ class ScenarioTestCase(unittest.TestCase):
|
||||
"active_status_unsupported",
|
||||
"ordinary_turn",
|
||||
"rapid_tools_interims",
|
||||
"subagent_child_preview",
|
||||
"terminal_gap_activate",
|
||||
"terminal_gap_session_info",
|
||||
"queued_follow_up",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "subagent_child_preview",
|
||||
"live_session_id": "fixture-live-1",
|
||||
"stored_session_id": "20260821_120000_fixture",
|
||||
"profile": "default",
|
||||
"contract_requirements": [
|
||||
"gateway.message_complete",
|
||||
"gateway.subagent_child_watch"
|
||||
],
|
||||
"turns": [
|
||||
{
|
||||
"steps": [
|
||||
{"op": "event", "type": "message.start"},
|
||||
{"op": "event", "type": "subagent.spawn_requested", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.start", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.start", "payload": {"goal": "Review privacy", "task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.thinking", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "Mapping events"}},
|
||||
{"op": "event", "type": "subagent.tool", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "tool_name": "read_file", "tool_preview": "policy.md"}},
|
||||
{"op": "event", "type": "subagent.progress", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "One tool complete"}},
|
||||
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "status": "interrupted", "summary": "Stopped safely"}},
|
||||
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "status": "completed", "summary": "Mapped Android events", "duration_seconds": 2.5}},
|
||||
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Exercise child previews.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Delegation complete.", "timestamp": 2.0}]},
|
||||
{"op": "set_running", "value": false},
|
||||
{"op": "event", "type": "message.complete", "payload": {"text": "Delegation complete.", "status": "complete"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user