feat(android): add Wear Agent Pulse (#122123)

* feat(android): add Wear agent pulse

* fix-android-wear-pulse-selected-session

* fix(android): count direct Wear Pulse subagents

* fix(android): gate Wear Pulse page by capability

* fix:bound-wear-pulse-gateway-reads

* fix: keep native locale artifacts generated

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: Colin Johnson <colin@solvely.net>
Co-authored-by: Solvely-Colin <159171061+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Colin Johnson <211764741+Solvely-Colin@users.noreply.github.com>
This commit is contained in:
WhatsSkiLL
2026-08-14 18:22:26 +02:00
committed by GitHub
parent 2b31cf114b
commit e37e63ceb6
26 changed files with 2422 additions and 40 deletions
+191
View File
@@ -1415,6 +1415,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -1440,6 +1444,17 @@
}
]
},
{
"id": "native.android.ccd226a2c0e85242",
"source": "Active task list is at its limit",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.28e1dcd489f67784",
"source": "Adaptive",
@@ -2325,6 +2340,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -2707,6 +2726,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -2787,6 +2810,17 @@
}
]
},
{
"id": "native.android.7e716e5025c96755",
"source": "Bounded active and recent snapshot",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.b62482f58e031dc3",
"source": "Bridge",
@@ -3771,6 +3805,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -5991,6 +6029,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -6467,6 +6509,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -7495,6 +7541,17 @@
}
]
},
{
"id": "native.android.cee2b043b9ff723d",
"source": "Groups",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.4f316f3050dd4439",
"source": "Have a terminal open on the device running OpenClaw.",
@@ -7718,6 +7775,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -8462,6 +8523,17 @@
}
]
},
{
"id": "native.android.982d8645f94585d2",
"source": "Loading Pulse",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.0ba4fafa3c405a73",
"source": "Loading automation…",
@@ -9083,6 +9155,17 @@
}
]
},
{
"id": "native.android.e238a849f3745e16",
"source": "More phases not shown",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.a5beb359c71ff68c",
"source": "Motion",
@@ -12039,6 +12122,17 @@
}
]
},
{
"id": "native.android.4c0349bb7fb1300c",
"source": "Pending requests",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.95992be09ebae8eb",
"source": "Permission needed",
@@ -12083,6 +12177,17 @@
}
]
},
{
"id": "native.android.b90b0acb54a1e930",
"source": "Phase %1$d",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.008ac966ec9191f9",
"source": "Phone Capabilities",
@@ -12658,6 +12763,17 @@
}
]
},
{
"id": "native.android.139b5e6557177a23",
"source": "Pulse",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.c0fef45b3b102aac",
"source": "QR code did not contain a valid setup code.",
@@ -12732,6 +12848,21 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.139768629b9f2483",
"source": "Queued %1$d, Running %2$d, Done %3$d, Failed %4$d, Hidden %5$d",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -12922,6 +13053,17 @@
}
]
},
{
"id": "native.android.e8bfe61eb7a178e9",
"source": "Reading bounded aggregates from the paired phone.",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.dc842ee2019a7fda",
"source": "Ready",
@@ -13143,6 +13285,17 @@
}
]
},
{
"id": "native.android.f26643818268e147",
"source": "Recent task list is at its limit",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.cfd90a828c5f6bda",
"source": "Recents",
@@ -13425,6 +13578,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -14070,6 +14227,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -15032,6 +15193,17 @@
}
]
},
{
"id": "native.android.076e5ee0a1e35da3",
"source": "Session swarm",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.84424d26d15b7f68",
"source": "Session target",
@@ -16084,6 +16256,17 @@
}
]
},
{
"id": "native.android.245f1127ee17dae9",
"source": "Status: %1$s",
"surface": "android",
"sites": [
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
{
"id": "native.android.c5aca22f498a5dda",
"source": "Step 1",
@@ -17658,6 +17841,10 @@
{
"kind": "ui-call",
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -17715,6 +17902,10 @@
{
"kind": "resource-string",
"path": "apps/android/app/src/main/res/values/strings.xml"
},
{
"kind": "resource-string",
"path": "apps/android/wear/src/main/res/values/strings.xml"
}
]
},
@@ -122,6 +122,7 @@ import ai.openclaw.app.wear.WearProxyGatewayException
import ai.openclaw.app.wear.WearProxyModel
import ai.openclaw.app.wear.WearRealtimeAttemptOwner
import ai.openclaw.app.wear.WearRealtimeTalkController
import ai.openclaw.app.wear.projectWearAgentPulse
import ai.openclaw.app.wear.wearConnectionFailure
import ai.openclaw.wear.shared.WearMessage
import ai.openclaw.wear.shared.WearRealtimeTalkCodec
@@ -141,6 +142,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -159,6 +161,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -184,6 +187,39 @@ private const val CRON_JOBS_SNAPSHOT_MAX_ATTEMPTS = 3
private const val OperatorAdminScope = "operator.admin"
private const val OperatorPairingScope = "operator.pairing"
internal const val WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS = 8_000L
internal data class WearAgentPulseReads<Tasks, Swarm>(
val tasks: Tasks?,
val swarm: Swarm?,
)
internal suspend fun <Tasks, Swarm> readWearAgentPulseConcurrently(
readTasks: suspend () -> Tasks,
readSwarm: suspend () -> Swarm,
budgetMillis: Long = WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS,
): WearAgentPulseReads<Tasks, Swarm> =
coroutineScope {
val tasks = async { readWearAgentPulseComponent(budgetMillis, readTasks) }
val swarm = async { readWearAgentPulseComponent(budgetMillis, readSwarm) }
WearAgentPulseReads(
tasks = tasks.await(),
swarm = swarm.await(),
)
}
private suspend fun <T> readWearAgentPulseComponent(
budgetMillis: Long,
read: suspend () -> T,
): T? =
try {
withTimeoutOrNull(budgetMillis) { read() }
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
null
}
private fun execApprovalOutcomeUnknownMessage(): String = nativeText("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.").source
private fun execApprovalStillPendingMessage(): String = nativeText("The Gateway still shows this approval as pending. Review it before trying again.").source
@@ -1310,6 +1346,7 @@ class NodeRuntime private constructor(
val execApprovalsNotice: StateFlow<GatewayExecApprovalNotice?> = _execApprovalsNotice.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private var execApprovalsSnapshotReady = false
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val pendingExecApprovalWrites = mutableMapOf<String, PendingExecApprovalWrite>()
@@ -1518,9 +1555,7 @@ class NodeRuntime private constructor(
isGatewayConnected = operatorSession::isReady,
gatewayStatusText = { synchronized(gatewayStatusLock) { operatorStatusText } },
hasOperatorAdminScope = { OperatorAdminScope in _operatorScopes.value },
activeAgentId = {
resolveAgentIdFromMainSessionKey(mainSessionKey.value) ?: gatewayDefaultAgentId.value
},
activeAgentId = ::currentWearAgentId,
activeSessionKey = { chatSessionKey.value },
selectedModelRef = { chatSelectedModelRef.value },
agents = {
@@ -1560,6 +1595,7 @@ class NodeRuntime private constructor(
},
connectGateway = { refreshGatewayConnection() },
disconnectGateway = { disconnect() },
loadAgentPulse = ::loadWearAgentPulse,
startRealtimeTalk = { nodeId, sessionKey, attemptId, language, attemptScopedAudio ->
if (startWearRealtimeTalk(nodeId, sessionKey, attemptId, language, attemptScopedAudio)) wearRealtimeTalkSnapshot.value else null
},
@@ -1569,6 +1605,67 @@ class NodeRuntime private constructor(
)
}
private fun currentWearAgentId(): String? = resolveAgentIdFromMainSessionKey(mainSessionKey.value) ?: gatewayDefaultAgentId.value
private suspend fun loadWearAgentPulse(requestedSessionKey: String?): JsonObject {
val gatewayScope = captureGatewayDataScope()
val agentId = currentWearAgentId()
val connected = gatewayScope != null && operatorSession.isReady()
val reads =
if (connected && agentId != null) {
readWearAgentPulseConcurrently(
readTasks = { listBackgroundTasks(agentId) },
readSwarm = {
requestedSessionKey?.let { sessionKey ->
chat.readSwarmSnapshotFor(sessionKey, agentId)
}
},
)
} else {
null
}
val tasks = reads?.tasks
val swarmSnapshot = reads?.swarm
// Capture every projection input before the final route check so a route
// change cannot mix a current task result with later-route aggregates.
val approvals = currentWearAgentPulseApprovals()
val routeStillCurrent =
gatewayScope?.let { capturedScope ->
connected &&
isGatewayDataScopeCurrent(capturedScope) &&
operatorSession.isReady() &&
currentWearAgentId() == agentId
} == true
val swarmAvailable =
routeStillCurrent &&
requestedSessionKey != null &&
swarmSnapshot?.isAvailableFor(requestedSessionKey) == true
return projectWearAgentPulse(
gatewayConnected = routeStillCurrent,
tasks = tasks.takeIf { routeStillCurrent },
swarmAvailable = swarmAvailable,
swarmGroups = if (swarmAvailable) swarmSnapshot.groups else emptyList(),
pendingApprovalCount = approvals.pendingCount,
approvalsAvailable = routeStillCurrent && approvals.available,
approvalsRefreshing = approvals.refreshing,
)
}
private fun currentWearAgentPulseApprovals(): WearAgentPulseApprovalSnapshot =
synchronized(execApprovalsStateLock) {
WearAgentPulseApprovalSnapshot(
pendingCount = _execApprovals.value.size,
available = execApprovalsSnapshotReady && _execApprovalsErrorText.value == null,
refreshing = _execApprovalsRefreshing.value,
)
}
private data class WearAgentPulseApprovalSnapshot(
val pendingCount: Int,
val available: Boolean,
val refreshing: Boolean,
)
internal suspend fun handleWearProxyRequest(
sourceNodeId: String,
request: WearMessage.Request,
@@ -1668,8 +1765,11 @@ class NodeRuntime private constructor(
}
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
if (retirePendingCronRuns) {
synchronized(execApprovalsStateLock) { pendingExecApprovalWrites.clear() }
synchronized(execApprovalsStateLock) {
execApprovalsSnapshotReady = false
if (retirePendingCronRuns) {
pendingExecApprovalWrites.clear()
}
}
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
@@ -7056,7 +7156,9 @@ class NodeRuntime private constructor(
val gatewayScope = captureGatewayDataScope() ?: return
val refreshGeneration =
synchronized(execApprovalsStateLock) {
execApprovalsRefreshSeq.incrementAndGet()
val nextGeneration = execApprovalsRefreshSeq.incrementAndGet()
execApprovalsSnapshotReady = false
nextGeneration
}
publishGatewayData(gatewayScope) {
_execApprovalsRefreshing.value = true
@@ -7623,6 +7725,7 @@ class NodeRuntime private constructor(
resolvedExecApprovalIds.addAll(terminalIds)
terminalIds.forEach(pendingExecApprovalWrites::remove)
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
execApprovalsSnapshotReady = true
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
@@ -58,6 +58,9 @@ import java.util.concurrent.atomic.AtomicLong
internal const val SESSION_LIST_FETCH_LIMIT = 200
private val QUESTION_REFRESH_RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 4_000L)
private val SWARM_REFRESH_RETRY_DELAYS_MS = longArrayOf(1_000L, 2_000L, 4_000L)
private const val WEAR_AGENT_PULSE_SWARM_MAX_ROWS = 1_000
private const val WEAR_AGENT_PULSE_SWARM_FETCH_LIMIT = WEAR_AGENT_PULSE_SWARM_MAX_ROWS + 1
private const val WEAR_AGENT_PULSE_DIRECT_CHILDREN_GROUP = "__wear_agent_pulse_direct_children__"
private const val SUBAGENT_ACTIVITY_RETENTION_MS = 60_000L
private const val SESSION_EDITOR_MAX_BASE64_CHARS = ((OUTBOX_MAX_COMMAND_ATTACHMENT_BYTES + 2) / 3) * 4
private val MANAGED_MEDIA_PATH_REGEX =
@@ -385,6 +388,116 @@ class ChatController internal constructor(
private var swarmSessionKey: String? = null
private var swarmEnabled = false
internal fun currentSwarmSnapshot(): ChatSwarmSnapshot =
synchronized(swarmLock) {
ChatSwarmSnapshot(
sessionKey = swarmSessionKey,
enabled = swarmEnabled,
groups = _swarmGroups.value,
)
}
/** Reads Swarm state for a Wear-selected session without changing the Phone chat selection. */
internal suspend fun readSwarmSnapshotFor(
sessionKey: String,
agentId: String,
): ChatSwarmSnapshot? {
val requestedSessionKey = sessionKey.trim().takeIf(String::isNotEmpty) ?: return null
val requestedAgentId = agentId.trim().takeIf(String::isNotEmpty) ?: return null
val scopedSessionKey = normalizeRequestedSessionKey(requestedSessionKey)
val scopedAgentId = resolveAgentIdFromMainSessionKey(scopedSessionKey)
val restrictToParentAgent =
when {
scopedAgentId == requestedAgentId -> false
requestedSessionKey == "main" && scopedSessionKey == "main" && scopedAgentId == null -> true
else -> return null
}
val requestCacheScope = currentCacheScope() ?: return null
val enabled =
try {
val params =
buildJsonObject {
put("agentId", JsonPrimitive(requestedAgentId))
}
val root =
json
.parseToJsonElement(
requestGatewayBound(
requestCacheScope.gatewayId,
"chat.metadata",
params.toString(),
),
).asObjectOrNull()
root?.get("swarmEnabled").asBooleanOrNull() == true
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
return null
}
if (!enabled) {
return ChatSwarmSnapshot(
sessionKey = requestedSessionKey,
enabled = false,
groups = emptyList(),
)
}
val rows =
try {
val params =
buildJsonObject {
put("includeGlobal", JsonPrimitive(false))
put("includeUnknown", JsonPrimitive(false))
put("configuredAgentsOnly", JsonPrimitive(true))
if (restrictToParentAgent) {
put("agentId", JsonPrimitive(requestedAgentId))
}
put("spawnedBy", JsonPrimitive(scopedSessionKey))
put("limit", JsonPrimitive(WEAR_AGENT_PULSE_SWARM_FETCH_LIMIT))
put("offset", JsonPrimitive(0))
}
val root =
json
.parseToJsonElement(
requestGatewayBound(
requestCacheScope.gatewayId,
"sessions.list",
params.toString(),
),
).asObjectOrNull()
?: return null
val sessionElements = root["sessions"] as? JsonArray ?: return null
val parsedRows = sessionElements.mapNotNull { parseSessionEntry(it.asObjectOrNull()) }
val truncated =
root["hasMore"].asBooleanOrNull() == true ||
root["totalCount"].asLongOrNull()?.let { total -> total > sessionElements.size.toLong() } == true ||
sessionElements.size > WEAR_AGENT_PULSE_SWARM_MAX_ROWS
if (truncated || parsedRows.size != sessionElements.size) {
return null
}
parsedRows
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
return null
}
val projectedRows =
rows.map { row ->
val hasExplicitGroup = !row.swarmGroupId.isNullOrBlank()
val hasSubagentProvenance = row.subagentRunState != null || row.hasActiveSubagentRun != null
if (!hasExplicitGroup && hasSubagentProvenance) {
row.copy(swarmGroupId = WEAR_AGENT_PULSE_DIRECT_CHILDREN_GROUP)
} else {
row
}
}
if (requestCacheScope != currentCacheScope()) return null
return ChatSwarmSnapshot(
sessionKey = requestedSessionKey,
enabled = true,
groups = buildChatSwarmGroups(projectedRows) { candidate -> sameOutboxSession(candidate, scopedSessionKey) },
)
}
private val _sessionBranches = MutableStateFlow<List<SessionBranch>>(emptyList())
val sessionBranches: StateFlow<List<SessionBranch>> = _sessionBranches.asStateFlow()
@@ -4238,6 +4351,7 @@ class ChatController internal constructor(
) {
return
}
swarmSessionKey = snapshot.parentKey
_swarmGroups.value = groups
}
}
@@ -43,6 +43,14 @@ data class ChatSwarmGroup(
val phases: List<ChatSwarmPhase>,
)
internal data class ChatSwarmSnapshot(
val sessionKey: String?,
val enabled: Boolean,
val groups: List<ChatSwarmGroup>,
) {
fun isAvailableFor(sessionKey: String?): Boolean = enabled && sessionKey != null && this.sessionKey == sessionKey
}
internal data class ChatSwarmSessionPage(
val sessions: List<ChatSessionEntry>,
val totalCount: Int?,
@@ -0,0 +1,129 @@
package ai.openclaw.app.wear
import ai.openclaw.app.chat.BackgroundTask
import ai.openclaw.app.chat.ChatSwarmDotStatus
import ai.openclaw.app.chat.ChatSwarmGroup
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal fun projectWearAgentPulse(
gatewayConnected: Boolean,
tasks: List<BackgroundTask>?,
swarmAvailable: Boolean,
swarmGroups: List<ChatSwarmGroup>,
pendingApprovalCount: Int,
approvalsAvailable: Boolean,
approvalsRefreshing: Boolean,
): JsonObject =
buildJsonObject {
put(
"tasks",
buildJsonObject {
if (!gatewayConnected || tasks == null) {
put("state", "unavailable")
} else {
val queued = tasks.count { task -> task.status == "queued" }
val running = tasks.count { task -> task.status == "running" }
val completed = tasks.count { task -> task.status == "completed" }
val failed =
tasks.count { task ->
task.status == "failed" || task.status == "cancelled" || task.status == "timed_out"
}
put("state", "ready")
put("scope", "bounded")
put("queued", queued)
put("running", running)
put("completed", completed)
put("failed", failed)
put("activeAtLimit", queued + running >= ACTIVE_TASK_LIMIT)
put("recentAtLimit", completed + failed >= RECENT_TASK_LIMIT)
}
},
)
put(
"swarm",
buildJsonObject {
when {
!gatewayConnected || !swarmAvailable -> put("state", "unavailable")
swarmGroups.isEmpty() -> {
put("state", "idle")
put("scope", "selected-session")
}
else -> {
val phaseBuckets = MutableList(MAX_PHASE_BUCKETS) { MutablePhaseCounts() }
var morePhases = false
swarmGroups.forEach { group ->
if (group.phases.size > MAX_PHASE_BUCKETS) morePhases = true
group.phases.take(MAX_PHASE_BUCKETS).forEachIndexed { index, phase ->
val bucket = phaseBuckets[index]
phase.dots.forEach { dot ->
when (dot.status) {
ChatSwarmDotStatus.Queued -> bucket.queued += 1
ChatSwarmDotStatus.Running -> bucket.running += 1
ChatSwarmDotStatus.Done -> bucket.done += 1
ChatSwarmDotStatus.Failed -> bucket.failed += 1
}
}
bucket.hidden += phase.hidden
}
}
put("state", "active")
put("scope", "selected-session")
put("groups", swarmGroups.size)
put("running", swarmGroups.sumOf(ChatSwarmGroup::running))
put("done", swarmGroups.sumOf(ChatSwarmGroup::done))
put("failed", swarmGroups.sumOf(ChatSwarmGroup::failed))
put(
"phases",
buildJsonArray {
phaseBuckets
.dropLastWhile { phase -> !phase.hasData() }
.forEach { phase ->
add(
buildJsonObject {
put("queued", phase.queued)
put("running", phase.running)
put("done", phase.done)
put("failed", phase.failed)
put("hidden", phase.hidden)
},
)
}
},
)
put("morePhases", morePhases)
}
}
},
)
put(
"approvals",
buildJsonObject {
when {
!gatewayConnected -> put("state", "unavailable")
approvalsRefreshing -> put("state", "refreshing")
!approvalsAvailable -> put("state", "unavailable")
else -> {
put("state", "ready")
put("pending", pendingApprovalCount.coerceAtLeast(0))
}
}
},
)
}
private data class MutablePhaseCounts(
var queued: Int = 0,
var running: Int = 0,
var done: Int = 0,
var failed: Int = 0,
var hidden: Int = 0,
) {
fun hasData(): Boolean = queued != 0 || running != 0 || done != 0 || failed != 0 || hidden != 0
}
private const val ACTIVE_TASK_LIMIT = 100
private const val RECENT_TASK_LIMIT = 50
private const val MAX_PHASE_BUCKETS = 8
@@ -51,6 +51,9 @@ internal class WearProxyController(
private val selectSessionModel: suspend (sessionKey: String, modelRef: String) -> Boolean = { _, _ -> false },
private val connectGateway: suspend () -> Unit = {},
private val disconnectGateway: suspend () -> Unit = {},
private val loadAgentPulse: suspend (sessionKey: String?) -> JsonObject = {
throw WearProxyGatewayException("unavailable", "Agent Pulse is unavailable")
},
private val startRealtimeTalk:
suspend (nodeId: String, sessionKey: String, attemptId: String, language: String?, attemptScopedAudio: Boolean) -> WearRealtimeTalkSnapshot? = { _, _, _, _, _ -> null },
private val stopRealtimeTalk: suspend (nodeId: String, attemptId: String) -> WearRealtimeTalkSnapshot? = { _, _ -> null },
@@ -63,6 +66,7 @@ internal class WearProxyController(
val result =
when (request.method) {
WearRpcMethod.ProxyStatus -> proxyStatus(request.params)
WearRpcMethod.AgentPulse -> agentPulse(request.params)
WearRpcMethod.SessionsList -> listSessions(request.params)
WearRpcMethod.AgentsList -> listAgents(request.params)
WearRpcMethod.AgentsSelect -> selectAgent(request.params)
@@ -87,6 +91,12 @@ internal class WearProxyController(
failure(request.requestId, code = "unavailable", message = "Phone gateway request failed")
}
private suspend fun agentPulse(params: JsonObject): JsonObject {
params.requireOnly("sessionKey")
val sessionKey = params.optionalStringParam("sessionKey", MAX_SESSION_KEY_CHARS)
return loadAgentPulse(sessionKey)
}
private suspend fun talkStart(
sourceNodeId: String,
params: JsonObject,
@@ -40,6 +40,330 @@ class ChatControllerSwarmProgressTest {
assertTrue(controller.swarmGroups.value.isEmpty())
}
@Test
fun readsCrossAgentDirectChildrenForWearWithoutMutatingPhoneSwarmState() =
runTest {
val target = "agent:main:wear-b"
val requests = mutableListOf<Pair<String, String?>>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
requests += method to params
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" ->
"""
{"sessions":[
{
"key":"agent:codex:subagent:gateway-watch",
"parentSessionKey":"$target",
"spawnedBy":"$target",
"status":"running",
"subagentRunState":"active",
"hasActiveSubagentRun":true
},
{
"key":"agent:clock:subagent:clock-watch",
"parentSessionKey":"$target",
"spawnedBy":"$target",
"status":"running",
"subagentRunState":"active",
"hasActiveSubagentRun":true
},
{
"key":"agent:load:subagent:load-watch",
"parentSessionKey":"$target",
"spawnedBy":"$target",
"status":"running",
"subagentRunState":"active",
"hasActiveSubagentRun":true
}
],
"totalCount":3,
"hasMore":false
}
""".trimIndent()
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val phoneSessionBefore = controller.sessionKey.value
val phoneSwarmBefore = controller.currentSwarmSnapshot()
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertTrue(snapshot?.isAvailableFor(target) == true)
assertEquals(3, snapshot?.groups?.single()?.running)
assertTrue(
requests.any { (method, params) ->
method == "chat.metadata" && params.orEmpty().contains("\"agentId\":\"main\"")
},
)
assertTrue(
requests.any { (method, params) ->
method == "sessions.list" &&
params.orEmpty().contains("\"spawnedBy\":\"$target\"") &&
!params.orEmpty().contains("\"agentId\"")
},
)
assertEquals(phoneSessionBefore, controller.sessionKey.value)
assertEquals(phoneSwarmBefore, controller.currentSwarmSnapshot())
}
@Test
fun keepsCollectGroupsSeparateFromDirectWearChildren() =
runTest {
val target = "agent:main:wear-b"
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" ->
"""
{"sessions":[
{
"key":"agent:writer:subagent:collect",
"spawnedBy":"$target",
"swarmGroupId":"swarm:$target:turn-1",
"status":"running"
},
{
"key":"agent:clock:subagent:direct",
"spawnedBy":"$target",
"status":"running",
"subagentRunState":"active"
}
],"totalCount":2,"hasMore":false}
""".trimIndent()
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertEquals(2, snapshot?.groups?.size)
assertEquals(2, snapshot?.groups?.sumOf { it.running })
}
@Test
fun acceptsGatewayFilteredWearRowsWhenNavigationParentOwnsTheChild() =
runTest {
val target = "agent:main:wear-b"
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" ->
"""
{"sessions":[{
"key":"agent:codex:subagent:foreign",
"spawnedBy":"agent:main:other",
"parentSessionKey":"$target",
"swarmGroupId":"swarm:$target:turn-1",
"status":"running"
}],"totalCount":1,"hasMore":false}
""".trimIndent()
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertTrue(snapshot?.isAvailableFor(target) == true)
assertEquals(1, snapshot?.groups?.single()?.running)
}
@Test
fun ignoresUngroupedRowsWithoutSubagentProvenance() =
runTest {
val target = "agent:main:wear-b"
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" ->
"""
{"sessions":[{
"key":"agent:main:ordinary-session",
"spawnedBy":"$target",
"status":"running"
}],"totalCount":1,"hasMore":false}
""".trimIndent()
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertTrue(snapshot?.isAvailableFor(target) == true)
assertTrue(snapshot?.groups?.isEmpty() == true)
}
@Test
fun readsEmptyWearSelectedSessionAsAvailableIdleWithoutMutatingPhoneState() =
runTest {
val target = "main"
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" -> {
assertTrue(params.orEmpty().contains("\"agentId\":\"main\""))
assertTrue(params.orEmpty().contains("\"spawnedBy\":\"$target\""))
"""{"sessions":[],"totalCount":0,"hasMore":false}"""
}
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val phoneSessionBefore = controller.sessionKey.value
val phoneSwarmBefore = controller.currentSwarmSnapshot()
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertTrue(snapshot?.isAvailableFor(target) == true)
assertTrue(snapshot?.groups?.isEmpty() == true)
assertEquals(phoneSessionBefore, controller.sessionKey.value)
assertEquals(phoneSwarmBefore, controller.currentSwarmSnapshot())
}
@Test
fun rejectsForeignAgentWearSessionBeforeAnyGatewayRead() =
runTest {
val methods = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
methods += method
error("foreign session must fail before Gateway read")
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor("agent:other:foreign", "main")
assertEquals(null, snapshot)
assertTrue(methods.isEmpty())
}
@Test
fun rejectsTruncatedWearSessionSwarmInsteadOfPublishingPartialCounts() =
runTest {
val target = "agent:main:wear-large"
var sessionsListCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, params ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" -> {
sessionsListCalls += 1
assertTrue(params.orEmpty().contains("\"limit\":1001"))
assertTrue(params.orEmpty().contains("\"offset\":0"))
"""
{
"sessions":[],
"totalCount":1001,
"hasMore":true,
"nextOffset":1001
}
""".trimIndent()
}
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor(target, "main")
assertEquals(null, snapshot)
assertEquals(1, sessionsListCalls)
}
@Test
fun disabledWearSessionSwarmStaysUnavailableWithoutListingChildren() =
runTest {
var sessionsListCalls = 0
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":false}"""
"sessions.list" -> {
sessionsListCalls += 1
error("disabled Swarm must not list children")
}
else -> error("unexpected method $method")
}
},
cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) },
)
val snapshot = controller.readSwarmSnapshotFor("agent:main:wear-b", "main")
assertEquals(false, snapshot?.enabled)
assertEquals(0, sessionsListCalls)
}
@Test
fun discardsWearSessionSwarmWhenGatewayScopeChangesDuringRead() =
runTest {
var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1)
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
when (method) {
"chat.metadata" -> """{"commands":[],"models":[],"swarmEnabled":true}"""
"sessions.list" -> {
currentScope = currentScope.copy(connectionGeneration = 2)
"""{"sessions":[],"totalCount":0,"hasMore":false}"""
}
else -> error("unexpected method $method")
}
},
cacheScope = { currentScope },
)
val phoneSessionBefore = controller.sessionKey.value
val phoneSwarmBefore = controller.currentSwarmSnapshot()
val snapshot = controller.readSwarmSnapshotFor("agent:main:wear-b", "main")
assertEquals(null, snapshot)
assertEquals(phoneSessionBefore, controller.sessionKey.value)
assertEquals(phoneSwarmBefore, controller.currentSwarmSnapshot())
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun swarmChildLifecycleStillUpdatesCanonicalSessionProjection() =
@@ -3,6 +3,7 @@ package ai.openclaw.app.chat
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -226,6 +227,26 @@ class ChatSwarmProgressTest {
assertTrue(phase.dots.any { it.status == ChatSwarmDotStatus.Running })
}
@Test
fun swarmSnapshotRequiresEnabledMatchingSessionOwner() {
val group =
ChatSwarmGroup(
groupId = "private-group",
label = "private-label",
running = 1,
done = 0,
failed = 0,
narrator = null,
phases = emptyList(),
)
val snapshot = ChatSwarmSnapshot(sessionKey = "agent:main:a", enabled = true, groups = listOf(group))
assertTrue(snapshot.isAvailableFor("agent:main:a"))
assertFalse(snapshot.isAvailableFor("agent:main:b"))
assertFalse(snapshot.copy(enabled = false).isAvailableFor("agent:main:a"))
assertFalse(snapshot.copy(sessionKey = null).isAvailableFor("agent:main:a"))
}
private fun session(
key: String,
status: String?,
@@ -0,0 +1,197 @@
package ai.openclaw.app.wear
import ai.openclaw.app.WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS
import ai.openclaw.app.chat.BackgroundTask
import ai.openclaw.app.chat.ChatSwarmDot
import ai.openclaw.app.chat.ChatSwarmDotStatus
import ai.openclaw.app.chat.ChatSwarmGroup
import ai.openclaw.app.chat.ChatSwarmPhase
import ai.openclaw.app.readWearAgentPulseConcurrently
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WearAgentPulseProjectionTest {
@Test
fun projectsOnlyBoundedAggregateFields() {
val result =
projectWearAgentPulse(
gatewayConnected = true,
tasks =
listOf(
task("private-task-queued", "queued"),
task("private-task-running", "running"),
task("private-task-completed", "completed"),
task("private-task-cancelled", "cancelled"),
),
swarmAvailable = true,
swarmGroups =
listOf(
ChatSwarmGroup(
groupId = "private-group",
label = "private-label",
running = 1,
done = 2,
failed = 3,
narrator = "private-narrator",
phases =
listOf(
ChatSwarmPhase(
key = "private-phase",
title = "private-title",
dots =
listOf(
dot("queued", ChatSwarmDotStatus.Queued),
dot("running", ChatSwarmDotStatus.Running),
dot("done", ChatSwarmDotStatus.Done),
dot("failed", ChatSwarmDotStatus.Failed),
),
hidden = 7,
),
),
),
),
pendingApprovalCount = 2,
approvalsAvailable = true,
approvalsRefreshing = false,
)
assertEquals(
Json
.parseToJsonElement(
"""{"tasks":{"state":"ready","scope":"bounded","queued":1,"running":1,"completed":1,"failed":1,"activeAtLimit":false,"recentAtLimit":false},"swarm":{"state":"active","scope":"selected-session","groups":1,"running":1,"done":2,"failed":3,"phases":[{"queued":1,"running":1,"done":1,"failed":1,"hidden":7}],"morePhases":false},"approvals":{"state":"ready","pending":2}}""",
).jsonObject,
result,
)
assertFalse(result.toString().contains("private-"))
}
@Test
fun marksBoundedLimitsAndKeepsUnknownApprovalCountUnavailable() {
val bounded =
projectWearAgentPulse(
gatewayConnected = true,
tasks =
List(100) { index -> task("active-$index", "running") } +
List(50) { index -> task("recent-$index", "failed") },
swarmAvailable = true,
swarmGroups = emptyList(),
pendingApprovalCount = 9,
approvalsAvailable = false,
approvalsRefreshing = true,
)
assertEquals(
Json
.parseToJsonElement(
"""{"tasks":{"state":"ready","scope":"bounded","queued":0,"running":100,"completed":0,"failed":50,"activeAtLimit":true,"recentAtLimit":true},"swarm":{"state":"idle","scope":"selected-session"},"approvals":{"state":"refreshing"}}""",
).jsonObject,
bounded,
)
}
@Test
fun makesEveryComponentUnavailableWhenTheGatewayRouteIsStale() {
val result =
projectWearAgentPulse(
gatewayConnected = false,
tasks = listOf(task("private-task", "running")),
swarmAvailable = true,
swarmGroups = emptyList(),
pendingApprovalCount = 1,
approvalsAvailable = true,
approvalsRefreshing = true,
)
assertEquals(
Json
.parseToJsonElement(
"""{"tasks":{"state":"unavailable"},"swarm":{"state":"unavailable"},"approvals":{"state":"unavailable"}}""",
).jsonObject,
result,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun concurrentPhoneBudgetKeepsTheCompletedComponentAndBoundsTheSlowOne() =
runTest {
var tasksStartedAt = -1L
var swarmStartedAt = -1L
val reads =
readWearAgentPulseConcurrently(
readTasks = {
tasksStartedAt = currentTime
"tasks"
},
readSwarm = {
swarmStartedAt = currentTime
delay(WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS * 2)
"swarm"
},
)
assertEquals("tasks", reads.tasks)
assertEquals(null, reads.swarm)
assertEquals(0L, tasksStartedAt)
assertEquals(0L, swarmStartedAt)
assertEquals(WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS, currentTime)
}
@Test
fun concurrentPhoneBudgetPreservesCallerCancellation() =
runTest {
val failure =
runCatching {
readWearAgentPulseConcurrently(
readTasks = { throw CancellationException("request retired") },
readSwarm = {
delay(WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS * 2)
"swarm"
},
)
}.exceptionOrNull()
assertTrue(failure is CancellationException)
}
private fun task(
id: String,
status: String,
): BackgroundTask =
BackgroundTask(
id = id,
status = status,
runtime = "private-runtime",
title = "private-title",
agentId = "private-agent",
childSessionKey = "private-session",
createdAtMs = 1,
updatedAtMs = 2,
startedAtMs = 3,
endedAtMs = 4,
progress = "private-progress",
terminal = "private-terminal",
error = "private-error",
prompt = "private-prompt",
)
private fun dot(
suffix: String,
status: ChatSwarmDotStatus,
): ChatSwarmDot =
ChatSwarmDot(
key = "private-dot-$suffix",
label = "private-child-$suffix",
status = status,
)
}
@@ -1,5 +1,7 @@
package ai.openclaw.app.wear
import ai.openclaw.app.WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS
import ai.openclaw.app.readWearAgentPulseConcurrently
import ai.openclaw.wear.shared.WearConnectionFailure
import ai.openclaw.wear.shared.WearDecodeResult
import ai.openclaw.wear.shared.WearEventType
@@ -18,7 +20,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
@@ -215,6 +220,53 @@ class WearProxyBridgeTest {
assertEquals(1L, event.sequence)
}
@Test
fun pulsePhoneBudgetReleasesQueuedEventsBeforeTheWatchDeadline() =
runTest {
val requestStarted = CompletableDeferred<Unit>()
val (bridge, sent) =
backgroundScope.recordingBridge(
handleRequest = { _, request ->
requestStarted.complete(Unit)
readWearAgentPulseConcurrently(
readTasks = {
delay(WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS * 2)
Unit
},
readSwarm = {
delay(WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS * 2)
Unit
},
)
WearMessage.Response(requestId = request.requestId, ok = true)
},
)
val requestJob =
async {
bridge.handleMessage(
"watch-1",
WearProtocolCodec.encode(request("pulse-1", WearRpcMethod.AgentPulse)),
)
}
runCurrent()
requestStarted.await()
bridge.publishConnection(connected = true, status = "Connected")
runCurrent()
assertTrue(sent.isEmpty())
advanceTimeBy(WEAR_AGENT_PULSE_PHONE_BUDGET_MILLIS - 1)
runCurrent()
assertTrue(sent.isEmpty())
advanceTimeBy(1)
runCurrent()
assertTrue(requestJob.await())
bridge.awaitIdleForTests()
assertEquals(listOf(WearProtocol.RESPONSE_PATH, WearProtocol.EVENT_PATH), sent.map { it.path })
assertTrue(currentTime < WearProtocol.RPC_REQUEST_TIMEOUT_MILLIS)
}
@Test
fun chatStreamProjectionCarriesCanonicalTextAndFallbackCompleteness() {
val projector = WearChatStreamProjector()
@@ -744,7 +796,10 @@ class WearProxyBridgeTest {
private fun SentWearMessage.event(): WearMessage.Event = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Event
private fun request(requestId: String): WearMessage.Request = WearMessage.Request(requestId = requestId, method = WearRpcMethod.ProxyStatus)
private fun request(
requestId: String,
method: WearRpcMethod = WearRpcMethod.ProxyStatus,
): WearMessage.Request = WearMessage.Request(requestId = requestId, method = method)
}
private data class SentWearMessage(
@@ -98,6 +98,53 @@ class WearProxyControllerTest {
)
}
@Test
fun agentPulseAcceptsOnlyTheSelectedSessionAndCallsItsLoaderOnce() =
runTest {
var loaderCalls = 0
var requestedSessionKey: String? = null
val projected =
buildJsonObject {
put("tasks", buildJsonObject { put("state", "unavailable") })
put("swarm", buildJsonObject { put("state", "idle") })
put("approvals", buildJsonObject { put("state", "unavailable") })
}
val controller =
WearProxyController(
requestGateway = { _, _ -> error("Agent Pulse must stay on the Phone runtime boundary") },
isGatewayConnected = { true },
gatewayStatusText = { "Connected" },
loadAgentPulse = { sessionKey ->
loaderCalls += 1
requestedSessionKey = sessionKey
projected
},
)
val response =
controller.handle(
request(
WearRpcMethod.AgentPulse,
buildJsonObject { put("sessionKey", "agent:main:thread-7") },
),
)
val rejected =
controller.handle(
request(
WearRpcMethod.AgentPulse,
buildJsonObject { put("detail", true) },
),
)
assertTrue(response.ok)
assertEquals(projected, response.result)
assertEquals("agent:main:thread-7", requestedSessionKey)
assertEquals(1, loaderCalls)
assertFalse(rejected.ok)
assertEquals("invalid_request", rejected.error?.code)
assertEquals(1, loaderCalls)
}
@Test
fun agentsAndGatewayControlsStayOnThePhoneRuntimeBoundary() =
runTest {
@@ -75,6 +75,7 @@ enum class WearProxyCapability(
GatewayControls(wireValue = "gateway-controls"),
ModelControls(wireValue = "model-controls"),
SessionSelectionLookup(wireValue = "session-selection-lookup"),
AgentPulse(wireValue = "agent-pulse"),
AttemptScopedRealtimeAudio(wireValue = "attempt-scoped-realtime-audio"),
;
@@ -100,6 +101,9 @@ enum class WearRpcMethod {
@SerialName("proxy.status")
ProxyStatus,
@SerialName("agent.pulse")
AgentPulse,
@SerialName("sessions.list")
SessionsList,
@@ -68,6 +68,7 @@ class WearProtocolTest {
val methodNames =
mapOf(
WearRpcMethod.ProxyStatus to "proxy.status",
WearRpcMethod.AgentPulse to "agent.pulse",
WearRpcMethod.SessionsList to "sessions.list",
WearRpcMethod.AgentsList to "agents.list",
WearRpcMethod.AgentsSelect to "agents.select",
@@ -143,6 +144,7 @@ class WearProtocolTest {
assertEquals("gateway-controls", WearProxyCapability.GatewayControls.wireValue)
assertEquals("model-controls", WearProxyCapability.ModelControls.wireValue)
assertEquals("session-selection-lookup", WearProxyCapability.SessionSelectionLookup.wireValue)
assertEquals("agent-pulse", WearProxyCapability.AgentPulse.wireValue)
assertEquals(
"attempt-scoped-realtime-audio",
WearProxyCapability.AttemptScopedRealtimeAudio.wireValue,
+1
View File
@@ -12,4 +12,5 @@ The watch is a paired-phone companion. It never asks for, receives, or stores Ga
- Given a final assistant message while the app is not visible and notifications are allowed, the watch shows one local-only notification with direct reply. Phone-process recreation rediscovers the reachable watch before delivery. If the preferred phone changes before a notification reply, recovery opens the app to reload the session instead of retrying the stale phone.
- Given Android 13 or newer without notification permission, Controls offers both an explicit request action and direct access to the watch's app-notification settings. Granting or revoking permission outside the app is reflected when the app resumes, and denying it leaves the rest of the companion usable.
- Given a theme or automatic-speech selection, the watch persists that local UI preference without sending it to the phone or Gateway.
- Given a connected paired phone that advertises Agent Pulse, the Pulse page pulls bounded aggregate background-task, selected-session swarm, and pending-approval state only while that page is visible and the app is resumed. Leaving the page, pausing, changing phone, agent, or session, or losing connectivity stops polling and suppresses stale Pulse state. The watch exposes no task text, identifiers, approval details or actions, credentials, or direct Gateway access.
- Given the OpenClaw Tile, tapping its mascot Talk action opens the watch app directly on Voice, while the secondary Open edge action starts on Chat. The tile uses the official Android app launcher artwork from the phone manifest's `@mipmap/ic_launcher_foreground` resource with the Core app's dark visual tokens. Neither launch starts microphone capture. Tile rendering performs no phone or network work and persists no cache.
@@ -475,6 +475,8 @@ internal fun OpenClawWearApp(
leaveConversationContext()
viewModel.selectModel(modelRef)
},
onAgentPulseVisibilityChanged = viewModel::setAgentPulseVisible,
onAgentPulseRefresh = viewModel::refreshAgentPulse,
onRefresh = viewModel::refresh,
onGatewayEnabledChange = { enabled ->
speaker.stop()
@@ -58,6 +58,10 @@ internal data class WearConversationSnapshot(
val selectedModelRef: String? = null,
val failure: WearConversationFailure? = null,
val realtimeTalk: WearRealtimeTalkSnapshot = WearRealtimeTalkSnapshot(),
val agentPulseSupported: Boolean = false,
val agentPulse: WearAgentPulseSnapshot? = null,
val agentPulseLoading: Boolean = false,
val agentPulseFailure: WearConversationFailure? = null,
)
internal enum class WearConversationFailure {
@@ -81,6 +85,9 @@ internal enum class WearInteractionState {
internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? {
if (phoneNodeId == null) return null
val pulseSupported =
connected &&
WearProxyCapability.AgentPulse in proxyCapabilities
return WearConversationSnapshot(
gatewayState = if (connected) WearGatewayState.CONNECTED else WearGatewayState.DISCONNECTED,
activeAgentId = activeAgentId,
@@ -120,5 +127,9 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? {
selectedModelRef = selectedModelRef,
failure = failure,
realtimeTalk = realtimeTalk,
agentPulseSupported = pulseSupported,
agentPulse = agentPulse.takeIf { pulseSupported },
agentPulseLoading = pulseSupported && agentPulseLoading,
agentPulseFailure = agentPulseFailure.takeIf { pulseSupported },
)
}
@@ -11,6 +11,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.UUID
@@ -26,6 +27,65 @@ internal data class WearProxyStatus(
val eventStreamId: String? = null,
)
internal enum class WearAgentPulseTaskState {
Ready,
Unavailable,
}
internal data class WearAgentPulseTasks(
val state: WearAgentPulseTaskState,
val queued: Int? = null,
val running: Int? = null,
val completed: Int? = null,
val failed: Int? = null,
val activeAtLimit: Boolean? = null,
val recentAtLimit: Boolean? = null,
)
internal enum class WearAgentPulseSwarmState {
Active,
Idle,
Unavailable,
}
internal data class WearAgentPulsePhase(
val queued: Int,
val running: Int,
val done: Int,
val failed: Int,
val hidden: Int,
)
internal data class WearAgentPulseSwarm(
val state: WearAgentPulseSwarmState,
val groups: Int? = null,
val running: Int? = null,
val done: Int? = null,
val failed: Int? = null,
val phases: List<WearAgentPulsePhase> = emptyList(),
val morePhases: Boolean? = null,
)
internal enum class WearAgentPulseApprovalsState {
Ready,
Refreshing,
Unavailable,
}
internal data class WearAgentPulseApprovals(
val state: WearAgentPulseApprovalsState,
val pending: Int? = null,
)
internal data class WearAgentPulseSnapshot(
val tasks: WearAgentPulseTasks,
val swarm: WearAgentPulseSwarm,
val approvals: WearAgentPulseApprovals,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearAgent(
val id: String,
val name: String,
@@ -158,6 +218,32 @@ internal class WearGatewayRepository(
)
}
suspend fun agentPulse(
expectedNodeId: String,
capabilities: Set<WearProxyCapability>,
selectedSessionKey: String? = null,
): WearAgentPulseSnapshot {
capabilities.require(WearProxyCapability.AgentPulse)
val response =
requester.request(
WearRpcMethod.AgentPulse,
buildJsonObject {
selectedSessionKey?.takeIf(String::isNotBlank)?.let { put("sessionKey", it) }
},
expectedNodeId,
requirePreferredNode = true,
)
val result = response.payload.asObject("agent.pulse")
return WearAgentPulseSnapshot(
tasks = parseAgentPulseTasks(result["tasks"]),
swarm = parseAgentPulseSwarm(result["swarm"]),
approvals = parseAgentPulseApprovals(result["approvals"]),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun agents(
expectedNodeId: String,
capabilities: Set<WearProxyCapability>,
@@ -423,6 +509,88 @@ internal fun parseWearChatEvent(payload: JsonElement?): WearChatEvent? {
)
}
private fun parseAgentPulseTasks(element: JsonElement?): WearAgentPulseTasks {
val source = element as? JsonObject ?: invalidAgentPulse()
return when (source.string("state")) {
"ready" -> {
if (source.string("scope") != "bounded") invalidAgentPulse()
WearAgentPulseTasks(
state = WearAgentPulseTaskState.Ready,
queued = source.nonNegativeInt("queued"),
running = source.nonNegativeInt("running"),
completed = source.nonNegativeInt("completed"),
failed = source.nonNegativeInt("failed"),
activeAtLimit = source.requiredBoolean("activeAtLimit"),
recentAtLimit = source.requiredBoolean("recentAtLimit"),
)
}
"unavailable" -> WearAgentPulseTasks(state = WearAgentPulseTaskState.Unavailable)
else -> invalidAgentPulse()
}
}
private fun parseAgentPulseSwarm(element: JsonElement?): WearAgentPulseSwarm {
val source = element as? JsonObject ?: invalidAgentPulse()
return when (source.string("state")) {
"active" -> {
if (source.string("scope") != "selected-session") invalidAgentPulse()
val phases = source["phases"] as? JsonArray ?: invalidAgentPulse()
if (phases.size > MAX_AGENT_PULSE_PHASES) invalidAgentPulse()
WearAgentPulseSwarm(
state = WearAgentPulseSwarmState.Active,
groups = source.nonNegativeInt("groups"),
running = source.nonNegativeInt("running"),
done = source.nonNegativeInt("done"),
failed = source.nonNegativeInt("failed"),
phases = phases.map(::parseAgentPulsePhase),
morePhases = source.requiredBoolean("morePhases"),
)
}
"idle" -> {
if (source.string("scope") != "selected-session") invalidAgentPulse()
WearAgentPulseSwarm(state = WearAgentPulseSwarmState.Idle)
}
"unavailable" -> WearAgentPulseSwarm(state = WearAgentPulseSwarmState.Unavailable)
else -> invalidAgentPulse()
}
}
private fun parseAgentPulsePhase(element: JsonElement): WearAgentPulsePhase {
val source = element as? JsonObject ?: invalidAgentPulse()
return WearAgentPulsePhase(
queued = source.nonNegativeInt("queued"),
running = source.nonNegativeInt("running"),
done = source.nonNegativeInt("done"),
failed = source.nonNegativeInt("failed"),
hidden = source.nonNegativeInt("hidden"),
)
}
private fun parseAgentPulseApprovals(element: JsonElement?): WearAgentPulseApprovals {
val source = element as? JsonObject ?: invalidAgentPulse()
return when (source.string("state")) {
"ready" ->
WearAgentPulseApprovals(
state = WearAgentPulseApprovalsState.Ready,
pending = source.nonNegativeInt("pending"),
)
"refreshing" -> WearAgentPulseApprovals(state = WearAgentPulseApprovalsState.Refreshing)
"unavailable" -> WearAgentPulseApprovals(state = WearAgentPulseApprovalsState.Unavailable)
else -> invalidAgentPulse()
}
}
private fun JsonObject.nonNegativeInt(name: String): Int {
val value = (this[name] as? JsonPrimitive)?.takeUnless(JsonPrimitive::isString)?.intOrNull
return value?.takeIf { it >= 0 } ?: invalidAgentPulse()
}
private fun JsonObject.requiredBoolean(name: String): Boolean = boolean(name) ?: invalidAgentPulse()
private fun invalidAgentPulse(): Nothing = throw WearProxyException("invalid_response", "agent.pulse returned invalid data")
private const val MAX_AGENT_PULSE_PHASES = 8
private fun parseSession(
element: JsonElement,
phoneNodeId: String,
@@ -397,6 +397,10 @@ internal data class WearResponseRequest(
val eventGeneration: Long,
)
internal data class WearReadOnlyResponseRequest(
val eventGeneration: Long,
)
internal class WearEventSequenceTracker {
private var streamId: String? = null
private var lastSequence: Long? = null
@@ -461,6 +465,11 @@ internal class WearEventSequenceTracker {
return WearResponseRequest(responseGeneration = responseGeneration, eventGeneration = eventGeneration)
}
// Read-only projections may overlap a model request. Their owner supplies
// feature-local cancellation, while this token only binds the event cursor.
@Synchronized
fun beginReadOnlyResponseRequest(): WearReadOnlyResponseRequest = WearReadOnlyResponseRequest(eventGeneration = eventGeneration)
@Synchronized
fun invalidateResponseRequests() {
responseGeneration += 1
@@ -473,11 +482,31 @@ internal class WearEventSequenceTracker {
sequence: Long?,
): Boolean {
if (request.responseGeneration != responseGeneration) return false
return isEventCursorCurrent(request.eventGeneration, streamId, sequence)
}
@Synchronized
fun isReadOnlyResponseCurrent(
request: WearReadOnlyResponseRequest,
streamId: String?,
sequence: Long?,
): Boolean = isEventCursorCurrent(request.eventGeneration, streamId, sequence)
private fun isEventCursorCurrent(
requestEventGeneration: Long,
responseStreamId: String?,
sequence: Long?,
): Boolean {
if (awaitingSnapshot) return false
if (this.streamId != streamId && (this.streamId != null || streamId != null)) return false
if (
this.streamId != responseStreamId &&
(this.streamId != null || responseStreamId != null)
) {
return false
}
val currentSequence = lastSequence
return if (sequence == null) {
request.eventGeneration == eventGeneration
requestEventGeneration == eventGeneration
} else {
sequence == currentSequence
}
@@ -38,6 +38,7 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -70,6 +71,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.foundation.pager.HorizontalPager
@@ -90,8 +94,16 @@ internal enum class WearHomePage {
Chat,
Voice,
Controls,
Pulse,
}
internal fun wearHomePages(agentPulseSupported: Boolean): List<WearHomePage> =
if (agentPulseSupported) {
WearHomePage.entries
} else {
WearHomePage.entries.filterNot { it == WearHomePage.Pulse }
}
private const val VOICE_MODE_COUNT = 2
private const val VOICE_HOME_MODE = 0
private const val VOICE_THREAD_MODE = 1
@@ -154,6 +166,8 @@ internal fun OpenClawWearScreens(
onSelectAgent: (String) -> Unit,
onSelectSession: (String) -> Unit,
onSelectModel: (String) -> Unit,
onAgentPulseVisibilityChanged: (Boolean) -> Unit = {},
onAgentPulseRefresh: () -> Unit = {},
onRefresh: () -> Unit,
onGatewayEnabledChange: (Boolean) -> Unit,
onThemeModeChange: (WearThemeMode) -> Unit,
@@ -163,6 +177,47 @@ internal fun OpenClawWearScreens(
onSpeakLatest: () -> Unit,
onStopSpeaking: () -> Unit,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val agentPulseSupported = snapshot?.agentPulseSupported == true
val homePages = remember(agentPulseSupported) { wearHomePages(agentPulseSupported) }
val initialPageIndex = homePages.indexOf(initialPage).takeIf { it >= 0 } ?: 0
val pagerState =
rememberPagerState(
initialPage = initialPageIndex,
pageCount = { homePages.size },
)
var lifecycleResumed by remember(lifecycleOwner) {
mutableStateOf(
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED),
)
}
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, _ ->
val resumed =
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
lifecycleResumed = resumed
if (!resumed) {
onAgentPulseVisibilityChanged(false)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
onAgentPulseVisibilityChanged(false)
}
}
LaunchedEffect(homePages) {
if (homePages.getOrNull(pagerState.currentPage) == null) {
pagerState.scrollToPage(homePages.indexOf(WearHomePage.Chat))
}
}
LaunchedEffect(pagerState, lifecycleResumed, snapshot != null, homePages) {
snapshotFlow { homePages.getOrNull(pagerState.currentPage) == WearHomePage.Pulse }
.collect { selected ->
onAgentPulseVisibilityChanged(snapshot != null && lifecycleResumed && selected)
}
}
if (snapshot == null) {
ConnectionStateScreen(
loading = loading,
@@ -173,11 +228,6 @@ internal fun OpenClawWearScreens(
}
val colors = OpenClawWearTheme.colors
val pagerState =
rememberPagerState(
initialPage = initialPage.ordinal,
pageCount = { WearHomePage.entries.size },
)
val voicePagerState = rememberPagerState(pageCount = { VOICE_MODE_COUNT })
val pagerScope = rememberCoroutineScope()
val realtimeActive = snapshot.realtimeTalk.active || realtimeCapturing
@@ -187,11 +237,12 @@ internal fun OpenClawWearScreens(
LaunchedEffect(navigationRequest?.id) {
val request = navigationRequest ?: return@LaunchedEffect
val destination = wearLaunchPage(request.target, realtimeActive)
pagerState.scrollToPage(destination.ordinal)
val destinationIndex = homePages.indexOf(destination).takeIf { it >= 0 } ?: 0
pagerState.scrollToPage(destinationIndex)
onNavigationRequestHandled(request.id)
}
LaunchedEffect(pagerState.currentPage, showVoiceSwipeHint) {
if (pagerState.currentPage == WearHomePage.Voice.ordinal && showVoiceSwipeHint) {
if (homePages.getOrNull(pagerState.currentPage) == WearHomePage.Voice && showVoiceSwipeHint) {
delay(1_800L)
showVoiceSwipeHint = false
}
@@ -212,9 +263,9 @@ internal fun OpenClawWearScreens(
delay(250L)
}
}
BackHandler(enabled = pagerState.currentPage == WearHomePage.Voice.ordinal) {
BackHandler(enabled = homePages.getOrNull(pagerState.currentPage) == WearHomePage.Voice) {
pagerScope.launch {
pagerState.animateScrollToPage(WearHomePage.Chat.ordinal)
pagerState.animateScrollToPage(homePages.indexOf(WearHomePage.Chat))
}
}
HorizontalPagerScaffold(
@@ -229,12 +280,12 @@ internal fun OpenClawWearScreens(
modifier = Modifier.fillMaxSize(),
rotaryScrollableBehavior = null,
userScrollEnabled =
pagerState.currentPage != WearHomePage.Voice.ordinal ||
homePages.getOrNull(pagerState.currentPage) != WearHomePage.Voice ||
voicePagerState.currentPage == VOICE_HOME_MODE ||
voicePagerState.currentPage == VOICE_THREAD_MODE,
) { page ->
when (page) {
WearHomePage.Chat.ordinal ->
when (homePages.getOrNull(page)) {
WearHomePage.Chat ->
ChatPage(
snapshot = snapshot,
interaction = interaction,
@@ -251,25 +302,10 @@ internal fun OpenClawWearScreens(
onSpeakLatest = onSpeakLatest,
onStopSpeaking = onStopSpeaking,
)
WearHomePage.Controls.ordinal ->
ControlsPage(
snapshot = snapshot,
themeMode = themeMode,
autoSpeak = autoSpeak,
notificationsGranted = notificationsGranted,
gatewayControlSupported = snapshot.gatewayControlsSupported,
actionBusy = actionBusy,
onThemeModeChange = onThemeModeChange,
onAutoSpeakChange = onAutoSpeakChange,
onRequestNotifications = onRequestNotifications,
onOpenNotificationSettings = onOpenNotificationSettings,
onRefresh = onRefresh,
onGatewayEnabledChange = onGatewayEnabledChange,
)
else ->
WearHomePage.Voice ->
VoicePage(
voicePagerState = voicePagerState,
showSwipeHint = showVoiceSwipeHint && pagerState.currentPage == WearHomePage.Voice.ordinal,
showSwipeHint = showVoiceSwipeHint && homePages.getOrNull(pagerState.currentPage) == WearHomePage.Voice,
realtimeTalk = snapshot.realtimeTalk,
speaking = speaking,
realtimeCapturing = realtimeCapturing,
@@ -285,6 +321,33 @@ internal fun OpenClawWearScreens(
onRealtimeTalk = onRealtimeTalk,
onStopSpeaking = onStopSpeaking,
)
WearHomePage.Controls ->
ControlsPage(
snapshot = snapshot,
themeMode = themeMode,
autoSpeak = autoSpeak,
notificationsGranted = notificationsGranted,
gatewayControlSupported = snapshot.gatewayControlsSupported,
actionBusy = actionBusy,
onThemeModeChange = onThemeModeChange,
onAutoSpeakChange = onAutoSpeakChange,
onRequestNotifications = onRequestNotifications,
onOpenNotificationSettings = onOpenNotificationSettings,
onRefresh = onRefresh,
onGatewayEnabledChange = onGatewayEnabledChange,
)
WearHomePage.Pulse ->
AgentPulsePage(
snapshot = snapshot,
onRefresh = {
if (snapshot.agentPulseSupported) {
onAgentPulseRefresh()
} else {
onRefresh()
}
},
)
else -> Unit
}
}
}
@@ -1425,6 +1488,254 @@ private fun ControlsPage(
}
}
@Composable
private fun AgentPulsePage(
snapshot: WearConversationSnapshot,
onRefresh: () -> Unit,
) {
val pulse = snapshot.agentPulse
WearPage(pageLabel = stringResource(R.string.pulse)) {
when {
snapshot.gatewayState != WearGatewayState.CONNECTED ->
item {
EmptyPanel(
title = stringResource(R.string.pulse_unavailable),
detail = stringResource(R.string.gateway_offline_detail),
)
}
!snapshot.agentPulseSupported ->
item {
EmptyPanel(
title = stringResource(R.string.pulse_unavailable),
detail = stringResource(R.string.update_required_detail),
)
}
pulse == null ->
item {
EmptyPanel(
title =
if (snapshot.agentPulseLoading) {
stringResource(R.string.pulse_loading)
} else {
stringResource(R.string.pulse_unavailable)
},
detail =
when {
snapshot.agentPulseLoading -> stringResource(R.string.pulse_loading_detail)
snapshot.agentPulseFailure != null -> failureDetail(snapshot.agentPulseFailure)
else -> stringResource(R.string.try_again)
},
)
}
else -> {
item { AgentPulseTasksPanel(pulse.tasks) }
item { AgentPulseSwarmPanel(pulse.swarm) }
item { AgentPulseApprovalsPanel(pulse.approvals) }
snapshot.agentPulseFailure?.let { pulseFailure ->
item { InlineError(text = failureDetail(pulseFailure)) }
}
}
}
item {
SecondaryButton(
label = stringResource(R.string.refresh),
enabled = !snapshot.agentPulseLoading,
onClick = onRefresh,
)
}
}
}
@Composable
private fun AgentPulseTasksPanel(tasks: WearAgentPulseTasks) {
val ready = tasks.state == WearAgentPulseTaskState.Ready
Panel {
AgentPulsePanelHeader(
title = stringResource(R.string.pulse_tasks),
status =
if (ready) {
stringResource(R.string.pulse_ready)
} else {
stringResource(R.string.pulse_unavailable)
},
statusColor =
if (ready) {
OpenClawWearTheme.colors.success
} else {
OpenClawWearTheme.colors.danger
},
)
if (ready) {
AgentPulseMetricRow(stringResource(R.string.pulse_queued), tasks.queued)
AgentPulseMetricRow(stringResource(R.string.pulse_running), tasks.running)
AgentPulseMetricRow(stringResource(R.string.pulse_completed), tasks.completed)
AgentPulseMetricRow(stringResource(R.string.pulse_failed), tasks.failed)
AgentPulseDetail(text = stringResource(R.string.pulse_task_snapshot_bounded))
if (tasks.activeAtLimit == true) {
AgentPulseDetail(
text = stringResource(R.string.pulse_active_at_limit),
color = OpenClawWearTheme.colors.warning,
)
}
if (tasks.recentAtLimit == true) {
AgentPulseDetail(
text = stringResource(R.string.pulse_recent_at_limit),
color = OpenClawWearTheme.colors.warning,
)
}
}
}
}
@Composable
private fun AgentPulseSwarmPanel(swarm: WearAgentPulseSwarm) {
val colors = OpenClawWearTheme.colors
val status =
when (swarm.state) {
WearAgentPulseSwarmState.Active -> stringResource(R.string.pulse_swarm_active)
WearAgentPulseSwarmState.Idle -> stringResource(R.string.pulse_swarm_idle)
WearAgentPulseSwarmState.Unavailable -> stringResource(R.string.pulse_unavailable)
}
val statusColor =
when (swarm.state) {
WearAgentPulseSwarmState.Active -> colors.warning
WearAgentPulseSwarmState.Idle -> colors.success
WearAgentPulseSwarmState.Unavailable -> colors.danger
}
Panel {
AgentPulsePanelHeader(
title = stringResource(R.string.pulse_swarm),
status = status,
statusColor = statusColor,
)
if (swarm.state == WearAgentPulseSwarmState.Active) {
AgentPulseMetricRow(stringResource(R.string.pulse_groups), swarm.groups)
AgentPulseMetricRow(stringResource(R.string.pulse_running), swarm.running)
AgentPulseMetricRow(stringResource(R.string.pulse_done), swarm.done)
AgentPulseMetricRow(stringResource(R.string.pulse_failed), swarm.failed)
swarm.phases.forEachIndexed { index, phase ->
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.pulse_phase, index + 1),
color = colors.text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text =
stringResource(
R.string.pulse_phase_counts,
phase.queued,
phase.running,
phase.done,
phase.failed,
phase.hidden,
),
color = colors.textMuted,
fontSize = 10.sp,
lineHeight = 14.sp,
)
}
if (swarm.morePhases == true) {
AgentPulseDetail(text = stringResource(R.string.pulse_more_phases))
}
}
}
}
@Composable
private fun AgentPulseApprovalsPanel(approvals: WearAgentPulseApprovals) {
val colors = OpenClawWearTheme.colors
val status =
when (approvals.state) {
WearAgentPulseApprovalsState.Ready -> stringResource(R.string.pulse_ready)
WearAgentPulseApprovalsState.Refreshing -> stringResource(R.string.pulse_refreshing)
WearAgentPulseApprovalsState.Unavailable -> stringResource(R.string.pulse_unavailable)
}
val statusColor =
when (approvals.state) {
WearAgentPulseApprovalsState.Ready -> colors.success
WearAgentPulseApprovalsState.Refreshing -> colors.warning
WearAgentPulseApprovalsState.Unavailable -> colors.danger
}
Panel {
AgentPulsePanelHeader(
title = stringResource(R.string.pulse_attention),
status = status,
statusColor = statusColor,
)
if (approvals.state == WearAgentPulseApprovalsState.Ready) {
AgentPulseMetricRow(
label = stringResource(R.string.pulse_pending_requests),
value = approvals.pending,
)
}
}
}
@Composable
private fun AgentPulsePanelHeader(
title: String,
status: String,
statusColor: Color,
) {
Text(
text = title,
color = OpenClawWearTheme.colors.text,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = stringResource(R.string.pulse_status, status),
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(6.dp))
}
@Composable
private fun AgentPulseMetricRow(
label: String,
value: Int?,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
color = OpenClawWearTheme.colors.textMuted,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
)
Text(
text = value?.toString() ?: stringResource(R.string.pulse_unknown),
color = OpenClawWearTheme.colors.text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun AgentPulseDetail(
text: String,
color: Color = OpenClawWearTheme.colors.textMuted,
) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
color = color,
fontSize = 10.sp,
lineHeight = 14.sp,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun ConnectionStateScreen(
loading: Boolean,
@@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -45,6 +46,9 @@ internal data class WearUiState(
val talkBusy: Boolean = false,
val controlBusy: Boolean = false,
val failure: WearConversationFailure? = null,
val agentPulse: WearAgentPulseSnapshot? = null,
val agentPulseLoading: Boolean = false,
val agentPulseFailure: WearConversationFailure? = null,
)
internal fun WearUiState.resetForPhoneChange(): WearUiState =
@@ -71,6 +75,9 @@ internal fun WearUiState.resetForPhoneChange(): WearUiState =
talkBusy = false,
controlBusy = false,
failure = null,
agentPulse = null,
agentPulseLoading = false,
agentPulseFailure = null,
)
internal fun WearUiState.switchAgentContext(agentId: String): WearUiState =
@@ -83,6 +90,9 @@ internal fun WearUiState.switchAgentContext(agentId: String): WearUiState =
activeRunId = null,
selectedModelRef = null,
models = emptyList(),
agentPulse = null,
agentPulseLoading = false,
agentPulseFailure = null,
)
internal fun WearUiState.switchSessionContext(session: WearSession): WearUiState =
@@ -97,6 +107,9 @@ internal fun WearUiState.switchSessionContext(session: WearSession): WearUiState
realtimeMouthLevel = 0f,
talkBusy = false,
failure = null,
agentPulse = null,
agentPulseLoading = false,
agentPulseFailure = null,
)
internal fun WearUiState.switchModelContext(modelRef: String): WearUiState {
@@ -187,6 +200,9 @@ internal class WearViewModel(
private val controlBusyOwner = WearControlBusyOwner()
private var loadJob: Job? = null
private var phoneRouteGeneration = 0L
private var agentPulsePollJob: Job? = null
private var agentPulseVisible = false
private var agentPulseRequestGeneration = 0L
// Session switches clear the prior bounded catalog; only the matching phone/session may refill it.
private var modelLoadJob: Job? = null
@@ -242,6 +258,26 @@ internal class WearViewModel(
loadSessions()
}
fun setAgentPulseVisible(visible: Boolean) {
if (agentPulseVisible == visible) {
if (visible && agentPulsePollJob?.isActive != true) {
restartAgentPulsePolling(forceLoading = true)
}
return
}
agentPulseVisible = visible
if (visible) {
restartAgentPulsePolling(forceLoading = true)
} else {
invalidateAgentPulse(clearSnapshot = true)
}
}
fun refreshAgentPulse() {
if (!agentPulseVisible) return
restartAgentPulsePolling(forceLoading = true)
}
fun openSession(session: WearSession) {
val current = mutableState.value
if (
@@ -256,9 +292,11 @@ internal class WearViewModel(
}
endRealtimeTalkForNavigation()
cancelModelLoad()
invalidateAgentPulse(clearSnapshot = true)
mutableState.update { it.switchSessionContext(session) }
loadModels(session)
loadHistory(session)
restartAgentPulsePolling(forceLoading = true)
}
fun closeSession() {
@@ -548,6 +586,7 @@ internal class WearViewModel(
}
private fun loadSessions(expectedNodeId: String? = null) {
invalidateAgentPulse(clearSnapshot = true)
cancelLoad()
cancelModelLoad()
loadJob =
@@ -677,6 +716,8 @@ internal class WearViewModel(
pendingEvents.forEach(::handleEvent)
if (status.connected && selectedSession != null) {
loadHistory(selectedSession)
} else {
restartAgentPulsePolling(forceLoading = true)
}
} catch (err: CancellationException) {
throw err
@@ -771,6 +812,7 @@ internal class WearViewModel(
)
}
pendingEvents.forEach(::handleEvent)
restartAgentPulsePolling(forceLoading = true)
if (catalogScopeChanged) loadModels(loadedSession)
} catch (err: CancellationException) {
throw err
@@ -786,6 +828,7 @@ internal class WearViewModel(
}
if (currentLoad && mutableState.value.selectedSession?.key == session.key) {
recordFailure(err, loading = false)
restartAgentPulsePolling(forceLoading = true)
}
}
}
@@ -890,6 +933,7 @@ internal class WearViewModel(
sourceChanged: Boolean,
) {
// A source switch or sequence gap invalidates the old phone's live state.
invalidateAgentPulse(clearSnapshot = true)
// Buffer this boundary event until the selected phone supplies a watermark.
eventSequenceTracker.requireSnapshot()
resyncEventBuffer.start(event)
@@ -932,6 +976,7 @@ internal class WearViewModel(
}
private fun resetForPhoneRouteChange() {
invalidateAgentPulse(clearSnapshot = true)
phoneRouteGeneration += 1
controlBusyOwner.reset()
mutableState.update(WearUiState::resetForPhoneChange)
@@ -952,6 +997,7 @@ internal class WearViewModel(
cancelLoad()
val connected = payload.boolean("connected") ?: false
if (!connected) {
invalidateAgentPulse(clearSnapshot = true)
talkStartJob?.cancel()
talkStartJob = null
talkAttemptId = null
@@ -1028,6 +1074,181 @@ internal class WearViewModel(
eventSequenceTracker.invalidateResponseRequests()
}
private fun restartAgentPulsePolling(forceLoading: Boolean = false) {
invalidateAgentPulse(clearSnapshot = false)
val initialState = mutableState.value
if (!shouldPollAgentPulse(initialState, agentPulseVisible)) {
mutableState.update {
it.copy(
agentPulse = null,
agentPulseLoading = false,
agentPulseFailure = null,
)
}
return
}
val phoneNodeId = initialState.phoneNodeId ?: return
val activeAgentId = initialState.activeAgentId
val selectedSessionKey = initialState.selectedSession?.key
val routeGeneration = phoneRouteGeneration
val requestGeneration = agentPulseRequestGeneration
agentPulsePollJob =
viewModelScope.launch {
var showForcedLoading = forceLoading
try {
while (
isCurrentAgentPulseRoute(
phoneNodeId = phoneNodeId,
activeAgentId = activeAgentId,
selectedSessionKey = selectedSessionKey,
routeGeneration = routeGeneration,
requestGeneration = requestGeneration,
)
) {
mutableState.update { state ->
if (
isCurrentAgentPulseRoute(
phoneNodeId,
activeAgentId,
selectedSessionKey,
routeGeneration,
requestGeneration,
state,
)
) {
state.copy(
agentPulseLoading = showForcedLoading || state.agentPulse == null,
agentPulseFailure = null,
)
} else {
state
}
}
try {
val responseRequest = eventSequenceTracker.beginReadOnlyResponseRequest()
val pulse =
repository.agentPulse(
expectedNodeId = phoneNodeId,
capabilities = initialState.proxyCapabilities,
selectedSessionKey = selectedSessionKey,
)
if (
pulse.phoneNodeId != phoneNodeId ||
!isCurrentAgentPulseRoute(
phoneNodeId,
activeAgentId,
selectedSessionKey,
routeGeneration,
requestGeneration,
)
) {
return@launch
}
if (
!eventSequenceTracker.isReadOnlyResponseCurrent(
responseRequest,
pulse.eventStreamId,
pulse.eventSequence,
)
) {
// Rebuild from a canonical snapshot instead of exposing an old Phone cursor.
loadSessions(pulse.phoneNodeId)
return@launch
}
mutableState.update { state ->
if (
isCurrentAgentPulseRoute(
phoneNodeId,
activeAgentId,
selectedSessionKey,
routeGeneration,
requestGeneration,
state,
)
) {
state.copy(
agentPulse = pulse,
agentPulseLoading = false,
agentPulseFailure = null,
)
} else {
state
}
}
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
if (
err is WearProxyException &&
err.code == "phone_changed" &&
requestGeneration == agentPulseRequestGeneration
) {
reloadForPreferredPhone(nodeId = null)
return@launch
}
mutableState.update { state ->
if (
isCurrentAgentPulseRoute(
phoneNodeId,
activeAgentId,
selectedSessionKey,
routeGeneration,
requestGeneration,
state,
)
) {
state.copy(
agentPulse = if (err.isConnectivityFailure()) null else state.agentPulse,
agentPulseLoading = false,
agentPulseFailure = err.toWearConversationFailure(),
)
} else {
state
}
}
}
showForcedLoading = false
delay(AGENT_PULSE_POLL_INTERVAL_MS)
}
} finally {
if (agentPulsePollJob === coroutineContext[Job]) agentPulsePollJob = null
}
}
}
private fun isCurrentAgentPulseRoute(
phoneNodeId: String,
activeAgentId: String?,
selectedSessionKey: String?,
routeGeneration: Long,
requestGeneration: Long,
state: WearUiState = mutableState.value,
): Boolean =
wearAgentPulseRouteIsCurrent(
requestedPhoneNodeId = phoneNodeId,
requestedAgentId = activeAgentId,
requestedSessionKey = selectedSessionKey,
requestedRouteGeneration = routeGeneration,
currentRouteGeneration = phoneRouteGeneration,
requestedGeneration = requestGeneration,
currentGeneration = agentPulseRequestGeneration,
pulseVisible = agentPulseVisible,
state = state,
)
private fun invalidateAgentPulse(clearSnapshot: Boolean) {
agentPulseRequestGeneration += 1
agentPulsePollJob?.cancel()
agentPulsePollJob = null
mutableState.update {
it.copy(
agentPulse = if (clearSnapshot) null else it.agentPulse,
agentPulseLoading = false,
agentPulseFailure = null,
)
}
}
private fun reloadHistoryIfSelected(
session: WearSession,
routeGeneration: Long,
@@ -1090,6 +1311,7 @@ internal class WearViewModel(
) {
val disconnected = error.isConnectivityFailure()
if (disconnected) {
invalidateAgentPulse(clearSnapshot = true)
talkStartJob?.cancel()
talkStartJob = null
talkAttemptId = null
@@ -1132,11 +1354,44 @@ internal class WearViewModel(
override fun onCleared() {
modelLoadJob?.cancel()
agentPulseVisible = false
agentPulseRequestGeneration += 1
agentPulsePollJob?.cancel()
agentPulsePollJob = null
talkStartJob?.cancel()
realtimeTalkClient.shutdown()
}
}
private const val AGENT_PULSE_POLL_INTERVAL_MS = 15_000L
internal fun shouldPollAgentPulse(
state: WearUiState,
pulseVisible: Boolean,
): Boolean =
pulseVisible &&
state.connected &&
!state.phoneNodeId.isNullOrBlank() &&
WearProxyCapability.AgentPulse in state.proxyCapabilities
internal fun wearAgentPulseRouteIsCurrent(
requestedPhoneNodeId: String,
requestedAgentId: String?,
requestedSessionKey: String?,
requestedRouteGeneration: Long,
currentRouteGeneration: Long,
requestedGeneration: Long,
currentGeneration: Long,
pulseVisible: Boolean,
state: WearUiState,
): Boolean =
requestedRouteGeneration == currentRouteGeneration &&
requestedGeneration == currentGeneration &&
shouldPollAgentPulse(state, pulseVisible) &&
state.phoneNodeId == requestedPhoneNodeId &&
state.activeAgentId == requestedAgentId &&
state.selectedSession?.key == requestedSessionKey
internal fun coherentWearActiveSessionKey(
statusAgentId: String?,
statusSessionKey: String?,
@@ -88,4 +88,30 @@
<string name="tile_description">Open sessions and reply through your paired phone</string>
<string name="tile_open">OPEN</string>
<string name="tile_phone_proxy">PHONE PROXY</string>
<string name="pulse" tools:ignore="MissingTranslation">Pulse</string>
<string name="pulse_tasks" tools:ignore="MissingTranslation">Background tasks</string>
<string name="pulse_swarm" tools:ignore="MissingTranslation">Session swarm</string>
<string name="pulse_attention" tools:ignore="MissingTranslation">Attention</string>
<string name="pulse_status" tools:ignore="MissingTranslation">Status: %1$s</string>
<string name="pulse_ready" tools:ignore="MissingTranslation">Ready</string>
<string name="pulse_refreshing" tools:ignore="MissingTranslation">Refreshing</string>
<string name="pulse_unavailable" tools:ignore="MissingTranslation">Unavailable</string>
<string name="pulse_loading" tools:ignore="MissingTranslation">Loading Pulse</string>
<string name="pulse_loading_detail" tools:ignore="MissingTranslation">Reading bounded aggregates from the paired phone.</string>
<string name="pulse_queued" tools:ignore="MissingTranslation">Queued</string>
<string name="pulse_running" tools:ignore="MissingTranslation">Running</string>
<string name="pulse_completed" tools:ignore="MissingTranslation">Completed</string>
<string name="pulse_done" tools:ignore="MissingTranslation">Done</string>
<string name="pulse_failed" tools:ignore="MissingTranslation">Failed</string>
<string name="pulse_groups" tools:ignore="MissingTranslation">Groups</string>
<string name="pulse_pending_requests" tools:ignore="MissingTranslation">Pending requests</string>
<string name="pulse_task_snapshot_bounded" tools:ignore="MissingTranslation">Bounded active and recent snapshot</string>
<string name="pulse_active_at_limit" tools:ignore="MissingTranslation">Active task list is at its limit</string>
<string name="pulse_recent_at_limit" tools:ignore="MissingTranslation">Recent task list is at its limit</string>
<string name="pulse_swarm_active" tools:ignore="MissingTranslation">Active</string>
<string name="pulse_swarm_idle" tools:ignore="MissingTranslation">Idle</string>
<string name="pulse_phase" tools:ignore="MissingTranslation">Phase %1$d</string>
<string name="pulse_phase_counts" tools:ignore="MissingTranslation">Queued %1$d, Running %2$d, Done %3$d, Failed %4$d, Hidden %5$d</string>
<string name="pulse_more_phases" tools:ignore="MissingTranslation">More phases not shown</string>
<string name="pulse_unknown" tools:ignore="MissingTranslation">Unknown</string>
</resources>
@@ -1,5 +1,6 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import ai.openclaw.wear.shared.WearRealtimeTalkEntry
import ai.openclaw.wear.shared.WearRealtimeTalkRole
import ai.openclaw.wear.shared.WearRealtimeTalkSnapshot
@@ -266,6 +267,92 @@ class MainActivityTest {
assertNull(snapshot?.sessions?.single()?.title)
}
@Test
fun conversationSnapshotExposesPulseOnlyForConnectedCapablePhone() {
val pulse =
WearAgentPulseSnapshot(
tasks =
WearAgentPulseTasks(
state = WearAgentPulseTaskState.Ready,
queued = 2,
running = 3,
completed = 5,
failed = 1,
activeAtLimit = false,
recentAtLimit = false,
),
swarm =
WearAgentPulseSwarm(
state = WearAgentPulseSwarmState.Active,
groups = 1,
running = 1,
done = 0,
failed = 0,
phases =
listOf(
WearAgentPulsePhase(
queued = 2,
running = 1,
done = 0,
failed = 0,
hidden = 0,
),
),
morePhases = false,
),
approvals =
WearAgentPulseApprovals(
state = WearAgentPulseApprovalsState.Ready,
pending = 2,
),
eventSequence = 7L,
phoneNodeId = "phone-1",
eventStreamId = "epoch-1",
)
val capable =
WearUiState(
loading = false,
connected = true,
phoneNodeId = "phone-1",
proxyCapabilities = setOf(WearProxyCapability.AgentPulse),
agentPulse = pulse,
agentPulseLoading = true,
agentPulseFailure = WearConversationFailure.INTERNAL_ERROR,
).toConversationSnapshot()
assertTrue(capable?.agentPulseSupported == true)
assertEquals(pulse, capable?.agentPulse)
assertTrue(capable?.agentPulseLoading == true)
assertEquals(WearConversationFailure.INTERNAL_ERROR, capable?.agentPulseFailure)
val offline =
WearUiState(
loading = false,
connected = false,
phoneNodeId = "phone-1",
proxyCapabilities = setOf(WearProxyCapability.AgentPulse),
agentPulse = pulse,
agentPulseLoading = true,
agentPulseFailure = WearConversationFailure.INTERNAL_ERROR,
).toConversationSnapshot()
assertFalse(offline?.agentPulseSupported == true)
assertNull(offline?.agentPulse)
assertFalse(offline?.agentPulseLoading == true)
assertNull(offline?.agentPulseFailure)
val unsupported =
WearUiState(
loading = false,
connected = true,
phoneNodeId = "phone-1",
agentPulse = pulse,
).toConversationSnapshot()
assertFalse(unsupported?.agentPulseSupported == true)
assertNull(unsupported?.agentPulse)
}
@Test
fun connectionEventsPreserveTypedAndLegacyIncompatibilityReasons() {
assertEquals(
@@ -28,6 +28,127 @@ class WearGatewayRepositoryTest {
assertFalse(shouldAcceptWearTalkSnapshot(WearRealtimeTalkSnapshot(), "attempt-current"))
}
@Test
fun agentPulseRequiresNegotiatedCapabilityBeforeSendingItsRpc() =
runTest {
val requester = RecordingRequester { method, _ -> error("unexpected $method") }
val repository = WearGatewayRepository(requester)
val failure =
runCatching {
repository.agentPulse(
expectedNodeId = "phone-a",
capabilities = emptySet(),
selectedSessionKey = "agent:main",
)
}.exceptionOrNull()
assertEquals("unsupported_peer", (failure as? WearProxyException)?.code)
assertTrue(requester.calls.isEmpty())
}
@Test
fun agentPulseParsesOnlyAggregateCountsAndRequiresThePreferredPhone() =
runTest {
val requester =
RecordingRequester { method, _ ->
assertEquals(WearRpcMethod.AgentPulse, method)
json.parseToJsonElement(
"""{"tasks":{"state":"ready","scope":"bounded","queued":2,"running":3,"completed":5,"failed":1,"activeAtLimit":false,"recentAtLimit":true},"swarm":{"state":"active","scope":"selected-session","groups":2,"running":4,"done":6,"failed":1,"phases":[{"queued":1,"running":2,"done":3,"failed":0,"hidden":4}],"morePhases":false},"approvals":{"state":"ready","pending":2}}""",
)
}
val repository = WearGatewayRepository(requester)
val pulse =
repository.agentPulse(
expectedNodeId = "phone-a",
capabilities = setOf(WearProxyCapability.AgentPulse),
selectedSessionKey = "agent:main",
)
assertEquals(WearAgentPulseTaskState.Ready, pulse.tasks.state)
assertEquals(2, pulse.tasks.queued)
assertEquals(3, pulse.tasks.running)
assertEquals(5, pulse.tasks.completed)
assertEquals(1, pulse.tasks.failed)
assertEquals(false, pulse.tasks.activeAtLimit)
assertEquals(true, pulse.tasks.recentAtLimit)
assertEquals(WearAgentPulseSwarmState.Active, pulse.swarm.state)
assertEquals(2, pulse.swarm.groups)
assertEquals(4, pulse.swarm.running)
assertEquals(6, pulse.swarm.done)
assertEquals(1, pulse.swarm.failed)
assertEquals(WearAgentPulsePhase(1, 2, 3, 0, 4), pulse.swarm.phases.single())
assertEquals(false, pulse.swarm.morePhases)
assertEquals(WearAgentPulseApprovalsState.Ready, pulse.approvals.state)
assertEquals(2, pulse.approvals.pending)
assertEquals(7L, pulse.eventSequence)
assertEquals("phone-a", pulse.phoneNodeId)
assertEquals(
json.parseToJsonElement("""{"sessionKey":"agent:main"}""").jsonObject,
requester.calls.single().second,
)
assertEquals("phone-a", requester.expectedNodeIds.single())
assertTrue(requester.requirePreferredNodes.single())
}
@Test
fun agentPulseKeepsUnavailableIdleAndRefreshingDistinctWithoutInventingZeroes() =
runTest {
val requester =
RecordingRequester { _, _ ->
json.parseToJsonElement(
"""{"tasks":{"state":"unavailable"},"swarm":{"state":"idle","scope":"selected-session"},"approvals":{"state":"refreshing"}}""",
)
}
val pulse =
WearGatewayRepository(requester).agentPulse(
expectedNodeId = "phone-a",
capabilities = setOf(WearProxyCapability.AgentPulse),
selectedSessionKey = " ",
)
assertEquals(WearAgentPulseTaskState.Unavailable, pulse.tasks.state)
assertNull(pulse.tasks.running)
assertEquals(WearAgentPulseSwarmState.Idle, pulse.swarm.state)
assertNull(pulse.swarm.groups)
assertEquals(WearAgentPulseApprovalsState.Refreshing, pulse.approvals.state)
assertNull(pulse.approvals.pending)
assertTrue(
requester.calls
.single()
.second
.isEmpty(),
)
}
@Test
fun agentPulseRejectsUnknownStatesNegativeCountsAndOversizedPhaseLists() =
runTest {
val invalidPayloads =
listOf(
"""{"tasks":{"state":"future"},"swarm":{"state":"unavailable"},"approvals":{"state":"unavailable"}}""",
"""{"tasks":{"state":"ready","scope":"bounded","queued":-1,"running":0,"completed":0,"failed":0,"activeAtLimit":false,"recentAtLimit":false},"swarm":{"state":"unavailable"},"approvals":{"state":"unavailable"}}""",
"""{"tasks":{"state":"unavailable"},"swarm":{"state":"active","scope":"selected-session","groups":1,"running":0,"done":0,"failed":0,"phases":[{"queued":0,"running":0,"done":0,"failed":0,"hidden":-1}],"morePhases":false},"approvals":{"state":"unavailable"}}""",
"""{"tasks":{"state":"unavailable"},"swarm":{"state":"unavailable"},"approvals":{"state":"ready","pending":-1}}""",
"""{"tasks":{"state":"unavailable"},"swarm":{"state":"active","scope":"selected-session","groups":1,"running":0,"done":0,"failed":0,"phases":[{},{},{},{},{},{},{},{},{}],"morePhases":true},"approvals":{"state":"unavailable"}}""",
)
invalidPayloads.forEach { payload ->
val requester = RecordingRequester { _, _ -> json.parseToJsonElement(payload) }
val failure =
runCatching {
WearGatewayRepository(requester).agentPulse(
expectedNodeId = "phone-a",
capabilities = setOf(WearProxyCapability.AgentPulse),
)
}.exceptionOrNull()
assertEquals("invalid_response", (failure as? WearProxyException)?.code)
}
}
@Test
fun sessionsAndHistoryParseOnlyProjectedContract() =
runTest {
@@ -86,7 +207,7 @@ class WearGatewayRepositoryTest {
WearRpcMethod.AgentsSelect -> JsonObject(emptyMap())
WearRpcMethod.GatewayDisconnect ->
json.parseToJsonElement(
"""{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""",
"""{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""",
)
else -> error("unexpected $method")
}
@@ -154,7 +275,7 @@ class WearGatewayRepositoryTest {
val requester =
RecordingRequester { _, _ ->
json.parseToJsonElement(
"""{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","attempt-scoped-realtime-audio"]}""",
"""{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""",
)
}
@@ -34,6 +34,26 @@ class WearLaunchIntentTest {
)
}
@Test
fun olderPhoneKeepsOriginalHomePages() {
assertEquals(
listOf(WearHomePage.Chat, WearHomePage.Voice, WearHomePage.Controls),
wearHomePages(agentPulseSupported = false),
)
}
@Test
fun capablePhoneAppendsPulseWithoutChangingExistingHomePageOrdinals() {
assertEquals(0, WearHomePage.Chat.ordinal)
assertEquals(1, WearHomePage.Voice.ordinal)
assertEquals(2, WearHomePage.Controls.ordinal)
assertEquals(3, WearHomePage.Pulse.ordinal)
assertEquals(
listOf(WearHomePage.Chat, WearHomePage.Voice, WearHomePage.Controls, WearHomePage.Pulse),
wearHomePages(agentPulseSupported = true),
)
}
@Test
fun tileTalkLaunchStartsOnVoice() {
val target =
@@ -697,6 +697,26 @@ class WearProxyClientTest {
assertTrue(tracker.isResponseCurrent(newerRequest, "stream", 10))
}
@Test
fun readOnlyRpcCursorDoesNotInvalidateAnOverlappingModelRequest() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("stream", 10)
val pulseBeforeModel = tracker.beginReadOnlyResponseRequest()
val modelRequest = tracker.beginResponseRequest()
val pulseRequest = tracker.beginReadOnlyResponseRequest()
tracker.beginReadOnlyResponseRequest()
assertTrue(tracker.isReadOnlyResponseCurrent(pulseBeforeModel, "stream", 10))
assertTrue(tracker.isResponseCurrent(modelRequest, "stream", 10))
assertTrue(tracker.isReadOnlyResponseCurrent(pulseRequest, "stream", 10))
val staleLegacyPulse = tracker.beginReadOnlyResponseRequest()
assertEquals(WearSequenceDecision.Accepted, tracker.accept("stream", 11))
assertFalse(tracker.isReadOnlyResponseCurrent(staleLegacyPulse, null, null))
assertFalse(tracker.isReadOnlyResponseCurrent(pulseRequest, "stream", 10))
}
@Test
fun resyncBufferReplaysEventsNewerThanTheSnapshotWatermark() {
val tracker = WearEventSequenceTracker()
@@ -1,5 +1,6 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearProxyCapability
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
@@ -7,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -39,6 +41,110 @@ class WearViewModelLifecycleTest {
}
}
@Test
fun agentPulsePollingRequiresVisibleConnectedCapablePreferredRoute() {
val connected =
WearUiState(
loading = false,
connected = true,
phoneNodeId = "phone-a",
proxyCapabilities = setOf(WearProxyCapability.AgentPulse),
)
assertTrue(shouldPollAgentPulse(connected, pulseVisible = true))
assertFalse(shouldPollAgentPulse(connected, pulseVisible = false))
assertFalse(shouldPollAgentPulse(connected.copy(connected = false), pulseVisible = true))
assertFalse(shouldPollAgentPulse(connected.copy(phoneNodeId = null), pulseVisible = true))
assertFalse(
shouldPollAgentPulse(
connected.copy(proxyCapabilities = emptySet()),
pulseVisible = true,
),
)
}
@Test
fun agentPulseRouteRejectsPhoneAgentSessionAndGenerationChanges() {
val session =
WearSession(
key = "agent:main:one",
title = "One",
updatedAt = null,
hasActiveRun = false,
phoneNodeId = "phone-a",
agentId = "main",
)
val current =
WearUiState(
loading = false,
connected = true,
phoneNodeId = "phone-a",
activeAgentId = "main",
proxyCapabilities = setOf(WearProxyCapability.AgentPulse),
selectedSession = session,
)
fun accepts(
state: WearUiState = current,
routeGeneration: Long = 7L,
requestGeneration: Long = 11L,
): Boolean =
wearAgentPulseRouteIsCurrent(
requestedPhoneNodeId = "phone-a",
requestedAgentId = "main",
requestedSessionKey = session.key,
requestedRouteGeneration = 7L,
currentRouteGeneration = routeGeneration,
requestedGeneration = 11L,
currentGeneration = requestGeneration,
pulseVisible = true,
state = state,
)
assertTrue(accepts())
assertFalse(accepts(current.copy(phoneNodeId = "phone-b")))
assertFalse(accepts(current.copy(activeAgentId = "secondary")))
assertFalse(accepts(current.copy(selectedSession = session.copy(key = "agent:main:two"))))
assertFalse(accepts(routeGeneration = 8L))
assertFalse(accepts(requestGeneration = 12L))
}
@Test
fun hidingAgentPulseCancelsTheSinglePoller() {
val app = RuntimeEnvironment.getApplication() as WearApplication
val factory = ViewModelProvider.AndroidViewModelFactory.getInstance(app)
val owner = TestViewModelStoreOwner()
val viewModel = ViewModelProvider(owner, factory)[WearViewModel::class.java]
val pollJob = Job()
try {
viewModel.setAgentPulseVisibleForTest(true)
viewModel.setAgentPulsePollJobForTest(pollJob)
viewModel.setAgentPulseVisible(false)
assertFalse(pollJob.isActive)
assertNull(viewModel.agentPulsePollJobForTest())
assertFalse(viewModel.state.value.agentPulseLoading)
} finally {
owner.viewModelStore.clear()
}
}
@Test
fun clearingViewModelCancelsTheAgentPulsePoller() {
val app = RuntimeEnvironment.getApplication() as WearApplication
val factory = ViewModelProvider.AndroidViewModelFactory.getInstance(app)
val owner = TestViewModelStoreOwner()
val viewModel = ViewModelProvider(owner, factory)[WearViewModel::class.java]
val pollJob = Job()
viewModel.setAgentPulsePollJobForTest(pollJob)
owner.viewModelStore.clear()
assertFalse(pollJob.isActive)
assertNull(viewModel.agentPulsePollJobForTest())
}
private class TestViewModelStoreOwner : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
@@ -49,6 +155,26 @@ class WearViewModelLifecycleTest {
get(this@realtimeTalkClientForTest) as WearRealtimeTalkClient
}
private fun WearViewModel.setAgentPulseVisibleForTest(visible: Boolean) {
javaClass.getDeclaredField("agentPulseVisible").run {
isAccessible = true
setBoolean(this@setAgentPulseVisibleForTest, visible)
}
}
private fun WearViewModel.setAgentPulsePollJobForTest(job: Job?) {
javaClass.getDeclaredField("agentPulsePollJob").run {
isAccessible = true
set(this@setAgentPulsePollJobForTest, job)
}
}
private fun WearViewModel.agentPulsePollJobForTest(): Job? =
javaClass.getDeclaredField("agentPulsePollJob").run {
isAccessible = true
get(this@agentPulsePollJobForTest) as? Job
}
private fun WearRealtimeTalkClient.scopeForTest(): CoroutineScope =
javaClass.getDeclaredField("scope").run {
isAccessible = true